diff options
Diffstat (limited to 'Userland/Applications')
285 files changed, 37234 insertions, 0 deletions
diff --git a/Userland/Applications/About/CMakeLists.txt b/Userland/Applications/About/CMakeLists.txt new file mode 100644 index 0000000000..0b3023c819 --- /dev/null +++ b/Userland/Applications/About/CMakeLists.txt @@ -0,0 +1,12 @@ +set(SOURCES + main.cpp +) + +execute_process(COMMAND "git rev-parse --short HEAD" OUTPUT_VARIABLE GIT_COMMIT) +execute_process(COMMAND "git rev-parse --abbrev-ref HEAD" OUTPUT_VARIABLE GIT_BRANCH) +execute_process(COMMAND "git diff-index --quiet HEAD -- && echo tracked || echo untracked" OUTPUT_VARIABLE GIT_CHANGES) + +add_definitions(-DGIT_COMMIT="${GIT_COMMIT}" -DGIT_BRANCH="${GIT_BRANCH}" -DGIT_CHANGES="${GIT_CHANGES}") + +serenity_bin(About) +target_link_libraries(About LibGUI) diff --git a/Userland/Applications/About/main.cpp b/Userland/Applications/About/main.cpp new file mode 100644 index 0000000000..b7e18caee3 --- /dev/null +++ b/Userland/Applications/About/main.cpp @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include <LibGUI/AboutDialog.h> +#include <LibGUI/Application.h> +#include <LibGUI/Icon.h> +#include <LibGfx/Bitmap.h> +#include <stdio.h> +#include <sys/utsname.h> + +int main(int argc, char** argv) +{ + if (pledge("stdio shared_buffer accept rpath unix cpath fattr", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio shared_buffer accept rpath", nullptr) < 0) { + perror("pledge"); + return 1; + } + + if (unveil("/res", "r") < 0) { + perror("unveil"); + return 1; + } + + unveil(nullptr, nullptr); + + auto app_icon = GUI::Icon::default_icon("ladybug"); + GUI::AboutDialog::show("SerenityOS", nullptr, nullptr, app_icon.bitmap_for_size(32)); + return app->exec(); +} diff --git a/Userland/Applications/Browser/BookmarksBarWidget.cpp b/Userland/Applications/Browser/BookmarksBarWidget.cpp new file mode 100644 index 0000000000..ef9af48a60 --- /dev/null +++ b/Userland/Applications/Browser/BookmarksBarWidget.cpp @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2020, Emanuel Sprung <emanuel.sprung@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "BookmarksBarWidget.h" +#include <LibGUI/Action.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/Event.h> +#include <LibGUI/JsonArrayModel.h> +#include <LibGUI/Menu.h> +#include <LibGUI/Model.h> +#include <LibGUI/Widget.h> +#include <LibGUI/Window.h> +#include <LibGfx/Palette.h> + +namespace Browser { + +static BookmarksBarWidget* s_the; + +BookmarksBarWidget& BookmarksBarWidget::the() +{ + return *s_the; +} + +BookmarksBarWidget::BookmarksBarWidget(const String& bookmarks_file, bool enabled) +{ + s_the = this; + set_layout<GUI::HorizontalBoxLayout>(); + layout()->set_spacing(0); + + set_fixed_height(20); + + if (!enabled) + set_visible(false); + + m_additional = GUI::Button::construct(); + m_additional->set_button_style(Gfx::ButtonStyle::CoolBar); + m_additional->set_text(">"); + m_additional->set_fixed_size(14, 20); + m_additional->set_focus_policy(GUI::FocusPolicy::TabFocus); + m_additional->on_click = [this](auto) { + if (m_additional_menu) { + m_additional_menu->popup(m_additional->relative_position().translated(relative_position().translated(m_additional->window()->position()))); + } + }; + + m_separator = GUI::Widget::construct(); + + m_context_menu = GUI::Menu::construct(); + auto default_action = GUI::Action::create("Open", [this](auto&) { + if (on_bookmark_click) + on_bookmark_click(m_context_menu_url, Mod_None); + }); + m_context_menu_default_action = default_action; + m_context_menu->add_action(default_action); + m_context_menu->add_action(GUI::Action::create("Open in new tab", [this](auto&) { + if (on_bookmark_click) + on_bookmark_click(m_context_menu_url, Mod_Ctrl); + })); + m_context_menu->add_action(GUI::Action::create("Delete", [this](auto&) { + remove_bookmark(m_context_menu_url); + })); + + Vector<GUI::JsonArrayModel::FieldSpec> fields; + fields.empend("title", "Title", Gfx::TextAlignment::CenterLeft); + fields.empend("url", "Url", Gfx::TextAlignment::CenterRight); + set_model(GUI::JsonArrayModel::create(bookmarks_file, move(fields))); + model()->update(); +} + +BookmarksBarWidget::~BookmarksBarWidget() +{ + if (m_model) + m_model->unregister_client(*this); +} + +void BookmarksBarWidget::set_model(RefPtr<GUI::Model> model) +{ + if (model == m_model) + return; + if (m_model) + m_model->unregister_client(*this); + m_model = move(model); + m_model->register_client(*this); +} + +void BookmarksBarWidget::resize_event(GUI::ResizeEvent& event) +{ + Widget::resize_event(event); + update_content_size(); +} + +void BookmarksBarWidget::model_did_update(unsigned) +{ + remove_all_children(); + + m_bookmarks.clear(); + + int width = 0; + for (int item_index = 0; item_index < model()->row_count(); ++item_index) { + + auto title = model()->index(item_index, 0).data().to_string(); + auto url = model()->index(item_index, 1).data().to_string(); + + Gfx::IntRect rect { width, 0, font().width(title) + 32, height() }; + + auto& button = add<GUI::Button>(); + m_bookmarks.append(button); + + button.set_button_style(Gfx::ButtonStyle::CoolBar); + button.set_text(title); + button.set_icon(Gfx::Bitmap::load_from_file("/res/icons/16x16/filetype-html.png")); + button.set_fixed_size(font().width(title) + 32, 20); + button.set_relative_rect(rect); + button.set_focus_policy(GUI::FocusPolicy::TabFocus); + button.set_tooltip(url); + + button.on_click = [title, url, this](auto modifiers) { + if (on_bookmark_click) + on_bookmark_click(url, modifiers); + }; + + button.on_context_menu_request = [this, url](auto& context_menu_event) { + m_context_menu_url = url; + m_context_menu->popup(context_menu_event.screen_position(), m_context_menu_default_action); + }; + + width += rect.width(); + } + + add_child(*m_separator); + add_child(*m_additional); + + update_content_size(); + update(); +} + +void BookmarksBarWidget::update_content_size() +{ + int x_position = 0; + m_last_visible_index = -1; + + for (size_t i = 0; i < m_bookmarks.size(); ++i) { + auto& bookmark = m_bookmarks.at(i); + if (x_position + bookmark.width() > width()) { + m_last_visible_index = i; + break; + } + bookmark.set_x(x_position); + bookmark.set_visible(true); + x_position += bookmark.width(); + } + + if (m_last_visible_index < 0) { + m_additional->set_visible(false); + } else { + // hide all items > m_last_visible_index and create new bookmarks menu for them + m_additional->set_visible(true); + m_additional_menu = GUI::Menu::construct("Additional Bookmarks"); + for (size_t i = m_last_visible_index; i < m_bookmarks.size(); ++i) { + auto& bookmark = m_bookmarks.at(i); + bookmark.set_visible(false); + m_additional_menu->add_action(GUI::Action::create(bookmark.text(), + Gfx::Bitmap::load_from_file("/res/icons/16x16/filetype-html.png"), + [&](auto&) { + bookmark.on_click(0); + })); + } + } +} + +bool BookmarksBarWidget::contains_bookmark(const String& url) +{ + for (int item_index = 0; item_index < model()->row_count(); ++item_index) { + + auto item_title = model()->index(item_index, 0).data().to_string(); + auto item_url = model()->index(item_index, 1).data().to_string(); + if (item_url == url) { + return true; + } + } + return false; +} + +bool BookmarksBarWidget::remove_bookmark(const String& url) +{ + for (int item_index = 0; item_index < model()->row_count(); ++item_index) { + + auto item_title = model()->index(item_index, 0).data().to_string(); + auto item_url = model()->index(item_index, 1).data().to_string(); + if (item_url == url) { + auto& json_model = *static_cast<GUI::JsonArrayModel*>(model()); + + const auto item_removed = json_model.remove(item_index); + if (item_removed) + json_model.store(); + + return item_removed; + } + } + + return false; +} +bool BookmarksBarWidget::add_bookmark(const String& url, const String& title) +{ + Vector<JsonValue> values; + values.append(title); + values.append(url); + + auto& json_model = *static_cast<GUI::JsonArrayModel*>(model()); + if (json_model.add(move(values))) { + json_model.store(); + return true; + } + return false; +} + +} diff --git a/Userland/Applications/Browser/BookmarksBarWidget.h b/Userland/Applications/Browser/BookmarksBarWidget.h new file mode 100644 index 0000000000..2eeed8fec5 --- /dev/null +++ b/Userland/Applications/Browser/BookmarksBarWidget.h @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2020, Emanuel Sprung <emanuel.sprung@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Forward.h> +#include <LibGUI/Model.h> +#include <LibGUI/Widget.h> + +namespace Browser { + +class BookmarksBarWidget final + : public GUI::Widget + , private GUI::ModelClient { + C_OBJECT(BookmarksBarWidget); + +public: + static BookmarksBarWidget& the(); + + virtual ~BookmarksBarWidget() override; + + void set_model(RefPtr<GUI::Model>); + GUI::Model* model() { return m_model.ptr(); } + const GUI::Model* model() const { return m_model.ptr(); } + + Function<void(const String& url, unsigned modifiers)> on_bookmark_click; + Function<void(const String&, const String&)> on_bookmark_hover; + + bool contains_bookmark(const String& url); + bool remove_bookmark(const String& url); + bool add_bookmark(const String& url, const String& title); + +private: + BookmarksBarWidget(const String&, bool enabled); + + // ^GUI::ModelClient + virtual void model_did_update(unsigned) override; + + // ^GUI::Widget + virtual void resize_event(GUI::ResizeEvent&) override; + + void update_content_size(); + + RefPtr<GUI::Model> m_model; + RefPtr<GUI::Button> m_additional; + RefPtr<GUI::Widget> m_separator; + RefPtr<GUI::Menu> m_additional_menu; + + RefPtr<GUI::Menu> m_context_menu; + RefPtr<GUI::Action> m_context_menu_default_action; + String m_context_menu_url; + + NonnullRefPtrVector<GUI::Button> m_bookmarks; + + int m_last_visible_index { -1 }; +}; + +} diff --git a/Userland/Applications/Browser/Browser.h b/Userland/Applications/Browser/Browser.h new file mode 100644 index 0000000000..a4a4eefe1f --- /dev/null +++ b/Userland/Applications/Browser/Browser.h @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/String.h> + +namespace Browser { + +extern String g_home_url; + +} diff --git a/Userland/Applications/Browser/BrowserConsoleClient.cpp b/Userland/Applications/Browser/BrowserConsoleClient.cpp new file mode 100644 index 0000000000..ce1dd7e5cb --- /dev/null +++ b/Userland/Applications/Browser/BrowserConsoleClient.cpp @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2020, Hunter Salyer <thefalsehonesty@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "BrowserConsoleClient.h" +#include "ConsoleWidget.h" +#include <AK/StringBuilder.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/JSSyntaxHighlighter.h> +#include <LibGUI/TextBox.h> +#include <LibWeb/DOM/DocumentType.h> +#include <LibWeb/DOM/ElementFactory.h> +#include <LibWeb/DOM/Text.h> +#include <LibWeb/DOMTreeModel.h> +#include <LibWeb/HTML/HTMLBodyElement.h> + +namespace Browser { + +JS::Value BrowserConsoleClient::log() +{ + m_console_widget.print_html(vm().join_arguments()); + return JS::js_undefined(); +} + +JS::Value BrowserConsoleClient::info() +{ + StringBuilder html; + html.append("<span class=\"info\">"); + html.append("(i) "); + html.append(vm().join_arguments()); + html.append("</span>"); + m_console_widget.print_html(html.string_view()); + return JS::js_undefined(); +} + +JS::Value BrowserConsoleClient::debug() +{ + StringBuilder html; + html.append("<span class=\"debug\">"); + html.append("(d) "); + html.append(vm().join_arguments()); + html.append("</span>"); + m_console_widget.print_html(html.string_view()); + return JS::js_undefined(); +} + +JS::Value BrowserConsoleClient::warn() +{ + StringBuilder html; + html.append("<span class=\"warn\">"); + html.append("(w) "); + html.append(vm().join_arguments()); + html.append("</span>"); + m_console_widget.print_html(html.string_view()); + return JS::js_undefined(); +} + +JS::Value BrowserConsoleClient::error() +{ + StringBuilder html; + html.append("<span class=\"error\">"); + html.append("(e) "); + html.append(vm().join_arguments()); + html.append("</span>"); + m_console_widget.print_html(html.string_view()); + return JS::js_undefined(); +} + +JS::Value BrowserConsoleClient::clear() +{ + m_console_widget.clear_output(); + return JS::js_undefined(); +} + +JS::Value BrowserConsoleClient::trace() +{ + StringBuilder html; + html.append(vm().join_arguments()); + auto trace = get_trace(); + for (auto& function_name : trace) { + if (function_name.is_empty()) + function_name = "<anonymous>"; + html.appendff(" -> {}<br>", function_name); + } + m_console_widget.print_html(html.string_view()); + return JS::js_undefined(); +} + +JS::Value BrowserConsoleClient::count() +{ + auto label = vm().argument_count() ? vm().argument(0).to_string_without_side_effects() : "default"; + auto counter_value = m_console.counter_increment(label); + m_console_widget.print_html(String::formatted("{}: {}", label, counter_value)); + return JS::js_undefined(); +} + +JS::Value BrowserConsoleClient::count_reset() +{ + auto label = vm().argument_count() ? vm().argument(0).to_string_without_side_effects() : "default"; + if (m_console.counter_reset(label)) { + m_console_widget.print_html(String::formatted("{}: 0", label)); + } else { + m_console_widget.print_html(String::formatted("\"{}\" doesn't have a count", label)); + } + return JS::js_undefined(); +} + +} diff --git a/Userland/Applications/Browser/BrowserConsoleClient.h b/Userland/Applications/Browser/BrowserConsoleClient.h new file mode 100644 index 0000000000..e87f6e32b0 --- /dev/null +++ b/Userland/Applications/Browser/BrowserConsoleClient.h @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2020, Hunter Salyer <thefalsehonesty@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Widget.h> +#include <LibJS/Console.h> +#include <LibJS/Forward.h> +#include <LibWeb/InProcessWebView.h> + +namespace Browser { + +class ConsoleWidget; + +class BrowserConsoleClient final : public JS::ConsoleClient { +public: + BrowserConsoleClient(JS::Console& console, ConsoleWidget& console_widget) + : ConsoleClient(console) + , m_console_widget(console_widget) + { + } + +private: + virtual JS::Value log() override; + virtual JS::Value info() override; + virtual JS::Value debug() override; + virtual JS::Value warn() override; + virtual JS::Value error() override; + virtual JS::Value clear() override; + virtual JS::Value trace() override; + virtual JS::Value count() override; + virtual JS::Value count_reset() override; + + ConsoleWidget& m_console_widget; +}; + +} diff --git a/Userland/Applications/Browser/BrowserWindow.gml b/Userland/Applications/Browser/BrowserWindow.gml new file mode 100644 index 0000000000..f6ec7ef1af --- /dev/null +++ b/Userland/Applications/Browser/BrowserWindow.gml @@ -0,0 +1,15 @@ +@GUI::Widget { + name: "browser" + fill_with_background_color: true + + layout: @GUI::VerticalBoxLayout { + spacing: 2 + } + + @GUI::TabWidget { + name: "tab_widget" + container_padding: 0 + uniform_tabs: true + text_alignment: "CenterLeft" + } +} diff --git a/Userland/Applications/Browser/CMakeLists.txt b/Userland/Applications/Browser/CMakeLists.txt new file mode 100644 index 0000000000..4652ec8b11 --- /dev/null +++ b/Userland/Applications/Browser/CMakeLists.txt @@ -0,0 +1,19 @@ +compile_gml(BrowserWindow.gml BrowserWindowGML.h browser_window_gml) +compile_gml(Tab.gml TabGML.h tab_gml) + +set(SOURCES + BookmarksBarWidget.cpp + BrowserConsoleClient.cpp + ConsoleWidget.cpp + DownloadWidget.cpp + History.cpp + InspectorWidget.cpp + main.cpp + Tab.cpp + WindowActions.cpp + BrowserWindowGML.h + TabGML.h +) + +serenity_app(Browser ICON app-browser) +target_link_libraries(Browser LibWeb LibProtocol LibGUI LibDesktop) diff --git a/Userland/Applications/Browser/ConsoleWidget.cpp b/Userland/Applications/Browser/ConsoleWidget.cpp new file mode 100644 index 0000000000..55c545dc55 --- /dev/null +++ b/Userland/Applications/Browser/ConsoleWidget.cpp @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2020, Hunter Salyer <thefalsehonesty@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "ConsoleWidget.h" +#include <AK/StringBuilder.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/JSSyntaxHighlighter.h> +#include <LibGUI/TextBox.h> +#include <LibGfx/FontDatabase.h> +#include <LibJS/Interpreter.h> +#include <LibJS/MarkupGenerator.h> +#include <LibJS/Parser.h> +#include <LibJS/Runtime/Error.h> +#include <LibWeb/DOM/DocumentType.h> +#include <LibWeb/DOM/ElementFactory.h> +#include <LibWeb/DOM/Text.h> +#include <LibWeb/DOMTreeModel.h> +#include <LibWeb/HTML/HTMLBodyElement.h> + +namespace Browser { + +ConsoleWidget::ConsoleWidget() +{ + set_layout<GUI::VerticalBoxLayout>(); + set_fill_with_background_color(true); + + auto base_document = Web::DOM::Document::create(); + base_document->append_child(adopt(*new Web::DOM::DocumentType(base_document))); + auto html_element = base_document->create_element("html"); + base_document->append_child(html_element); + auto head_element = base_document->create_element("head"); + html_element->append_child(head_element); + auto body_element = base_document->create_element("body"); + html_element->append_child(body_element); + m_output_container = body_element; + + m_output_view = add<Web::InProcessWebView>(); + m_output_view->set_document(base_document); + + auto& bottom_container = add<GUI::Widget>(); + bottom_container.set_layout<GUI::HorizontalBoxLayout>(); + bottom_container.set_fixed_height(22); + + m_input = bottom_container.add<GUI::TextBox>(); + m_input->set_syntax_highlighter(make<GUI::JSSyntaxHighlighter>()); + // FIXME: Syntax Highlighting breaks the cursor's position on non fixed-width fonts. + m_input->set_font(Gfx::FontDatabase::default_fixed_width_font()); + m_input->set_history_enabled(true); + + m_input->on_return_pressed = [this] { + auto js_source = m_input->text(); + + // FIXME: An is_blank check to check if there is only whitespace would probably be preferable. + if (js_source.is_empty()) + return; + + m_input->add_current_text_to_history(); + m_input->clear(); + + print_source_line(js_source); + + auto parser = JS::Parser(JS::Lexer(js_source)); + auto program = parser.parse_program(); + + StringBuilder output_html; + if (parser.has_errors()) { + auto error = parser.errors()[0]; + auto hint = error.source_location_hint(js_source); + if (!hint.is_empty()) + output_html.append(String::formatted("<pre>{}</pre>", escape_html_entities(hint))); + m_interpreter->vm().throw_exception<JS::SyntaxError>(m_interpreter->global_object(), error.to_string()); + } else { + m_interpreter->run(m_interpreter->global_object(), *program); + } + + if (m_interpreter->exception()) { + output_html.append("Uncaught exception: "); + output_html.append(JS::MarkupGenerator::html_from_value(m_interpreter->exception()->value())); + print_html(output_html.string_view()); + + m_interpreter->vm().clear_exception(); + return; + } + + print_html(JS::MarkupGenerator::html_from_value(m_interpreter->vm().last_value())); + }; + + set_focus_proxy(m_input); + + auto& clear_button = bottom_container.add<GUI::Button>(); + clear_button.set_fixed_size(22, 22); + clear_button.set_icon(Gfx::Bitmap::load_from_file("/res/icons/16x16/delete.png")); + clear_button.set_tooltip("Clear the console output"); + clear_button.on_click = [this](auto) { + clear_output(); + }; +} + +ConsoleWidget::~ConsoleWidget() +{ +} + +void ConsoleWidget::set_interpreter(WeakPtr<JS::Interpreter> interpreter) +{ + if (m_interpreter.ptr() == interpreter.ptr()) + return; + + m_interpreter = interpreter; + m_console_client = make<BrowserConsoleClient>(interpreter->global_object().console(), *this); + interpreter->global_object().console().set_client(*m_console_client.ptr()); + + clear_output(); +} + +void ConsoleWidget::print_source_line(const StringView& source) +{ + StringBuilder html; + html.append("<span class=\"repl-indicator\">"); + html.append("> "); + html.append("</span>"); + + html.append(JS::MarkupGenerator::html_from_source(source)); + + print_html(html.string_view()); +} + +void ConsoleWidget::print_html(const StringView& line) +{ + auto paragraph = m_output_container->document().create_element("p"); + paragraph->set_inner_html(line); + + m_output_container->append_child(paragraph); + m_output_container->document().invalidate_layout(); + m_output_container->document().update_layout(); + + m_output_view->scroll_to_bottom(); +} + +void ConsoleWidget::clear_output() +{ + m_output_container->remove_all_children(); + m_output_view->update(); +} + +} diff --git a/Userland/Applications/Browser/ConsoleWidget.h b/Userland/Applications/Browser/ConsoleWidget.h new file mode 100644 index 0000000000..813ceed2ed --- /dev/null +++ b/Userland/Applications/Browser/ConsoleWidget.h @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2020, Hunter Salyer <thefalsehonesty@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "BrowserConsoleClient.h" +#include "History.h" +#include <LibGUI/Widget.h> +#include <LibJS/Forward.h> +#include <LibWeb/InProcessWebView.h> + +namespace Browser { + +class ConsoleWidget final : public GUI::Widget { + C_OBJECT(ConsoleWidget) +public: + virtual ~ConsoleWidget(); + + void set_interpreter(WeakPtr<JS::Interpreter>); + void print_source_line(const StringView&); + void print_html(const StringView&); + void clear_output(); + +private: + ConsoleWidget(); + + RefPtr<GUI::TextBox> m_input; + RefPtr<Web::InProcessWebView> m_output_view; + RefPtr<Web::DOM::Element> m_output_container; + WeakPtr<JS::Interpreter> m_interpreter; + OwnPtr<BrowserConsoleClient> m_console_client; +}; + +} diff --git a/Userland/Applications/Browser/DownloadWidget.cpp b/Userland/Applications/Browser/DownloadWidget.cpp new file mode 100644 index 0000000000..39d931e216 --- /dev/null +++ b/Userland/Applications/Browser/DownloadWidget.cpp @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "DownloadWidget.h" +#include <AK/NumberFormat.h> +#include <AK/SharedBuffer.h> +#include <AK/StringBuilder.h> +#include <LibCore/File.h> +#include <LibCore/FileStream.h> +#include <LibCore/StandardPaths.h> +#include <LibDesktop/Launcher.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/ImageWidget.h> +#include <LibGUI/Label.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/ProgressBar.h> +#include <LibGUI/Window.h> +#include <LibProtocol/Client.h> +#include <LibWeb/Loader/ResourceLoader.h> +#include <math.h> + +namespace Browser { + +DownloadWidget::DownloadWidget(const URL& url) + : m_url(url) +{ + { + StringBuilder builder; + builder.append(Core::StandardPaths::downloads_directory()); + builder.append('/'); + builder.append(m_url.basename()); + m_destination_path = builder.to_string(); + } + + m_elapsed_timer.start(); + m_download = Web::ResourceLoader::the().protocol_client().start_download("GET", url.to_string()); + ASSERT(m_download); + m_download->on_progress = [this](Optional<u32> total_size, u32 downloaded_size) { + did_progress(total_size.value(), downloaded_size); + }; + + { + auto file_or_error = Core::File::open(m_destination_path, Core::IODevice::WriteOnly); + if (file_or_error.is_error()) { + GUI::MessageBox::show(window(), String::formatted("Cannot open {} for writing", m_destination_path), "Download failed", GUI::MessageBox::Type::Error); + window()->close(); + return; + } + m_output_file_stream = make<Core::OutputFileStream>(*file_or_error.value()); + } + + m_download->on_finish = [this](bool success, auto) { did_finish(success); }; + m_download->stream_into(*m_output_file_stream); + + set_fill_with_background_color(true); + auto& layout = set_layout<GUI::VerticalBoxLayout>(); + layout.set_margins({ 4, 4, 4, 4 }); + + auto& animation_container = add<GUI::Widget>(); + animation_container.set_fixed_height(32); + auto& animation_layout = animation_container.set_layout<GUI::HorizontalBoxLayout>(); + + auto& browser_image = animation_container.add<GUI::ImageWidget>(); + browser_image.load_from_file("/res/graphics/download-animation.gif"); + animation_layout.add_spacer(); + + auto& source_label = add<GUI::Label>(String::formatted("From: {}", url)); + source_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + source_label.set_fixed_height(16); + + m_progress_bar = add<GUI::ProgressBar>(); + m_progress_bar->set_fixed_height(20); + + m_progress_label = add<GUI::Label>(); + m_progress_label->set_text_alignment(Gfx::TextAlignment::CenterLeft); + m_progress_label->set_fixed_height(16); + + auto& destination_label = add<GUI::Label>(String::formatted("To: {}", m_destination_path)); + destination_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + destination_label.set_fixed_height(16); + + auto& button_container = add<GUI::Widget>(); + auto& button_container_layout = button_container.set_layout<GUI::HorizontalBoxLayout>(); + button_container_layout.add_spacer(); + m_cancel_button = button_container.add<GUI::Button>("Cancel"); + m_cancel_button->set_fixed_size(100, 22); + m_cancel_button->on_click = [this](auto) { + bool success = m_download->stop(); + ASSERT(success); + window()->close(); + }; + + m_close_button = button_container.add<GUI::Button>("OK"); + m_close_button->set_enabled(false); + m_close_button->set_fixed_size(100, 22); + m_close_button->on_click = [this](auto) { + window()->close(); + }; +} + +DownloadWidget::~DownloadWidget() +{ +} + +void DownloadWidget::did_progress(Optional<u32> total_size, u32 downloaded_size) +{ + m_progress_bar->set_min(0); + if (total_size.has_value()) { + int percent = roundf(((float)downloaded_size / (float)total_size.value()) * 100.0f); + window()->set_progress(percent); + m_progress_bar->set_max(total_size.value()); + } else { + m_progress_bar->set_max(0); + } + m_progress_bar->set_value(downloaded_size); + + { + StringBuilder builder; + builder.append("Downloaded "); + builder.append(human_readable_size(downloaded_size)); + builder.appendff(" in {} sec", m_elapsed_timer.elapsed() / 1000); + m_progress_label->set_text(builder.to_string()); + } + + { + StringBuilder builder; + if (total_size.has_value()) { + int percent = roundf(((float)downloaded_size / (float)total_size.value()) * 100); + builder.appendff("{}%", percent); + } else { + builder.append(human_readable_size(downloaded_size)); + } + builder.append(" of "); + builder.append(m_url.basename()); + window()->set_title(builder.to_string()); + } +} + +void DownloadWidget::did_finish(bool success) +{ + dbgln("did_finish, success={}", success); + + m_close_button->set_enabled(true); + m_cancel_button->set_text("Open in Folder"); + m_cancel_button->on_click = [this](auto) { + Desktop::Launcher::open(URL::create_with_file_protocol(Core::StandardPaths::downloads_directory())); + window()->close(); + }; + m_cancel_button->update(); + + if (!success) { + GUI::MessageBox::show(window(), String::formatted("Download failed for some reason"), "Download failed", GUI::MessageBox::Type::Error); + window()->close(); + return; + } +} + +} diff --git a/Userland/Applications/Browser/DownloadWidget.h b/Userland/Applications/Browser/DownloadWidget.h new file mode 100644 index 0000000000..44fc21c60d --- /dev/null +++ b/Userland/Applications/Browser/DownloadWidget.h @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/URL.h> +#include <LibCore/ElapsedTimer.h> +#include <LibCore/FileStream.h> +#include <LibGUI/ProgressBar.h> +#include <LibGUI/Widget.h> +#include <LibProtocol/Download.h> + +namespace Browser { + +class DownloadWidget final : public GUI::Widget { + C_OBJECT(DownloadWidget); + +public: + virtual ~DownloadWidget() override; + +private: + explicit DownloadWidget(const URL&); + + void did_progress(Optional<u32> total_size, u32 downloaded_size); + void did_finish(bool success); + + URL m_url; + String m_destination_path; + RefPtr<Protocol::Download> m_download; + RefPtr<GUI::ProgressBar> m_progress_bar; + RefPtr<GUI::Label> m_progress_label; + RefPtr<GUI::Button> m_cancel_button; + RefPtr<GUI::Button> m_close_button; + OwnPtr<Core::OutputFileStream> m_output_file_stream; + Core::ElapsedTimer m_elapsed_timer; +}; + +} diff --git a/Userland/Applications/Browser/History.cpp b/Userland/Applications/Browser/History.cpp new file mode 100644 index 0000000000..cecf28477c --- /dev/null +++ b/Userland/Applications/Browser/History.cpp @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "History.h" + +namespace Browser { + +void History::dump() const +{ + dbgln("Dump {} items(s)", m_items.size()); + int i = 0; + for (auto& item : m_items) { + dbgln("[{}] {} {}", i, item, m_current == i ? '*' : ' '); + ++i; + } +} + +void History::push(const URL& url) +{ + m_items.shrink(m_current + 1); + m_items.append(url); + m_current++; +} + +URL History::current() const +{ + if (m_current == -1) + return {}; + return m_items[m_current]; +} + +void History::go_back() +{ + ASSERT(can_go_back()); + m_current--; +} + +void History::go_forward() +{ + ASSERT(can_go_forward()); + m_current++; +} + +void History::clear() +{ + m_items = {}; + m_current = -1; +} + +} diff --git a/Userland/Applications/Browser/History.h b/Userland/Applications/Browser/History.h new file mode 100644 index 0000000000..84fc4151d7 --- /dev/null +++ b/Userland/Applications/Browser/History.h @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/URL.h> +#include <AK/Vector.h> + +namespace Browser { + +class History { +public: + void dump() const; + + void push(const URL&); + URL current() const; + + void go_back(); + void go_forward(); + + bool can_go_back() { return m_current > 0; } + bool can_go_forward() { return m_current + 1 < static_cast<int>(m_items.size()); } + + void clear(); + +private: + Vector<URL> m_items; + int m_current { -1 }; +}; + +} diff --git a/Userland/Applications/Browser/InspectorWidget.cpp b/Userland/Applications/Browser/InspectorWidget.cpp new file mode 100644 index 0000000000..7e9111e513 --- /dev/null +++ b/Userland/Applications/Browser/InspectorWidget.cpp @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "InspectorWidget.h" +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Splitter.h> +#include <LibGUI/TabWidget.h> +#include <LibGUI/TableView.h> +#include <LibGUI/TreeView.h> +#include <LibWeb/DOM/Document.h> +#include <LibWeb/DOM/Element.h> +#include <LibWeb/DOMTreeModel.h> +#include <LibWeb/LayoutTreeModel.h> +#include <LibWeb/StylePropertiesModel.h> + +namespace Browser { + +void InspectorWidget::set_inspected_node(Web::DOM::Node* node) +{ + m_document->set_inspected_node(node); + if (node && node->is_element()) { + auto& element = downcast<Web::DOM::Element>(*node); + if (element.specified_css_values()) { + m_style_table_view->set_model(Web::StylePropertiesModel::create(*element.specified_css_values())); + m_computed_style_table_view->set_model(Web::StylePropertiesModel::create(*element.computed_style())); + } + } else { + m_style_table_view->set_model(nullptr); + m_computed_style_table_view->set_model(nullptr); + } +} + +InspectorWidget::InspectorWidget() +{ + set_layout<GUI::VerticalBoxLayout>(); + auto& splitter = add<GUI::VerticalSplitter>(); + + auto& top_tab_widget = splitter.add<GUI::TabWidget>(); + + m_dom_tree_view = top_tab_widget.add_tab<GUI::TreeView>("DOM"); + m_dom_tree_view->on_selection = [this](auto& index) { + auto* node = static_cast<Web::DOM::Node*>(index.internal_data()); + set_inspected_node(node); + }; + + m_layout_tree_view = top_tab_widget.add_tab<GUI::TreeView>("Layout"); + m_layout_tree_view->on_selection = [this](auto& index) { + auto* node = static_cast<Web::Layout::Node*>(index.internal_data()); + set_inspected_node(node->dom_node()); + }; + + auto& bottom_tab_widget = splitter.add<GUI::TabWidget>(); + + m_style_table_view = bottom_tab_widget.add_tab<GUI::TableView>("Styles"); + m_computed_style_table_view = bottom_tab_widget.add_tab<GUI::TableView>("Computed"); +} + +InspectorWidget::~InspectorWidget() +{ +} + +void InspectorWidget::set_document(Web::DOM::Document* document) +{ + if (m_document == document) + return; + m_document = document; + m_dom_tree_view->set_model(Web::DOMTreeModel::create(*document)); + m_layout_tree_view->set_model(Web::LayoutTreeModel::create(*document)); +} + +} diff --git a/Userland/Applications/Browser/InspectorWidget.h b/Userland/Applications/Browser/InspectorWidget.h new file mode 100644 index 0000000000..37f0f95754 --- /dev/null +++ b/Userland/Applications/Browser/InspectorWidget.h @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Widget.h> +#include <LibWeb/Forward.h> + +namespace Browser { + +class InspectorWidget final : public GUI::Widget { + C_OBJECT(InspectorWidget) +public: + virtual ~InspectorWidget(); + + void set_document(Web::DOM::Document*); + +private: + InspectorWidget(); + + void set_inspected_node(Web::DOM::Node*); + + RefPtr<GUI::TreeView> m_dom_tree_view; + RefPtr<GUI::TreeView> m_layout_tree_view; + RefPtr<GUI::TableView> m_style_table_view; + RefPtr<GUI::TableView> m_computed_style_table_view; + RefPtr<Web::DOM::Document> m_document; +}; + +} diff --git a/Userland/Applications/Browser/Tab.cpp b/Userland/Applications/Browser/Tab.cpp new file mode 100644 index 0000000000..ea23d61d73 --- /dev/null +++ b/Userland/Applications/Browser/Tab.cpp @@ -0,0 +1,550 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "Tab.h" +#include "BookmarksBarWidget.h" +#include "Browser.h" +#include "ConsoleWidget.h" +#include "DownloadWidget.h" +#include "InspectorWidget.h" +#include "WindowActions.h" +#include <AK/StringBuilder.h> +#include <Applications/Browser/TabGML.h> +#include <LibGUI/Action.h> +#include <LibGUI/Application.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/Clipboard.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/StatusBar.h> +#include <LibGUI/TabWidget.h> +#include <LibGUI/TextBox.h> +#include <LibGUI/ToolBar.h> +#include <LibGUI/ToolBarContainer.h> +#include <LibGUI/Window.h> +#include <LibJS/Interpreter.h> +#include <LibWeb/CSS/Parser/CSSParser.h> +#include <LibWeb/DOM/Element.h> +#include <LibWeb/DOMTreeModel.h> +#include <LibWeb/Dump.h> +#include <LibWeb/InProcessWebView.h> +#include <LibWeb/Layout/BlockBox.h> +#include <LibWeb/Layout/InitialContainingBlockBox.h> +#include <LibWeb/Layout/InlineNode.h> +#include <LibWeb/Layout/Node.h> +#include <LibWeb/Loader/ResourceLoader.h> +#include <LibWeb/OutOfProcessWebView.h> +#include <LibWeb/Page/Frame.h> + +namespace Browser { + +URL url_from_user_input(const String& input) +{ + auto url = URL(input); + if (url.is_valid()) + return url; + + StringBuilder builder; + builder.append("http://"); + builder.append(input); + return URL(builder.build()); +} + +static void start_download(const URL& url) +{ + auto window = GUI::Window::construct(); + window->resize(300, 150); + window->set_title(String::formatted("0% of {}", url.basename())); + window->set_resizable(false); + window->set_main_widget<DownloadWidget>(url); + window->show(); + [[maybe_unused]] auto& unused = window.leak_ref(); +} + +Tab::Tab(Type type) + : m_type(type) +{ + load_from_gml(tab_gml); + + m_toolbar_container = *find_descendant_of_type_named<GUI::ToolBarContainer>("toolbar_container"); + auto& toolbar = *find_descendant_of_type_named<GUI::ToolBar>("toolbar"); + + auto& webview_container = *find_descendant_of_type_named<GUI::Widget>("webview_container"); + + if (m_type == Type::InProcessWebView) + m_page_view = webview_container.add<Web::InProcessWebView>(); + else + m_web_content_view = webview_container.add<Web::OutOfProcessWebView>(); + + m_go_back_action = GUI::CommonActions::make_go_back_action([this](auto&) { go_back(); }, this); + m_go_forward_action = GUI::CommonActions::make_go_forward_action([this](auto&) { go_forward(); }, this); + + toolbar.add_action(*m_go_back_action); + toolbar.add_action(*m_go_forward_action); + + toolbar.add_action(GUI::CommonActions::make_go_home_action([this](auto&) { load(g_home_url); }, this)); + m_reload_action = GUI::CommonActions::make_reload_action([this](auto&) { reload(); }, this); + + toolbar.add_action(*m_reload_action); + + m_location_box = toolbar.add<GUI::TextBox>(); + m_location_box->set_placeholder("Address"); + + m_location_box->on_return_pressed = [this] { + auto url = url_from_user_input(m_location_box->text()); + load(url); + view().set_focus(true); + }; + + m_location_box->add_custom_context_menu_action(GUI::Action::create("Paste & Go", [this](auto&) { + m_location_box->set_text(GUI::Clipboard::the().data()); + m_location_box->on_return_pressed(); + })); + + m_bookmark_button = toolbar.add<GUI::Button>(); + m_bookmark_button->set_button_style(Gfx::ButtonStyle::CoolBar); + m_bookmark_button->set_icon(Gfx::Bitmap::load_from_file("/res/icons/16x16/bookmark-contour.png")); + m_bookmark_button->set_fixed_size(22, 22); + + m_bookmark_button->on_click = [this](auto) { + auto url = this->url().to_string(); + if (BookmarksBarWidget::the().contains_bookmark(url)) { + BookmarksBarWidget::the().remove_bookmark(url); + } else { + BookmarksBarWidget::the().add_bookmark(url, m_title); + } + update_bookmark_button(url); + }; + + hooks().on_load_start = [this](auto& url) { + m_location_box->set_icon(nullptr); + m_location_box->set_text(url.to_string()); + + // don't add to history if back or forward is pressed + if (!m_is_history_navigation) + m_history.push(url); + m_is_history_navigation = false; + + update_actions(); + update_bookmark_button(url.to_string()); + }; + + hooks().on_link_click = [this](auto& url, auto& target, unsigned modifiers) { + if (target == "_blank" || modifiers == Mod_Ctrl) { + on_tab_open_request(url); + } else { + load(url); + } + }; + + m_link_context_menu = GUI::Menu::construct(); + auto link_default_action = GUI::Action::create("Open", [this](auto&) { + hooks().on_link_click(m_link_context_menu_url, "", 0); + }); + m_link_context_menu->add_action(link_default_action); + m_link_context_menu_default_action = link_default_action; + m_link_context_menu->add_action(GUI::Action::create("Open in new tab", [this](auto&) { + hooks().on_link_click(m_link_context_menu_url, "_blank", 0); + })); + m_link_context_menu->add_separator(); + m_link_context_menu->add_action(GUI::Action::create("Copy link", [this](auto&) { + GUI::Clipboard::the().set_plain_text(m_link_context_menu_url.to_string()); + })); + m_link_context_menu->add_separator(); + m_link_context_menu->add_action(GUI::Action::create("Download", [this](auto&) { + start_download(m_link_context_menu_url); + })); + + hooks().on_link_context_menu_request = [this](auto& url, auto& screen_position) { + m_link_context_menu_url = url; + m_link_context_menu->popup(screen_position, m_link_context_menu_default_action); + }; + + m_image_context_menu = GUI::Menu::construct(); + m_image_context_menu->add_action(GUI::Action::create("Open image", [this](auto&) { + hooks().on_link_click(m_image_context_menu_url, "", 0); + })); + m_image_context_menu->add_action(GUI::Action::create("Open image in new tab", [this](auto&) { + hooks().on_link_click(m_image_context_menu_url, "_blank", 0); + })); + m_image_context_menu->add_separator(); + m_image_context_menu->add_action(GUI::Action::create("Copy image", [this](auto&) { + if (m_image_context_menu_bitmap.is_valid()) + GUI::Clipboard::the().set_bitmap(*m_image_context_menu_bitmap.bitmap()); + })); + m_image_context_menu->add_action(GUI::Action::create("Copy image URL", [this](auto&) { + GUI::Clipboard::the().set_plain_text(m_image_context_menu_url.to_string()); + })); + m_image_context_menu->add_separator(); + m_image_context_menu->add_action(GUI::Action::create("Download", [this](auto&) { + start_download(m_image_context_menu_url); + })); + + hooks().on_image_context_menu_request = [this](auto& image_url, auto& screen_position, const Gfx::ShareableBitmap& shareable_bitmap) { + m_image_context_menu_url = image_url; + m_image_context_menu_bitmap = shareable_bitmap; + m_image_context_menu->popup(screen_position); + }; + + hooks().on_link_middle_click = [this](auto& href, auto&, auto) { + hooks().on_link_click(href, "_blank", 0); + }; + + hooks().on_title_change = [this](auto& title) { + if (title.is_null()) { + m_title = url().to_string(); + } else { + m_title = title; + } + if (on_title_change) + on_title_change(m_title); + }; + + hooks().on_favicon_change = [this](auto& icon) { + m_icon = icon; + m_location_box->set_icon(&icon); + if (on_favicon_change) + on_favicon_change(icon); + }; + + // FIXME: Support JS console in multi-process mode. + if (m_type == Type::InProcessWebView) { + hooks().on_set_document = [this](auto* document) { + if (document && m_console_window) { + auto* console_widget = static_cast<ConsoleWidget*>(m_console_window->main_widget()); + console_widget->set_interpreter(document->interpreter().make_weak_ptr()); + } + }; + } + + auto focus_location_box_action = GUI::Action::create( + "Focus location box", { Mod_Ctrl, Key_L }, [this](auto&) { + m_location_box->select_all(); + m_location_box->set_focus(true); + }, + this); + + m_statusbar = *find_descendant_of_type_named<GUI::StatusBar>("statusbar"); + + hooks().on_link_hover = [this](auto& url) { + if (url.is_valid()) + m_statusbar->set_text(url.to_string()); + else + m_statusbar->set_text(""); + }; + + hooks().on_url_drop = [this](auto& url) { + load(url); + }; + + m_menubar = GUI::MenuBar::construct(); + + auto& app_menu = m_menubar->add_menu("Browser"); + app_menu.add_action(WindowActions::the().create_new_tab_action()); + app_menu.add_action(GUI::Action::create( + "Close tab", { Mod_Ctrl, Key_W }, Gfx::Bitmap::load_from_file("/res/icons/16x16/close-tab.png"), [this](auto&) { + on_tab_close_request(*this); + }, + this)); + + app_menu.add_action(*m_reload_action); + app_menu.add_separator(); + app_menu.add_action(GUI::CommonActions::make_quit_action([](auto&) { + GUI::Application::the()->quit(); + })); + + auto& view_menu = m_menubar->add_menu("View"); + view_menu.add_action(GUI::CommonActions::make_fullscreen_action( + [this](auto&) { + window()->set_fullscreen(!window()->is_fullscreen()); + + auto is_fullscreen = window()->is_fullscreen(); + auto* tab_widget = static_cast<GUI::TabWidget*>(parent_widget()); + tab_widget->set_bar_visible(!is_fullscreen && tab_widget->children().size() > 1); + m_toolbar_container->set_visible(!is_fullscreen); + m_statusbar->set_visible(!is_fullscreen); + }, + this)); + + auto view_source_action = GUI::Action::create( + "View source", { Mod_Ctrl, Key_U }, [this](auto&) { + if (m_type == Type::InProcessWebView) { + ASSERT(m_page_view->document()); + auto url = m_page_view->document()->url().to_string(); + auto source = m_page_view->document()->source(); + auto window = GUI::Window::construct(); + auto& editor = window->set_main_widget<GUI::TextEditor>(); + editor.set_text(source); + editor.set_mode(GUI::TextEditor::ReadOnly); + editor.set_ruler_visible(true); + window->resize(640, 480); + window->set_title(url); + window->set_icon(Gfx::Bitmap::load_from_file("/res/icons/16x16/filetype-text.png")); + window->show(); + [[maybe_unused]] auto& unused = window.leak_ref(); + } else { + TODO(); + } + }, + this); + + auto inspect_dom_tree_action = GUI::Action::create( + "Inspect DOM tree", { Mod_None, Key_F12 }, [this](auto&) { + if (m_type == Type::InProcessWebView) { + if (!m_dom_inspector_window) { + m_dom_inspector_window = GUI::Window::construct(); + m_dom_inspector_window->resize(300, 500); + m_dom_inspector_window->set_title("DOM inspector"); + m_dom_inspector_window->set_icon(Gfx::Bitmap::load_from_file("/res/icons/16x16/inspector-object.png")); + m_dom_inspector_window->set_main_widget<InspectorWidget>(); + } + auto* inspector_widget = static_cast<InspectorWidget*>(m_dom_inspector_window->main_widget()); + inspector_widget->set_document(m_page_view->document()); + m_dom_inspector_window->show(); + m_dom_inspector_window->move_to_front(); + } else { + TODO(); + } + }, + this); + + auto& inspect_menu = m_menubar->add_menu("Inspect"); + inspect_menu.add_action(*view_source_action); + inspect_menu.add_action(*inspect_dom_tree_action); + + inspect_menu.add_action(GUI::Action::create( + "Open JS Console", { Mod_Ctrl, Key_I }, [this](auto&) { + if (m_type == Type::InProcessWebView) { + if (!m_console_window) { + m_console_window = GUI::Window::construct(); + m_console_window->resize(500, 300); + m_console_window->set_title("JS Console"); + m_console_window->set_icon(Gfx::Bitmap::load_from_file("/res/icons/16x16/filetype-javascript.png")); + m_console_window->set_main_widget<ConsoleWidget>(); + } + auto* console_widget = static_cast<ConsoleWidget*>(m_console_window->main_widget()); + console_widget->set_interpreter(m_page_view->document()->interpreter().make_weak_ptr()); + m_console_window->show(); + m_console_window->move_to_front(); + } else { + TODO(); + } + }, + this)); + + auto& debug_menu = m_menubar->add_menu("Debug"); + debug_menu.add_action(GUI::Action::create( + "Dump DOM tree", [this](auto&) { + if (m_type == Type::InProcessWebView) { + Web::dump_tree(*m_page_view->document()); + } else { + TODO(); + } + }, + this)); + debug_menu.add_action(GUI::Action::create( + "Dump Layout tree", [this](auto&) { + if (m_type == Type::InProcessWebView) { + Web::dump_tree(*m_page_view->document()->layout_node()); + } else { + TODO(); + } + }, + this)); + debug_menu.add_action(GUI::Action::create( + "Dump Style sheets", [this](auto&) { + if (m_type == Type::InProcessWebView) { + for (auto& sheet : m_page_view->document()->style_sheets().sheets()) { + Web::dump_sheet(sheet); + } + } else { + TODO(); + } + }, + this)); + debug_menu.add_action(GUI::Action::create("Dump history", { Mod_Ctrl, Key_H }, [&](auto&) { + m_history.dump(); + })); + debug_menu.add_separator(); + auto line_box_borders_action = GUI::Action::create_checkable( + "Line box borders", [this](auto& action) { + if (m_type == Type::InProcessWebView) { + m_page_view->set_should_show_line_box_borders(action.is_checked()); + m_page_view->update(); + } else { + TODO(); + } + }, + this); + line_box_borders_action->set_checked(false); + debug_menu.add_action(line_box_borders_action); + + debug_menu.add_separator(); + debug_menu.add_action(GUI::Action::create("Collect garbage", { Mod_Ctrl | Mod_Shift, Key_G }, [this](auto&) { + if (m_type == Type::InProcessWebView) { + if (auto* document = m_page_view->document()) { + document->interpreter().heap().collect_garbage(JS::Heap::CollectionType::CollectGarbage, true); + } + } else { + TODO(); + } + })); + + auto& bookmarks_menu = m_menubar->add_menu("Bookmarks"); + bookmarks_menu.add_action(WindowActions::the().show_bookmarks_bar_action()); + + auto& help_menu = m_menubar->add_menu("Help"); + help_menu.add_action(WindowActions::the().about_action()); + + m_tab_context_menu = GUI::Menu::construct(); + m_tab_context_menu->add_action(GUI::Action::create("Reload Tab", [this](auto&) { + m_reload_action->activate(); + })); + m_tab_context_menu->add_action(GUI::Action::create("Close Tab", [this](auto&) { + on_tab_close_request(*this); + })); + + m_page_context_menu = GUI::Menu::construct(); + m_page_context_menu->add_action(*m_go_back_action); + m_page_context_menu->add_action(*m_go_forward_action); + m_page_context_menu->add_action(*m_reload_action); + m_page_context_menu->add_separator(); + m_page_context_menu->add_action(*view_source_action); + m_page_context_menu->add_action(*inspect_dom_tree_action); + hooks().on_context_menu_request = [&](auto& screen_position) { + m_page_context_menu->popup(screen_position); + }; +} + +Tab::~Tab() +{ +} + +void Tab::load(const URL& url, LoadType load_type) +{ + m_is_history_navigation = (load_type == LoadType::HistoryNavigation); + + if (m_type == Type::InProcessWebView) + m_page_view->load(url); + else + m_web_content_view->load(url); +} + +URL Tab::url() const +{ + if (m_type == Type::InProcessWebView) + return m_page_view->url(); + return m_web_content_view->url(); +} + +void Tab::reload() +{ + load(url()); +} + +void Tab::go_back() +{ + m_history.go_back(); + update_actions(); + load(m_history.current(), LoadType::HistoryNavigation); +} + +void Tab::go_forward() +{ + m_history.go_forward(); + update_actions(); + load(m_history.current(), LoadType::HistoryNavigation); +} + +void Tab::update_actions() +{ + m_go_back_action->set_enabled(m_history.can_go_back()); + m_go_forward_action->set_enabled(m_history.can_go_forward()); +} + +void Tab::update_bookmark_button(const String& url) +{ + if (BookmarksBarWidget::the().contains_bookmark(url)) { + m_bookmark_button->set_icon(Gfx::Bitmap::load_from_file("/res/icons/16x16/bookmark-filled.png")); + m_bookmark_button->set_tooltip("Remove Bookmark"); + } else { + m_bookmark_button->set_icon(Gfx::Bitmap::load_from_file("/res/icons/16x16/bookmark-contour.png")); + m_bookmark_button->set_tooltip("Add Bookmark"); + } +} + +void Tab::did_become_active() +{ + Web::ResourceLoader::the().on_load_counter_change = [this] { + if (Web::ResourceLoader::the().pending_loads() == 0) { + m_statusbar->set_text(""); + return; + } + m_statusbar->set_text(String::formatted("Loading ({} pending resources...)", Web::ResourceLoader::the().pending_loads())); + }; + + BookmarksBarWidget::the().on_bookmark_click = [this](auto& url, unsigned modifiers) { + if (modifiers & Mod_Ctrl) + on_tab_open_request(url); + else + load(url); + }; + + BookmarksBarWidget::the().on_bookmark_hover = [this](auto&, auto& url) { + m_statusbar->set_text(url); + }; + + BookmarksBarWidget::the().remove_from_parent(); + m_toolbar_container->add_child(BookmarksBarWidget::the()); + + auto is_fullscreen = window()->is_fullscreen(); + m_toolbar_container->set_visible(!is_fullscreen); + m_statusbar->set_visible(!is_fullscreen); + + GUI::Application::the()->set_menubar(m_menubar); +} + +void Tab::context_menu_requested(const Gfx::IntPoint& screen_position) +{ + m_tab_context_menu->popup(screen_position); +} + +GUI::Widget& Tab::view() +{ + if (m_type == Type::InProcessWebView) + return *m_page_view; + return *m_web_content_view; +} + +Web::WebViewHooks& Tab::hooks() +{ + if (m_type == Type::InProcessWebView) + return *m_page_view; + return *m_web_content_view; +} + +} diff --git a/Userland/Applications/Browser/Tab.gml b/Userland/Applications/Browser/Tab.gml new file mode 100644 index 0000000000..24c7456d77 --- /dev/null +++ b/Userland/Applications/Browser/Tab.gml @@ -0,0 +1,22 @@ +@GUI::Widget { + layout: @GUI::VerticalBoxLayout { + } + + @GUI::ToolBarContainer { + name: "toolbar_container" + + @GUI::ToolBar { + name: "toolbar" + } + } + + @GUI::Widget { + name: "webview_container" + layout: @GUI::VerticalBoxLayout { + } + } + + @GUI::StatusBar { + name: "statusbar" + } +} diff --git a/Userland/Applications/Browser/Tab.h b/Userland/Applications/Browser/Tab.h new file mode 100644 index 0000000000..79f86e4931 --- /dev/null +++ b/Userland/Applications/Browser/Tab.h @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "History.h" +#include <AK/URL.h> +#include <LibGUI/Widget.h> +#include <LibGfx/ShareableBitmap.h> +#include <LibHTTP/HttpJob.h> +#include <LibWeb/Forward.h> + +namespace Web { +class OutOfProcessWebView; +class WebViewHooks; +} + +namespace Browser { + +class Tab final : public GUI::Widget { + C_OBJECT(Tab); + +public: + enum class Type { + InProcessWebView, + OutOfProcessWebView, + }; + + virtual ~Tab() override; + + URL url() const; + + enum class LoadType { + Normal, + HistoryNavigation, + }; + + void load(const URL&, LoadType = LoadType::Normal); + void reload(); + void go_back(); + void go_forward(); + + void did_become_active(); + void context_menu_requested(const Gfx::IntPoint& screen_position); + + Function<void(String)> on_title_change; + Function<void(const URL&)> on_tab_open_request; + Function<void(Tab&)> on_tab_close_request; + Function<void(const Gfx::Bitmap&)> on_favicon_change; + + const String& title() const { return m_title; } + const Gfx::Bitmap* icon() const { return m_icon; } + + GUI::Widget& view(); + +private: + explicit Tab(Type); + + Web::WebViewHooks& hooks(); + void update_actions(); + void update_bookmark_button(const String& url); + + Type m_type; + + History m_history; + + RefPtr<Web::InProcessWebView> m_page_view; + RefPtr<Web::OutOfProcessWebView> m_web_content_view; + + RefPtr<GUI::Action> m_go_back_action; + RefPtr<GUI::Action> m_go_forward_action; + RefPtr<GUI::Action> m_reload_action; + RefPtr<GUI::TextBox> m_location_box; + RefPtr<GUI::Button> m_bookmark_button; + RefPtr<GUI::Window> m_dom_inspector_window; + RefPtr<GUI::Window> m_console_window; + RefPtr<GUI::StatusBar> m_statusbar; + RefPtr<GUI::MenuBar> m_menubar; + RefPtr<GUI::ToolBarContainer> m_toolbar_container; + + RefPtr<GUI::Menu> m_link_context_menu; + RefPtr<GUI::Action> m_link_context_menu_default_action; + URL m_link_context_menu_url; + + RefPtr<GUI::Menu> m_image_context_menu; + Gfx::ShareableBitmap m_image_context_menu_bitmap; + URL m_image_context_menu_url; + + RefPtr<GUI::Menu> m_tab_context_menu; + RefPtr<GUI::Menu> m_page_context_menu; + + String m_title; + RefPtr<const Gfx::Bitmap> m_icon; + + bool m_is_history_navigation { false }; +}; + +URL url_from_user_input(const String& input); + +} diff --git a/Userland/Applications/Browser/WindowActions.cpp b/Userland/Applications/Browser/WindowActions.cpp new file mode 100644 index 0000000000..ca0b12bd84 --- /dev/null +++ b/Userland/Applications/Browser/WindowActions.cpp @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "WindowActions.h" +#include <LibGUI/Icon.h> +#include <LibGUI/Window.h> +#include <LibGfx/Bitmap.h> + +namespace Browser { + +static WindowActions* s_the; + +WindowActions& WindowActions::the() +{ + ASSERT(s_the); + return *s_the; +} + +WindowActions::WindowActions(GUI::Window& window) +{ + ASSERT(!s_the); + s_the = this; + m_create_new_tab_action = GUI::Action::create( + "New tab", { Mod_Ctrl, Key_T }, Gfx::Bitmap::load_from_file("/res/icons/16x16/new-tab.png"), [this](auto&) { + if (on_create_new_tab) + on_create_new_tab(); + }, + &window); + + m_next_tab_action = GUI::Action::create( + "Next tab", { Mod_Ctrl, Key_PageDown }, [this](auto&) { + if (on_next_tab) + on_next_tab(); + }, + &window); + + m_previous_tab_action = GUI::Action::create( + "Previous tab", { Mod_Ctrl, Key_PageUp }, [this](auto&) { + if (on_previous_tab) + on_previous_tab(); + }, + &window); + + m_about_action = GUI::Action::create( + "About Browser", GUI::Icon::default_icon("app-browser").bitmap_for_size(16), [this](const GUI::Action&) { + if (on_about) + on_about(); + }, + &window); + m_show_bookmarks_bar_action = GUI::Action::create_checkable( + "Show bookmarks bar", + [this](auto& action) { + if (on_show_bookmarks_bar) + on_show_bookmarks_bar(action); + }, + &window); +} + +} diff --git a/Userland/Applications/Browser/WindowActions.h b/Userland/Applications/Browser/WindowActions.h new file mode 100644 index 0000000000..3212321837 --- /dev/null +++ b/Userland/Applications/Browser/WindowActions.h @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Action.h> + +namespace Browser { + +class WindowActions { +public: + static WindowActions& the(); + + WindowActions(GUI::Window&); + + Function<void()> on_create_new_tab; + Function<void()> on_next_tab; + Function<void()> on_previous_tab; + Function<void()> on_about; + Function<void(GUI::Action&)> on_show_bookmarks_bar; + + GUI::Action& create_new_tab_action() { return *m_create_new_tab_action; } + GUI::Action& next_tab_action() { return *m_next_tab_action; } + GUI::Action& previous_tab_action() { return *m_previous_tab_action; } + GUI::Action& about_action() { return *m_about_action; } + GUI::Action& show_bookmarks_bar_action() { return *m_show_bookmarks_bar_action; } + +private: + RefPtr<GUI::Action> m_create_new_tab_action; + RefPtr<GUI::Action> m_next_tab_action; + RefPtr<GUI::Action> m_previous_tab_action; + RefPtr<GUI::Action> m_about_action; + RefPtr<GUI::Action> m_show_bookmarks_bar_action; +}; + +} diff --git a/Userland/Applications/Browser/main.cpp b/Userland/Applications/Browser/main.cpp new file mode 100644 index 0000000000..d83b68f5f4 --- /dev/null +++ b/Userland/Applications/Browser/main.cpp @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "BookmarksBarWidget.h" +#include "Browser.h" +#include "InspectorWidget.h" +#include "Tab.h" +#include "WindowActions.h" +#include <AK/StringBuilder.h> +#include <Applications/Browser/BrowserWindowGML.h> +#include <LibCore/ArgsParser.h> +#include <LibCore/ConfigFile.h> +#include <LibCore/File.h> +#include <LibCore/StandardPaths.h> +#include <LibDesktop/Launcher.h> +#include <LibGUI/AboutDialog.h> +#include <LibGUI/Application.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Icon.h> +#include <LibGUI/TabWidget.h> +#include <LibGUI/Window.h> +#include <LibGfx/Bitmap.h> +#include <LibWeb/Loader/ContentFilter.h> +#include <LibWeb/Loader/ResourceLoader.h> +#include <stdio.h> +#include <stdlib.h> + +namespace Browser { + +String g_home_url; +bool g_multi_process = false; + +static String bookmarks_file_path() +{ + StringBuilder builder; + builder.append(Core::StandardPaths::config_directory()); + builder.append("/bookmarks.json"); + return builder.to_string(); +} + +} + +int main(int argc, char** argv) +{ + if (getuid() == 0) { + warnln("Refusing to run as root"); + return 1; + } + + if (pledge("stdio shared_buffer accept unix cpath rpath wpath fattr sendfd recvfd", nullptr) < 0) { + perror("pledge"); + return 1; + } + + const char* specified_url = nullptr; + + Core::ArgsParser args_parser; + args_parser.add_option(Browser::g_multi_process, "Multi-process mode", "multi-process", 'm'); + args_parser.add_positional_argument(specified_url, "URL to open", "url", Core::ArgsParser::Required::No); + args_parser.parse(argc, argv); + + auto app = GUI::Application::construct(argc, argv); + + // Connect to the ProtocolServer immediately so we can drop the "unix" pledge. + Web::ResourceLoader::the(); + + // Connect to LaunchServer immediately and let it know that we won't ask for anything other than opening + // the user's downloads directory. + // FIXME: This should go away with a standalone download manager at some point. + if (!Desktop::Launcher::add_allowed_url(URL::create_with_file_protocol(Core::StandardPaths::downloads_directory())) + || !Desktop::Launcher::seal_allowlist()) { + warnln("Failed to set up allowed launch URLs"); + return 1; + } + + if (pledge("stdio shared_buffer accept unix cpath rpath wpath sendfd recvfd", nullptr) < 0) { + perror("pledge"); + return 1; + } + + if (unveil("/home", "rwc") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/res", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/etc/passwd", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/tmp/portal/image", "rw") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/tmp/portal/webcontent", "rw") < 0) { + perror("unveil"); + return 1; + } + + unveil(nullptr, nullptr); + + auto app_icon = GUI::Icon::default_icon("app-browser"); + + auto m_config = Core::ConfigFile::get_for_app("Browser"); + Browser::g_home_url = m_config->read_entry("Preferences", "Home", "about:blank"); + + auto ad_filter_list_or_error = Core::File::open(String::formatted("{}/BrowserContentFilters.txt", Core::StandardPaths::config_directory()), Core::IODevice::ReadOnly); + if (!ad_filter_list_or_error.is_error()) { + auto& ad_filter_list = *ad_filter_list_or_error.value(); + while (!ad_filter_list.eof()) { + auto line = ad_filter_list.read_line(); + if (line.is_empty()) + continue; + Web::ContentFilter::the().add_pattern(line); + } + } + + bool bookmarksbar_enabled = true; + auto bookmarks_bar = Browser::BookmarksBarWidget::construct(Browser::bookmarks_file_path(), bookmarksbar_enabled); + + auto window = GUI::Window::construct(); + window->resize(640, 480); + window->set_icon(app_icon.bitmap_for_size(16)); + window->set_title("Browser"); + + auto& widget = window->set_main_widget<GUI::Widget>(); + widget.load_from_gml(browser_window_gml); + + auto& tab_widget = *widget.find_descendant_of_type_named<GUI::TabWidget>("tab_widget"); + + auto default_favicon = Gfx::Bitmap::load_from_file("/res/icons/16x16/filetype-html.png"); + ASSERT(default_favicon); + + tab_widget.on_change = [&](auto& active_widget) { + auto& tab = static_cast<Browser::Tab&>(active_widget); + window->set_title(String::formatted("{} - Browser", tab.title())); + tab.did_become_active(); + }; + + tab_widget.on_middle_click = [&](auto& clicked_widget) { + auto& tab = static_cast<Browser::Tab&>(clicked_widget); + tab.on_tab_close_request(tab); + }; + + tab_widget.on_context_menu_request = [&](auto& clicked_widget, const GUI::ContextMenuEvent& context_menu_event) { + auto& tab = static_cast<Browser::Tab&>(clicked_widget); + tab.context_menu_requested(context_menu_event.screen_position()); + }; + + Browser::WindowActions window_actions(*window); + + Function<void(URL url, bool activate)> create_new_tab; + create_new_tab = [&](auto url, auto activate) { + auto type = Browser::g_multi_process ? Browser::Tab::Type::OutOfProcessWebView : Browser::Tab::Type::InProcessWebView; + auto& new_tab = tab_widget.add_tab<Browser::Tab>("New tab", type); + + tab_widget.set_bar_visible(!window->is_fullscreen() && tab_widget.children().size() > 1); + tab_widget.set_tab_icon(new_tab, default_favicon); + + new_tab.on_title_change = [&](auto title) { + tab_widget.set_tab_title(new_tab, title); + if (tab_widget.active_widget() == &new_tab) + window->set_title(String::formatted("{} - Browser", title)); + }; + + new_tab.on_favicon_change = [&](auto& bitmap) { + tab_widget.set_tab_icon(new_tab, &bitmap); + }; + + new_tab.on_tab_open_request = [&](auto& url) { + create_new_tab(url, true); + }; + + new_tab.on_tab_close_request = [&](auto& tab) { + tab_widget.deferred_invoke([&](auto&) { + tab_widget.remove_tab(tab); + tab_widget.set_bar_visible(!window->is_fullscreen() && tab_widget.children().size() > 1); + if (tab_widget.children().is_empty()) + app->quit(); + }); + }; + + new_tab.load(url); + + dbgln("Added new tab {:p}, loading {}", &new_tab, url); + + if (activate) + tab_widget.set_active_widget(&new_tab); + }; + + URL first_url = Browser::g_home_url; + if (specified_url) { + if (Core::File::exists(specified_url)) { + first_url = URL::create_with_file_protocol(Core::File::real_path_for(specified_url)); + } else { + first_url = Browser::url_from_user_input(specified_url); + } + } + + window_actions.on_create_new_tab = [&] { + create_new_tab(Browser::g_home_url, true); + }; + + window_actions.on_next_tab = [&] { + tab_widget.activate_next_tab(); + }; + + window_actions.on_previous_tab = [&] { + tab_widget.activate_previous_tab(); + }; + + window_actions.on_about = [&] { + GUI::AboutDialog::show("Browser", app_icon.bitmap_for_size(32), window); + }; + + window_actions.on_show_bookmarks_bar = [&](auto& action) { + Browser::BookmarksBarWidget::the().set_visible(action.is_checked()); + }; + window_actions.show_bookmarks_bar_action().set_checked(bookmarksbar_enabled); + + create_new_tab(first_url, true); + window->show(); + + return app->exec(); +} diff --git a/Userland/Applications/CMakeLists.txt b/Userland/Applications/CMakeLists.txt new file mode 100644 index 0000000000..41803202e6 --- /dev/null +++ b/Userland/Applications/CMakeLists.txt @@ -0,0 +1,26 @@ +add_subdirectory(About) +add_subdirectory(Browser) +add_subdirectory(Calculator) +add_subdirectory(Calendar) +add_subdirectory(CrashReporter) +add_subdirectory(Debugger) +add_subdirectory(DisplaySettings) +add_subdirectory(FileManager) +add_subdirectory(FontEditor) +add_subdirectory(Help) +add_subdirectory(HexEditor) +add_subdirectory(IRCClient) +add_subdirectory(KeyboardMapper) +add_subdirectory(KeyboardSettings) +add_subdirectory(MouseSettings) +add_subdirectory(Piano) +add_subdirectory(PixelPaint) +add_subdirectory(QuickShow) +add_subdirectory(SoundPlayer) +add_subdirectory(SpaceAnalyzer) +add_subdirectory(Spreadsheet) +add_subdirectory(SystemMonitor) +add_subdirectory(ThemeEditor) +add_subdirectory(Terminal) +add_subdirectory(TextEditor) +add_subdirectory(Welcome) diff --git a/Userland/Applications/Calculator/CMakeLists.txt b/Userland/Applications/Calculator/CMakeLists.txt new file mode 100644 index 0000000000..d72624c308 --- /dev/null +++ b/Userland/Applications/Calculator/CMakeLists.txt @@ -0,0 +1,11 @@ +compile_gml(CalculatorWindow.gml CalculatorGML.h calculator_gml) +set(SOURCES + main.cpp + Calculator.cpp + CalculatorWidget.cpp + Keypad.cpp + CalculatorGML.h +) + +serenity_app(Calculator ICON app-calculator) +target_link_libraries(Calculator LibGUI) diff --git a/Userland/Applications/Calculator/Calculator.cpp b/Userland/Applications/Calculator/Calculator.cpp new file mode 100644 index 0000000000..c324fb84b9 --- /dev/null +++ b/Userland/Applications/Calculator/Calculator.cpp @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2019-2020, Sergey Bugaev <bugaevc@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "Calculator.h" +#include <AK/Assertions.h> +#include <math.h> + +Calculator::Calculator() +{ +} + +Calculator::~Calculator() +{ +} + +double Calculator::begin_operation(Operation operation, double argument) +{ + double res = 0.0; + + switch (operation) { + case Operation::None: + ASSERT_NOT_REACHED(); + + case Operation::Add: + case Operation::Subtract: + case Operation::Multiply: + case Operation::Divide: + m_saved_argument = argument; + m_operation_in_progress = operation; + return argument; + + case Operation::Sqrt: + if (argument < 0.0) { + m_has_error = true; + return argument; + } + res = sqrt(argument); + clear_operation(); + break; + case Operation::Inverse: + if (argument == 0.0) { + m_has_error = true; + return argument; + } + res = 1 / argument; + clear_operation(); + break; + case Operation::Percent: + res = argument * 0.01; + break; + case Operation::ToggleSign: + res = -argument; + break; + + case Operation::MemClear: + m_mem = 0.0; + res = argument; + break; + case Operation::MemRecall: + res = m_mem; + break; + case Operation::MemSave: + m_mem = argument; + res = argument; + break; + case Operation::MemAdd: + m_mem += argument; + res = m_mem; + break; + } + + return res; +} + +double Calculator::finish_operation(double argument) +{ + double res = 0.0; + + switch (m_operation_in_progress) { + case Operation::None: + return argument; + + case Operation::Add: + res = m_saved_argument + argument; + break; + case Operation::Subtract: + res = m_saved_argument - argument; + break; + case Operation::Multiply: + res = m_saved_argument * argument; + break; + case Operation::Divide: + if (argument == 0.0) { + m_has_error = true; + return argument; + } + res = m_saved_argument / argument; + break; + + case Operation::Sqrt: + case Operation::Inverse: + case Operation::Percent: + case Operation::ToggleSign: + case Operation::MemClear: + case Operation::MemRecall: + case Operation::MemSave: + case Operation::MemAdd: + ASSERT_NOT_REACHED(); + } + + clear_operation(); + return res; +} + +void Calculator::clear_operation() +{ + m_operation_in_progress = Operation::None; + m_saved_argument = 0.0; + clear_error(); +} diff --git a/Userland/Applications/Calculator/Calculator.h b/Userland/Applications/Calculator/Calculator.h new file mode 100644 index 0000000000..c53e31c397 --- /dev/null +++ b/Userland/Applications/Calculator/Calculator.h @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2019-2020, Sergey Bugaev <bugaevc@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +// This type implements the regular calculator +// behavior, such as performing arithmetic +// operations and providing a memory cell. +// It does not deal with number input; you +// have to pass in already parsed double +// values. + +class Calculator final { +public: + Calculator(); + ~Calculator(); + + enum class Operation { + None, + Add, + Subtract, + Multiply, + Divide, + + Sqrt, + Inverse, + Percent, + ToggleSign, + + MemClear, + MemRecall, + MemSave, + MemAdd + }; + + double begin_operation(Operation, double); + double finish_operation(double); + + bool has_error() const { return m_has_error; } + + void clear_operation(); + void clear_error() { m_has_error = false; } + +private: + Operation m_operation_in_progress { Operation::None }; + double m_saved_argument { 0.0 }; + double m_mem { 0.0 }; + bool m_has_error { false }; +}; diff --git a/Userland/Applications/Calculator/CalculatorWidget.cpp b/Userland/Applications/Calculator/CalculatorWidget.cpp new file mode 100644 index 0000000000..083edae93b --- /dev/null +++ b/Userland/Applications/Calculator/CalculatorWidget.cpp @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2019-2020, Sergey Bugaev <bugaevc@serenityos.org> + * Copyright (c) 2021 Glenford Williams <gw_dev@outlook.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "CalculatorWidget.h" +#include "Applications/Calculator/CalculatorGML.h" +#include <AK/Assertions.h> +#include <LibGUI/Button.h> +#include <LibGUI/Label.h> +#include <LibGUI/TextBox.h> +#include <LibGfx/Font.h> +#include <LibGfx/FontDatabase.h> +#include <LibGfx/Palette.h> + +CalculatorWidget::CalculatorWidget() +{ + load_from_gml(calculator_gml); + + m_entry = *find_descendant_of_type_named<GUI::TextBox>("entry_textbox"); + m_entry->set_relative_rect(5, 5, 244, 26); + m_entry->set_text_alignment(Gfx::TextAlignment::CenterRight); + m_entry->set_font(Gfx::FontDatabase::default_fixed_width_font()); + + m_label = *find_descendant_of_type_named<GUI::Label>("label"); + + m_label->set_frame_shadow(Gfx::FrameShadow::Sunken); + m_label->set_frame_shape(Gfx::FrameShape::Container); + m_label->set_frame_thickness(2); + + for (int i = 0; i < 10; i++) { + m_digit_button[i] = *find_descendant_of_type_named<GUI::Button>(String::formatted("{}_button", i)); + add_digit_button(*m_digit_button[i], i); + } + + m_mem_add_button = *find_descendant_of_type_named<GUI::Button>("mem_add_button"); + add_operation_button(*m_mem_add_button, Calculator::Operation::MemAdd); + + m_mem_save_button = *find_descendant_of_type_named<GUI::Button>("mem_save_button"); + add_operation_button(*m_mem_save_button, Calculator::Operation::MemSave); + + m_mem_recall_button = *find_descendant_of_type_named<GUI::Button>("mem_recall_button"); + add_operation_button(*m_mem_recall_button, Calculator::Operation::MemRecall); + + m_mem_clear_button = *find_descendant_of_type_named<GUI::Button>("mem_clear_button"); + add_operation_button(*m_mem_clear_button, Calculator::Operation::MemClear); + + m_clear_button = *find_descendant_of_type_named<GUI::Button>("clear_button"); + m_clear_button->on_click = [this](auto) { + m_keypad.set_value(0.0); + m_calculator.clear_operation(); + update_display(); + }; + + m_clear_error_button = *find_descendant_of_type_named<GUI::Button>("clear_error_button"); + m_clear_error_button->on_click = [this](auto) { + m_keypad.set_value(0.0); + update_display(); + }; + + m_backspace_button = *find_descendant_of_type_named<GUI::Button>("backspace_button"); + m_backspace_button->on_click = [this](auto) { + m_keypad.type_backspace(); + update_display(); + }; + + m_decimal_point_button = *find_descendant_of_type_named<GUI::Button>("decimal_button"); + m_decimal_point_button->on_click = [this](auto) { + m_keypad.type_decimal_point(); + update_display(); + }; + + m_sign_button = *find_descendant_of_type_named<GUI::Button>("sign_button"); + add_operation_button(*m_sign_button, Calculator::Operation::ToggleSign); + + m_add_button = *find_descendant_of_type_named<GUI::Button>("add_button"); + add_operation_button(*m_add_button, Calculator::Operation::Add); + + m_subtract_button = *find_descendant_of_type_named<GUI::Button>("subtract_button"); + add_operation_button(*m_subtract_button, Calculator::Operation::Subtract); + + m_multiply_button = *find_descendant_of_type_named<GUI::Button>("multiply_button"); + add_operation_button(*m_multiply_button, Calculator::Operation::Multiply); + + m_divide_button = *find_descendant_of_type_named<GUI::Button>("divide_button"); + add_operation_button(*m_divide_button, Calculator::Operation::Divide); + + m_sqrt_button = *find_descendant_of_type_named<GUI::Button>("sqrt_button"); + add_operation_button(*m_sqrt_button, Calculator::Operation::Sqrt); + + m_inverse_button = *find_descendant_of_type_named<GUI::Button>("inverse_button"); + add_operation_button(*m_inverse_button, Calculator::Operation::Inverse); + + m_percent_button = *find_descendant_of_type_named<GUI::Button>("mod_button"); + add_operation_button(*m_percent_button, Calculator::Operation::Percent); + + m_equals_button = *find_descendant_of_type_named<GUI::Button>("equal_button"); + m_equals_button->on_click = [this](auto) { + double argument = m_keypad.value(); + double res = m_calculator.finish_operation(argument); + m_keypad.set_value(res); + update_display(); + }; +} + +CalculatorWidget::~CalculatorWidget() +{ +} + +void CalculatorWidget::add_operation_button(GUI::Button& button, Calculator::Operation operation) +{ + button.on_click = [this, operation](auto) { + double argument = m_keypad.value(); + double res = m_calculator.begin_operation(operation, argument); + m_keypad.set_value(res); + update_display(); + }; +} + +void CalculatorWidget::add_digit_button(GUI::Button& button, int digit) +{ + button.on_click = [this, digit](auto) { + m_keypad.type_digit(digit); + update_display(); + }; +} + +void CalculatorWidget::update_display() +{ + m_entry->set_text(m_keypad.to_string()); + if (m_calculator.has_error()) + m_label->set_text("E"); + else + m_label->set_text(""); +} + +void CalculatorWidget::keydown_event(GUI::KeyEvent& event) +{ + //Clear button selection when we are typing + m_equals_button->set_focus(true); + m_equals_button->set_focus(false); + + if (event.key() == KeyCode::Key_Return) { + m_keypad.set_value(m_calculator.finish_operation(m_keypad.value())); + + } else if (event.key() >= KeyCode::Key_0 && event.key() <= KeyCode::Key_9) { + m_keypad.type_digit(atoi(event.text().characters())); + + } else if (event.key() == KeyCode::Key_Period) { + m_keypad.type_decimal_point(); + + } else if (event.key() == KeyCode::Key_Escape) { + m_keypad.set_value(0.0); + m_calculator.clear_operation(); + + } else if (event.key() == KeyCode::Key_Backspace) { + m_keypad.type_backspace(); + + } else { + Calculator::Operation operation; + + switch (event.key()) { + case KeyCode::Key_Plus: + operation = Calculator::Operation::Add; + break; + case KeyCode::Key_Minus: + operation = Calculator::Operation::Subtract; + break; + case KeyCode::Key_Asterisk: + operation = Calculator::Operation::Multiply; + break; + case KeyCode::Key_Slash: + operation = Calculator::Operation::Divide; + break; + case KeyCode::Key_Percent: + operation = Calculator::Operation::Percent; + break; + default: + return; + } + + m_keypad.set_value(m_calculator.begin_operation(operation, m_keypad.value())); + } + + update_display(); +} diff --git a/Userland/Applications/Calculator/CalculatorWidget.h b/Userland/Applications/Calculator/CalculatorWidget.h new file mode 100644 index 0000000000..31d9548b3c --- /dev/null +++ b/Userland/Applications/Calculator/CalculatorWidget.h @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2019-2020, Sergey Bugaev <bugaevc@serenityos.org> + * Copyright (c) 2021 Glenford Williams <gw_dev@outlook.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Calculator.h" +#include "Keypad.h" +#include <AK/Vector.h> +#include <LibGUI/Widget.h> + +class CalculatorWidget final : public GUI::Widget { + C_OBJECT(CalculatorWidget) +public: + virtual ~CalculatorWidget() override; + +private: + CalculatorWidget(); + void add_operation_button(GUI::Button&, Calculator::Operation); + void add_digit_button(GUI::Button&, int digit); + + void update_display(); + + virtual void keydown_event(GUI::KeyEvent&) override; + + Calculator m_calculator; + Keypad m_keypad; + + RefPtr<GUI::TextBox> m_entry; + RefPtr<GUI::Label> m_label; + + RefPtr<GUI::Button> m_digit_button[10]; + RefPtr<GUI::Button> m_mem_add_button; + RefPtr<GUI::Button> m_mem_save_button; + RefPtr<GUI::Button> m_mem_recall_button; + RefPtr<GUI::Button> m_mem_clear_button; + RefPtr<GUI::Button> m_clear_button; + RefPtr<GUI::Button> m_clear_error_button; + RefPtr<GUI::Button> m_backspace_button; + RefPtr<GUI::Button> m_decimal_point_button; + RefPtr<GUI::Button> m_sign_button; + RefPtr<GUI::Button> m_add_button; + RefPtr<GUI::Button> m_subtract_button; + RefPtr<GUI::Button> m_multiply_button; + RefPtr<GUI::Button> m_divide_button; + RefPtr<GUI::Button> m_sqrt_button; + RefPtr<GUI::Button> m_inverse_button; + RefPtr<GUI::Button> m_percent_button; + RefPtr<GUI::Button> m_equals_button; +}; diff --git a/Userland/Applications/Calculator/CalculatorWindow.gml b/Userland/Applications/Calculator/CalculatorWindow.gml new file mode 100644 index 0000000000..4763a4d8c2 --- /dev/null +++ b/Userland/Applications/Calculator/CalculatorWindow.gml @@ -0,0 +1,272 @@ +@GUI::Widget { + fixed_width: 254 + fixed_height: 213 + fill_with_background_color: true + + layout: @GUI::VerticalBoxLayout { + margins: [10, 0, 10, 0] + } + + @GUI::TextBox { + name: "entry_textbox" + } + + @GUI::Widget { + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::Label { + name: "label" + fixed_width: 35 + fixed_height: 27 + } + + @GUI::Widget { + fixed_width: 5 + } + + @GUI::Button { + name: "backspace_button" + text: "Backspace" + fixed_width: 65 + fixed_height: 28 + foreground_color: "brown" + } + + @GUI::Button { + name: "clear_error_button" + text: "CE" + fixed_width: 55 + fixed_height: 28 + foreground_color: "brown" + } + + @GUI::Button { + name: "clear_button" + text: "C" + fixed_width: 60 + fixed_height: 28 + foreground_color: "brown" + } + } + + @GUI::Widget { + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::Button { + name: "mem_clear_button" + text: "MC" + fixed_width: 35 + fixed_height: 28 + foreground_color: "red" + } + + @GUI::Widget { + fixed_width: 5 + } + + @GUI::Button { + name: "7_button" + text: "7" + fixed_width: 35 + fixed_height: 28 + foreground_color: "blue" + } + + @GUI::Button { + name: "8_button" + text: "8" + fixed_width: 35 + fixed_height: 28 + foreground_color: "blue" + } + + @GUI::Button { + name: "9_button" + text: "9" + fixed_width: 35 + fixed_height: 28 + } + + @GUI::Button { + name: "divide_button" + text: "/" + fixed_width: 35 + fixed_height: 28 + } + + @GUI::Button { + name: "sqrt_button" + text: "sqrt" + fixed_width: 35 + fixed_height: 28 + foreground_color: "blue" + } + } + + @GUI::Widget { + layout: @GUI::HorizontalBoxLayout {} + + @GUI::Button { + name: "mem_recall_button" + text: "MR" + fixed_width: 35 + fixed_height: 28 + foreground_color: "red" + } + + @GUI::Widget { + fixed_width: 5 + } + + @GUI::Button { + name: "4_button" + text: "4" + fixed_width: 35 + fixed_height: 28 + foreground_color: "blue" + } + + @GUI::Button { + name: "5_button" + text: "5" + fixed_width: 35 + fixed_height: 28 + foreground_color: "blue" + } + + @GUI::Button { + name: "6_button" + text: "6" + fixed_width: 35 + fixed_height: 28 + foreground_color: "blue" + } + + @GUI::Button { + name: "multiply_button" + text: "*" + fixed_width: 35 + fixed_height: 28 + } + + @GUI::Button { + name: "mod_button" + text: "%" + fixed_width: 35 + fixed_height: 28 + foreground_color: "blue" + } + } + + @GUI::Widget { + layout: @GUI::HorizontalBoxLayout {} + + @GUI::Button { + name: "mem_save_button" + text: "MS" + fixed_width: 35 + fixed_height: 28 + foreground_color: "red" + } + + @GUI::Widget { + fixed_width: 5 + } + + @GUI::Button { + name: "1_button" + text: "1" + fixed_width: 35 + fixed_height: 28 + foreground_color: "blue" + } + + @GUI::Button { + name: "2_button" + text: "2" + fixed_width: 35 + fixed_height: 28 + foreground_color: "blue" + } + + @GUI::Button { + name: "3_button" + text: "3" + fixed_width: 35 + fixed_height: 28 + foreground_color: "blue" + } + + @GUI::Button { + name: "subtract_button" + text: "-" + fixed_width: 35 + fixed_height: 28 + } + + @GUI::Button { + name: "inverse_button" + text: "1/x" + fixed_width: 35 + fixed_height: 28 + foreground_color: "blue" + } + } + + @GUI::Widget { + layout: @GUI::HorizontalBoxLayout {} + + @GUI::Button { + name: "mem_add_button" + text: "M+" + fixed_width: 35 + fixed_height: 28 + foreground_color: "red" + } + + @GUI::Widget { + fixed_width: 5 + } + + @GUI::Button { + name: "0_button" + text: "0" + fixed_width: 35 + fixed_height: 28 + foreground_color: "blue" + } + + @GUI::Button { + name: "sign_button" + text: "+/-" + fixed_width: 35 + fixed_height: 28 + foreground_color: "blue" + } + + @GUI::Button { + name: "decimal_button" + text: "." + fixed_width: 35 + fixed_height: 28 + foreground_color: "blue" + } + + @GUI::Button { + name: "add_button" + text: "+" + fixed_width: 35 + fixed_height: 28 + } + + @GUI::Button { + name: "equal_button" + text: "=" + fixed_width: 35 + fixed_height: 28 + foreground_color: "red" + } + } +} diff --git a/Userland/Applications/Calculator/Keypad.cpp b/Userland/Applications/Calculator/Keypad.cpp new file mode 100644 index 0000000000..9b2d672af5 --- /dev/null +++ b/Userland/Applications/Calculator/Keypad.cpp @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2019-2020, Sergey Bugaev <bugaevc@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "Keypad.h" +#include <AK/StringBuilder.h> +#include <math.h> + +Keypad::Keypad() +{ +} + +Keypad::~Keypad() +{ +} + +void Keypad::type_digit(int digit) +{ + switch (m_state) { + case State::External: + m_state = State::TypingInteger; + m_negative = false; + m_int_value = digit; + m_frac_value = 0; + m_frac_length = 0; + break; + case State::TypingInteger: + ASSERT(m_frac_value == 0); + ASSERT(m_frac_length == 0); + m_int_value *= 10; + m_int_value += digit; + break; + case State::TypingDecimal: + if (m_frac_length > 6) + break; + m_frac_value *= 10; + m_frac_value += digit; + m_frac_length++; + break; + } +} + +void Keypad::type_decimal_point() +{ + switch (m_state) { + case State::External: + m_negative = false; + m_int_value = 0; + m_frac_value = 0; + m_frac_length = 0; + break; + case State::TypingInteger: + ASSERT(m_frac_value == 0); + ASSERT(m_frac_length == 0); + m_state = State::TypingDecimal; + break; + case State::TypingDecimal: + // Ignore it. + break; + } +} + +void Keypad::type_backspace() +{ + switch (m_state) { + case State::External: + m_negative = false; + m_int_value = 0; + m_frac_value = 0; + m_frac_length = 0; + break; + case State::TypingDecimal: + if (m_frac_length > 0) { + m_frac_value /= 10; + m_frac_length--; + break; + } + ASSERT(m_frac_value == 0); + m_state = State::TypingInteger; + [[fallthrough]]; + case State::TypingInteger: + ASSERT(m_frac_value == 0); + ASSERT(m_frac_length == 0); + m_int_value /= 10; + if (m_int_value == 0) + m_negative = false; + break; + } +} + +double Keypad::value() const +{ + double res = 0.0; + + long frac = m_frac_value; + for (int i = 0; i < m_frac_length; i++) { + int digit = frac % 10; + res += digit; + res /= 10.0; + frac /= 10; + } + + res += m_int_value; + if (m_negative) + res = -res; + + return res; +} + +void Keypad::set_value(double value) +{ + m_state = State::External; + + if (value < 0.0) { + m_negative = true; + value = -value; + } else + m_negative = false; + + m_int_value = value; + value -= m_int_value; + + m_frac_value = 0; + m_frac_length = 0; + while (value != 0) { + value *= 10.0; + int digit = value; + m_frac_value *= 10; + m_frac_value += digit; + m_frac_length++; + value -= digit; + + if (m_frac_length > 6) + break; + } +} + +String Keypad::to_string() const +{ + StringBuilder builder; + if (m_negative) + builder.append("-"); + builder.appendff("{}", m_int_value); + + if (m_frac_length > 0) + builder.appendff(".{:0{}}", m_frac_value, m_frac_length); + + return builder.to_string(); +} diff --git a/Userland/Applications/Calculator/Keypad.h b/Userland/Applications/Calculator/Keypad.h new file mode 100644 index 0000000000..dae427a046 --- /dev/null +++ b/Userland/Applications/Calculator/Keypad.h @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2019-2020, Sergey Bugaev <bugaevc@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/String.h> + +// This type implements number typing and +// displaying mechanics. It does not perform +// any arithmetic operations or anything on +// the values it deals with. + +class Keypad final { +public: + Keypad(); + ~Keypad(); + + void type_digit(int digit); + void type_decimal_point(); + void type_backspace(); + + double value() const; + void set_value(double); + + String to_string() const; + +private: + // Internal representation of the current decimal value. + bool m_negative { false }; + long m_int_value { 0 }; + long m_frac_value { 0 }; + int m_frac_length { 0 }; + // E.g. for -35.004200, + // m_negative = true + // m_int_value = 35 + // m_frac_value = 4200 + // m_frac_length = 6 + + enum class State { + External, + TypingInteger, + TypingDecimal + }; + + State m_state { State::External }; +}; diff --git a/Userland/Applications/Calculator/main.cpp b/Userland/Applications/Calculator/main.cpp new file mode 100644 index 0000000000..7ff18b94bf --- /dev/null +++ b/Userland/Applications/Calculator/main.cpp @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2019-2020, Sergey Bugaev <bugaevc@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "CalculatorWidget.h" +#include <LibGUI/Action.h> +#include <LibGUI/Application.h> +#include <LibGUI/Icon.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/Window.h> +#include <LibGfx/Bitmap.h> +#include <stdio.h> + +int main(int argc, char** argv) +{ + if (pledge("stdio shared_buffer rpath accept unix cpath fattr", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio shared_buffer rpath accept", nullptr) < 0) { + perror("pledge"); + return 1; + } + + if (unveil("/res", "r") < 0) { + perror("unveil"); + return 1; + } + + unveil(nullptr, nullptr); + + auto app_icon = GUI::Icon::default_icon("app-calculator"); + + auto window = GUI::Window::construct(); + window->set_title("Calculator"); + window->set_resizable(false); + window->resize(254, 213); + + window->set_main_widget<CalculatorWidget>(); + + window->show(); + window->set_icon(app_icon.bitmap_for_size(16)); + + auto menubar = GUI::MenuBar::construct(); + + auto& app_menu = menubar->add_menu("Calculator"); + app_menu.add_action(GUI::CommonActions::make_quit_action([](auto&) { + GUI::Application::the()->quit(); + return; + })); + + auto& help_menu = menubar->add_menu("Help"); + help_menu.add_action(GUI::CommonActions::make_about_action("Calculator", app_icon)); + + app->set_menubar(move(menubar)); + + return app->exec(); +} diff --git a/Userland/Applications/Calendar/AddEventDialog.cpp b/Userland/Applications/Calendar/AddEventDialog.cpp new file mode 100644 index 0000000000..dbb70d33c1 --- /dev/null +++ b/Userland/Applications/Calendar/AddEventDialog.cpp @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2019-2020, Ryan Grieb <ryan.m.grieb@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "AddEventDialog.h" +#include <LibCore/DateTime.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/ComboBox.h> +#include <LibGUI/Label.h> +#include <LibGUI/Layout.h> +#include <LibGUI/Painter.h> +#include <LibGUI/SpinBox.h> +#include <LibGUI/TextBox.h> +#include <LibGUI/Widget.h> +#include <LibGUI/Window.h> +#include <LibGfx/Color.h> +#include <LibGfx/Font.h> +#include <LibGfx/FontDatabase.h> + +static const char* short_month_names[] = { + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" +}; + +AddEventDialog::AddEventDialog(Core::DateTime date_time, Window* parent_window) + : Dialog(parent_window) + , m_date_time(date_time) +{ + resize(158, 100); + set_title("Add Event"); + set_resizable(false); + set_icon(parent_window->icon()); + + auto& widget = set_main_widget<GUI::Widget>(); + widget.set_fill_with_background_color(true); + widget.set_layout<GUI::VerticalBoxLayout>(); + + auto& top_container = widget.add<GUI::Widget>(); + top_container.set_layout<GUI::VerticalBoxLayout>(); + top_container.set_fixed_height(45); + top_container.layout()->set_margins({ 4, 4, 4, 4 }); + + auto& add_label = top_container.add<GUI::Label>("Add title & date:"); + add_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + add_label.set_fixed_height(14); + add_label.set_font(Gfx::FontDatabase::default_bold_font()); + + auto& event_title_textbox = top_container.add<GUI::TextBox>(); + event_title_textbox.set_fixed_height(20); + + auto& middle_container = widget.add<GUI::Widget>(); + middle_container.set_layout<GUI::HorizontalBoxLayout>(); + middle_container.set_fixed_height(25); + middle_container.layout()->set_margins({ 4, 4, 4, 4 }); + + auto& starting_month_combo = middle_container.add<GUI::ComboBox>(); + starting_month_combo.set_only_allow_values_from_model(true); + starting_month_combo.set_fixed_size(50, 20); + starting_month_combo.set_model(MonthListModel::create()); + starting_month_combo.set_selected_index(m_date_time.month() - 1); + + auto& starting_day_combo = middle_container.add<GUI::SpinBox>(); + starting_day_combo.set_fixed_size(40, 20); + starting_day_combo.set_value(m_date_time.day()); + starting_day_combo.set_min(1); + + auto& starting_year_combo = middle_container.add<GUI::SpinBox>(); + starting_year_combo.set_fixed_size(55, 20); + starting_year_combo.set_range(0, 9999); + starting_year_combo.set_value(m_date_time.year()); + + widget.layout()->add_spacer(); + + auto& button_container = widget.add<GUI::Widget>(); + button_container.set_fixed_height(20); + button_container.set_layout<GUI::HorizontalBoxLayout>(); + button_container.layout()->add_spacer(); + auto& ok_button = button_container.add<GUI::Button>("OK"); + ok_button.set_fixed_size(80, 20); + ok_button.on_click = [this](auto) { + dbgln("TODO: Add event icon on specific tile"); + done(Dialog::ExecOK); + }; + + event_title_textbox.set_focus(true); +} + +AddEventDialog::~AddEventDialog() +{ +} + +AddEventDialog::MonthListModel::MonthListModel() +{ +} + +AddEventDialog::MonthListModel::~MonthListModel() +{ +} + +void AddEventDialog::MonthListModel::update() +{ +} + +int AddEventDialog::MonthListModel::row_count(const GUI::ModelIndex&) const +{ + return 12; +} + +String AddEventDialog::MonthListModel::column_name(int column) const +{ + switch (column) { + case Column::Month: + return "Month"; + default: + ASSERT_NOT_REACHED(); + } +} + +GUI::Variant AddEventDialog::MonthListModel::data(const GUI::ModelIndex& index, GUI::ModelRole role) const +{ + auto& month = short_month_names[index.row()]; + if (role == GUI::ModelRole::Display) { + switch (index.column()) { + case Column::Month: + return month; + default: + ASSERT_NOT_REACHED(); + } + } + return {}; +} diff --git a/Userland/Applications/Calendar/AddEventDialog.h b/Userland/Applications/Calendar/AddEventDialog.h new file mode 100644 index 0000000000..c84e494527 --- /dev/null +++ b/Userland/Applications/Calendar/AddEventDialog.h @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2019-2020, Ryan Grieb <ryan.m.grieb@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Calendar.h> +#include <LibGUI/Dialog.h> +#include <LibGUI/Model.h> +#include <LibGUI/Window.h> + +class AddEventDialog final : public GUI::Dialog { + C_OBJECT(AddEventDialog) +public: + virtual ~AddEventDialog() override; + + static void show(Core::DateTime date_time, Window* parent_window = nullptr) + { + auto dialog = AddEventDialog::construct(date_time, parent_window); + dialog->exec(); + } + +private: + AddEventDialog(Core::DateTime date_time, Window* parent_window = nullptr); + + class MonthListModel final : public GUI::Model { + public: + enum Column { + Month, + __Count, + }; + + static NonnullRefPtr<MonthListModel> create() { return adopt(*new MonthListModel); } + virtual ~MonthListModel() override; + + virtual int row_count(const GUI::ModelIndex& = GUI::ModelIndex()) const override; + virtual int column_count(const GUI::ModelIndex& = GUI::ModelIndex()) const override { return Column::__Count; } + virtual String column_name(int) const override; + virtual GUI::Variant data(const GUI::ModelIndex&, GUI::ModelRole) const override; + virtual void update() override; + + private: + MonthListModel(); + }; + + Core::DateTime m_date_time; +}; diff --git a/Userland/Applications/Calendar/CMakeLists.txt b/Userland/Applications/Calendar/CMakeLists.txt new file mode 100644 index 0000000000..c23a6743cc --- /dev/null +++ b/Userland/Applications/Calendar/CMakeLists.txt @@ -0,0 +1,7 @@ +set(SOURCES + AddEventDialog.cpp + main.cpp +) + +serenity_app(Calendar ICON app-calendar) +target_link_libraries(Calendar LibGUI) diff --git a/Userland/Applications/Calendar/main.cpp b/Userland/Applications/Calendar/main.cpp new file mode 100644 index 0000000000..aebc5c4fed --- /dev/null +++ b/Userland/Applications/Calendar/main.cpp @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2019-2020, Ryan Grieb <ryan.m.grieb@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "AddEventDialog.h" +#include <LibGUI/Action.h> +#include <LibGUI/Application.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/Calendar.h> +#include <LibGUI/Icon.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/ToolBar.h> +#include <LibGUI/ToolBarContainer.h> +#include <LibGUI/Window.h> +#include <LibGfx/Bitmap.h> +#include <LibGfx/Color.h> +#include <LibGfx/Font.h> +#include <LibGfx/FontDatabase.h> +#include <stdio.h> + +int main(int argc, char** argv) +{ + + if (pledge("stdio shared_buffer rpath accept unix cpath fattr", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio shared_buffer rpath accept", nullptr) < 0) { + perror("pledge"); + return 1; + } + + if (unveil("/res", "r") < 0) { + perror("unveil"); + return 1; + } + + unveil(nullptr, nullptr); + + auto app_icon = GUI::Icon::default_icon("app-calendar"); + auto window = GUI::Window::construct(); + window->set_title("Calendar"); + window->resize(600, 480); + window->set_icon(app_icon.bitmap_for_size(16)); + + auto& root_container = window->set_main_widget<GUI::Widget>(); + root_container.set_fill_with_background_color(true); + root_container.set_layout<GUI::VerticalBoxLayout>(); + + auto& toolbar_container = root_container.add<GUI::ToolBarContainer>(); + auto& toolbar = toolbar_container.add<GUI::ToolBar>(); + + auto& calendar_container = root_container.add<GUI::Frame>(); + calendar_container.set_layout<GUI::VerticalBoxLayout>(); + calendar_container.layout()->set_margins({ 2, 2, 2, 2 }); + auto& calendar_widget = calendar_container.add<GUI::Calendar>(Core::DateTime::now()); + + RefPtr<GUI::Button> selected_calendar_button; + + auto prev_date_action = GUI::Action::create("Previous date", { Mod_Alt, Key_Left }, Gfx::Bitmap::load_from_file("/res/icons/16x16/go-back.png"), [&](const GUI::Action&) { + unsigned int target_month = calendar_widget.selected_month(); + unsigned int target_year = calendar_widget.selected_year(); + + if (calendar_widget.mode() == GUI::Calendar::Month) { + target_month--; + if (calendar_widget.selected_month() <= 1) { + target_month = 12; + target_year--; + } + } else { + target_year--; + } + + calendar_widget.update_tiles(target_year, target_month); + selected_calendar_button->set_text(calendar_widget.selected_calendar_text()); + }); + + auto next_date_action = GUI::Action::create("Next date", { Mod_Alt, Key_Right }, Gfx::Bitmap::load_from_file("/res/icons/16x16/go-forward.png"), [&](const GUI::Action&) { + unsigned int target_month = calendar_widget.selected_month(); + unsigned int target_year = calendar_widget.selected_year(); + + if (calendar_widget.mode() == GUI::Calendar::Month) { + target_month++; + if (calendar_widget.selected_month() >= 12) { + target_month = 1; + target_year++; + } + } else { + target_year++; + } + + calendar_widget.update_tiles(target_year, target_month); + selected_calendar_button->set_text(calendar_widget.selected_calendar_text()); + }); + + auto add_event_action = GUI::Action::create("Add event", {}, Gfx::Bitmap::load_from_file("/res/icons/16x16/add-event.png"), [&](const GUI::Action&) { + AddEventDialog::show(calendar_widget.selected_date(), window); + }); + + auto jump_to_action = GUI::Action::create("Jump to today", {}, Gfx::Bitmap::load_from_file("/res/icons/16x16/calendar-date.png"), [&](const GUI::Action&) { + if (calendar_widget.mode() == GUI::Calendar::Year) + calendar_widget.toggle_mode(); + calendar_widget.set_selected_date(Core::DateTime::now()); + calendar_widget.update_tiles(Core::DateTime::now().year(), Core::DateTime::now().month()); + selected_calendar_button->set_text(calendar_widget.selected_calendar_text()); + }); + + toolbar.add_action(prev_date_action); + selected_calendar_button = toolbar.add<GUI::Button>(calendar_widget.selected_calendar_text()); + selected_calendar_button->set_fixed_width(70); + selected_calendar_button->set_button_style(Gfx::ButtonStyle::CoolBar); + selected_calendar_button->set_font(Gfx::FontDatabase::default_bold_fixed_width_font()); + selected_calendar_button->on_click = [&](auto) { + calendar_widget.toggle_mode(); + selected_calendar_button->set_text(calendar_widget.selected_calendar_text()); + }; + toolbar.add_action(next_date_action); + toolbar.add_separator(); + toolbar.add_action(jump_to_action); + toolbar.add_action(add_event_action); + + calendar_widget.on_calendar_tile_click = [&] { + selected_calendar_button->set_text(calendar_widget.selected_calendar_text()); + }; + + calendar_widget.on_calendar_tile_doubleclick = [&] { + AddEventDialog::show(calendar_widget.selected_date(), window); + }; + + calendar_widget.on_month_tile_click = [&] { + selected_calendar_button->set_text(calendar_widget.selected_calendar_text()); + }; + + auto menubar = GUI::MenuBar::construct(); + auto& app_menu = menubar->add_menu("Calendar"); + app_menu.add_action(GUI::Action::create("Add Event", { Mod_Ctrl | Mod_Shift, Key_E }, Gfx::Bitmap::load_from_file("/res/icons/16x16/add-event.png"), + [&](const GUI::Action&) { + AddEventDialog::show(calendar_widget.selected_date(), window); + return; + })); + + app_menu.add_separator(); + + app_menu.add_action(GUI::CommonActions::make_quit_action([](auto&) { + GUI::Application::the()->quit(); + return; + })); + + auto& help_menu = menubar->add_menu("Help"); + help_menu.add_action(GUI::CommonActions::make_about_action("Calendar", app_icon)); + + app->set_menubar(move(menubar)); + window->show(); + app->exec(); +} diff --git a/Userland/Applications/CrashReporter/CMakeLists.txt b/Userland/Applications/CrashReporter/CMakeLists.txt new file mode 100644 index 0000000000..d391aa6b84 --- /dev/null +++ b/Userland/Applications/CrashReporter/CMakeLists.txt @@ -0,0 +1,10 @@ +compile_gml(CrashReporterWindow.gml CrashReporterWindowGML.h crash_reporter_window_gml) + + +set(SOURCES + main.cpp + CrashReporterWindowGML.h +) + +serenity_app(CrashReporter ICON app-crash-reporter) +target_link_libraries(CrashReporter LibCore LibCoreDump LibDesktop LibGUI) diff --git a/Userland/Applications/CrashReporter/CrashReporterWindow.gml b/Userland/Applications/CrashReporter/CrashReporterWindow.gml new file mode 100644 index 0000000000..acca32bd13 --- /dev/null +++ b/Userland/Applications/CrashReporter/CrashReporterWindow.gml @@ -0,0 +1,95 @@ +@GUI::Widget { + fill_with_background_color: true + + layout: @GUI::VerticalBoxLayout { + margins: [5, 5, 5, 5] + } + + @GUI::Widget { + fixed_height: 44 + + layout: @GUI::HorizontalBoxLayout { + spacing: 10 + } + + @GUI::ImageWidget { + name: "icon" + } + + @GUI::Label { + name: "description" + text_alignment: "CenterLeft" + } + } + + @GUI::Widget { + fixed_height: 18 + + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::Label { + text: "Executable path:" + text_alignment: "CenterLeft" + fixed_width: 90 + } + + @GUI::LinkLabel { + name: "executable_link" + text_alignment: "CenterLeft" + } + } + + @GUI::Widget { + fixed_height: 18 + + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::Label { + text: "Coredump path:" + text_alignment: "CenterLeft" + fixed_width: 90 + } + + @GUI::LinkLabel { + name: "coredump_link" + text_alignment: "CenterLeft" + } + } + + @GUI::Widget { + fixed_height: 18 + + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::Label { + text: "Backtrace:" + text_alignment: "CenterLeft" + } + } + + @GUI::TextEditor { + name: "backtrace_text_editor" + mode: "ReadOnly" + } + + @GUI::Widget { + fixed_height: 32 + + layout: @GUI::HorizontalBoxLayout { + } + + // HACK: We need something like Layout::add_spacer() in GML! :^) + @GUI::Widget { + } + + @GUI::Button { + name: "close_button" + text: "Close" + fixed_width: 70 + fixed_height: 22 + } + } +} diff --git a/Userland/Applications/CrashReporter/main.cpp b/Userland/Applications/CrashReporter/main.cpp new file mode 100644 index 0000000000..047a696ea3 --- /dev/null +++ b/Userland/Applications/CrashReporter/main.cpp @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2020, Linus Groh <mail@linusgroh.de> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include <AK/LexicalPath.h> +#include <AK/StringBuilder.h> +#include <AK/Types.h> +#include <AK/URL.h> +#include <Applications/CrashReporter/CrashReporterWindowGML.h> +#include <LibCore/ArgsParser.h> +#include <LibCoreDump/Backtrace.h> +#include <LibCoreDump/Reader.h> +#include <LibDesktop/AppFile.h> +#include <LibDesktop/Launcher.h> +#include <LibELF/CoreDump.h> +#include <LibGUI/Application.h> +#include <LibGUI/Button.h> +#include <LibGUI/FileIconProvider.h> +#include <LibGUI/Icon.h> +#include <LibGUI/ImageWidget.h> +#include <LibGUI/Label.h> +#include <LibGUI/Layout.h> +#include <LibGUI/LinkLabel.h> +#include <LibGUI/TextEditor.h> +#include <LibGUI/Window.h> +#include <string.h> + +static String build_backtrace(const CoreDump::Reader& coredump) +{ + StringBuilder builder; + + auto assertion = coredump.metadata().get("assertion"); + if (assertion.has_value() && !assertion.value().is_empty()) { + builder.append("ASSERTION FAILED: "); + builder.append(assertion.value().characters()); + builder.append('\n'); + builder.append('\n'); + } + + auto first = true; + for (auto& entry : coredump.backtrace().entries()) { + if (first) + first = false; + else + builder.append('\n'); + builder.append(entry.to_string()); + } + + return builder.build(); +} + +int main(int argc, char** argv) +{ + if (pledge("stdio shared_buffer accept cpath rpath unix fattr", nullptr) < 0) { + perror("pledge"); + return 1; + } + + const char* coredump_path = nullptr; + + Core::ArgsParser args_parser; + args_parser.set_general_help("Show information from an application crash coredump."); + args_parser.add_positional_argument(coredump_path, "Coredump path", "coredump-path"); + args_parser.parse(argc, argv); + + String backtrace; + String executable_path; + int pid { 0 }; + u8 termination_signal { 0 }; + + { + auto coredump = CoreDump::Reader::create(coredump_path); + if (!coredump) { + warnln("Could not open coredump '{}'", coredump_path); + return 1; + } + auto& process_info = coredump->process_info(); + backtrace = build_backtrace(*coredump); + executable_path = String(process_info.executable_path); + pid = process_info.pid; + termination_signal = process_info.termination_signal; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio shared_buffer accept rpath unix", nullptr) < 0) { + perror("pledge"); + return 1; + } + + if (unveil(executable_path.characters(), "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/res", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/tmp/portal/launch", "rw") < 0) { + perror("unveil"); + return 1; + } + + if (unveil(nullptr, nullptr) < 0) { + perror("unveil"); + return 1; + } + + auto app_icon = GUI::Icon::default_icon("app-crash-reporter"); + + auto window = GUI::Window::construct(); + window->set_title("Crash Reporter"); + window->set_icon(app_icon.bitmap_for_size(16)); + window->resize(460, 340); + window->center_on_screen(); + + auto& widget = window->set_main_widget<GUI::Widget>(); + widget.load_from_gml(crash_reporter_window_gml); + + auto& icon_image_widget = *widget.find_descendant_of_type_named<GUI::ImageWidget>("icon"); + icon_image_widget.set_bitmap(GUI::FileIconProvider::icon_for_executable(executable_path).bitmap_for_size(32)); + + auto app_name = LexicalPath(executable_path).basename(); + auto af = Desktop::AppFile::get_for_app(app_name); + if (af->is_valid()) + app_name = af->name(); + + auto& description_label = *widget.find_descendant_of_type_named<GUI::Label>("description"); + description_label.set_text(String::formatted("\"{}\" (PID {}) has crashed - {} (signal {})", app_name, pid, strsignal(termination_signal), termination_signal)); + + auto& executable_link_label = *widget.find_descendant_of_type_named<GUI::LinkLabel>("executable_link"); + executable_link_label.set_text(LexicalPath::canonicalized_path(executable_path)); + executable_link_label.on_click = [&] { + Desktop::Launcher::open(URL::create_with_file_protocol(LexicalPath(executable_path).dirname())); + }; + + auto& coredump_link_label = *widget.find_descendant_of_type_named<GUI::LinkLabel>("coredump_link"); + coredump_link_label.set_text(LexicalPath::canonicalized_path(coredump_path)); + coredump_link_label.on_click = [&] { + Desktop::Launcher::open(URL::create_with_file_protocol(LexicalPath(coredump_path).dirname())); + }; + + auto& backtrace_text_editor = *widget.find_descendant_of_type_named<GUI::TextEditor>("backtrace_text_editor"); + backtrace_text_editor.set_text(backtrace); + backtrace_text_editor.set_should_hide_unnecessary_scrollbars(true); + + auto& close_button = *widget.find_descendant_of_type_named<GUI::Button>("close_button"); + close_button.on_click = [&](auto) { + app->quit(); + }; + + window->show(); + + return app->exec(); +} diff --git a/Userland/Applications/Debugger/CMakeLists.txt b/Userland/Applications/Debugger/CMakeLists.txt new file mode 100644 index 0000000000..7e6f200ea4 --- /dev/null +++ b/Userland/Applications/Debugger/CMakeLists.txt @@ -0,0 +1,6 @@ +set(SOURCES + main.cpp +) + +serenity_bin(Debugger) +target_link_libraries(Debugger LibCore LibDebug LibX86 LibLine) diff --git a/Userland/Applications/Debugger/main.cpp b/Userland/Applications/Debugger/main.cpp new file mode 100644 index 0000000000..ef5bbe9671 --- /dev/null +++ b/Userland/Applications/Debugger/main.cpp @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2020, Itamar S. <itamar8910@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include <AK/Assertions.h> +#include <AK/ByteBuffer.h> +#include <AK/Demangle.h> +#include <AK/LogStream.h> +#include <AK/StringBuilder.h> +#include <AK/kmalloc.h> +#include <LibC/sys/arch/i386/regs.h> +#include <LibCore/ArgsParser.h> +#include <LibCore/File.h> +#include <LibDebug/DebugInfo.h> +#include <LibDebug/DebugSession.h> +#include <LibLine/Editor.h> +#include <LibX86/Disassembler.h> +#include <LibX86/Instruction.h> +#include <signal.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +RefPtr<Line::Editor> editor; + +OwnPtr<Debug::DebugSession> g_debug_session; + +static void handle_sigint(int) +{ + outln("Debugger: SIGINT"); + + // The destructor of DebugSession takes care of detaching + g_debug_session = nullptr; +} + +static void handle_print_registers(const PtraceRegisters& regs) +{ + outln("eax={:08x} ebx={:08x} ecx={:08x} edx={:08x}", regs.eax, regs.ebx, regs.ecx, regs.edx); + outln("esp={:08x} ebp={:08x} esi={:08x} edi={:08x}", regs.esp, regs.ebp, regs.esi, regs.edi); + outln("eip={:08x} eflags={:08x}", regs.eip, regs.eflags); +} + +static bool handle_disassemble_command(const String& command, void* first_instruction) +{ + auto parts = command.split(' '); + size_t number_of_instructions_to_disassemble = 5; + if (parts.size() == 2) { + auto number = parts[1].to_uint(); + if (!number.has_value()) + return false; + number_of_instructions_to_disassemble = number.value(); + } + + // FIXME: Instead of using a fixed "dump_size", + // we can feed instructions to the disassembler one by one + constexpr size_t dump_size = 0x100; + ByteBuffer code; + for (size_t i = 0; i < dump_size / sizeof(u32); ++i) { + auto value = g_debug_session->peek(reinterpret_cast<u32*>(first_instruction) + i); + if (!value.has_value()) + break; + code.append(&value, sizeof(u32)); + } + + X86::SimpleInstructionStream stream(code.data(), code.size()); + X86::Disassembler disassembler(stream); + + for (size_t i = 0; i < number_of_instructions_to_disassemble; ++i) { + auto offset = stream.offset(); + auto insn = disassembler.next(); + if (!insn.has_value()) + break; + + outln(" {:p} <+{}>:\t{}", offset + reinterpret_cast<size_t>(first_instruction), offset, insn.value().to_string(offset)); + } + + return true; +} + +static bool insert_breakpoint_at_address(FlatPtr address) +{ + return g_debug_session->insert_breakpoint((void*)address); +} + +static bool insert_breakpoint_at_source_position(const String& file, size_t line) +{ + auto result = g_debug_session->insert_breakpoint(file, line); + if (!result.has_value()) { + warnln("Could not insert breakpoint at {}:{}", file, line); + return false; + } + outln("Breakpoint inserted [{}:{} ({}:{:p})]", result.value().file_name, result.value().line_number, result.value().library_name, result.value().address); + return true; +} + +static bool insert_breakpoint_at_symbol(const String& symbol) +{ + auto result = g_debug_session->insert_breakpoint(symbol); + if (!result.has_value()) { + warnln("Could not insert breakpoint at symbol: {}", symbol); + return false; + } + outln("Breakpoint inserted [{}:{:p}]", result.value().library_name, result.value().address); + return true; +} + +static bool handle_breakpoint_command(const String& command) +{ + auto parts = command.split(' '); + if (parts.size() != 2) + return false; + + auto argument = parts[1]; + if (argument.is_empty()) + return false; + + if (argument.contains(":")) { + auto source_arguments = argument.split(':'); + if (source_arguments.size() != 2) + return false; + auto line = source_arguments[1].to_uint(); + if (!line.has_value()) + return false; + auto file = source_arguments[0]; + return insert_breakpoint_at_source_position(file, line.value()); + } + if ((argument.starts_with("0x"))) { + return insert_breakpoint_at_address(strtoul(argument.characters() + 2, nullptr, 16)); + } + + return insert_breakpoint_at_symbol(argument); +} + +static bool handle_examine_command(const String& command) +{ + auto parts = command.split(' '); + if (parts.size() != 2) + return false; + + auto argument = parts[1]; + if (argument.is_empty()) + return false; + + if (!(argument.starts_with("0x"))) { + return false; + } + u32 address = strtoul(argument.characters() + 2, nullptr, 16); + auto res = g_debug_session->peek((u32*)address); + if (!res.has_value()) { + printf("could not examine memory at address 0x%x\n", address); + return true; + } + printf("0x%x\n", res.value()); + return true; +} + +static void print_help() +{ + out("Options:\n" + "cont - Continue execution\n" + "si - step to the next instruction\n" + "sl - step to the next source line\n" + "line - show the position of the current instruction in the source code\n" + "regs - Print registers\n" + "dis [number of instructions] - Print disassembly\n" + "bp <address/symbol/file:line> - Insert a breakpoint\n" + "x <address> - examine dword in memory\n"); +} + +int main(int argc, char** argv) +{ + editor = Line::Editor::construct(); + + if (pledge("stdio proc ptrace exec rpath tty sigaction cpath unix fattr", nullptr) < 0) { + perror("pledge"); + return 1; + } + + const char* command = nullptr; + Core::ArgsParser args_parser; + args_parser.add_positional_argument(command, + "The program to be debugged, along with its arguments", + "program", Core::ArgsParser::Required::Yes); + args_parser.parse(argc, argv); + + auto result = Debug::DebugSession::exec_and_attach(command); + if (!result) { + warnln("Failed to start debugging session for: \"{}\"", command); + exit(1); + } + g_debug_session = result.release_nonnull(); + + struct sigaction sa; + memset(&sa, 0, sizeof(struct sigaction)); + sa.sa_handler = handle_sigint; + sigaction(SIGINT, &sa, nullptr); + + Debug::DebugInfo::SourcePosition previous_source_position; + bool in_step_line = false; + + g_debug_session->run(Debug::DebugSession::DesiredInitialDebugeeState::Stopped, [&](Debug::DebugSession::DebugBreakReason reason, Optional<PtraceRegisters> optional_regs) { + if (reason == Debug::DebugSession::DebugBreakReason::Exited) { + outln("Program exited."); + return Debug::DebugSession::DebugDecision::Detach; + } + + ASSERT(optional_regs.has_value()); + const PtraceRegisters& regs = optional_regs.value(); + + auto symbol_at_ip = g_debug_session->symbolicate(regs.eip); + + auto source_position = g_debug_session->get_source_position(regs.eip); + + if (in_step_line) { + bool no_source_info = !source_position.has_value(); + if (no_source_info || source_position.value() != previous_source_position) { + if (no_source_info) + outln("No source information for current instruction! stoppoing."); + in_step_line = false; + } else { + return Debug::DebugSession::DebugDecision::SingleStep; + } + } + + if (symbol_at_ip.has_value()) + outln("Program is stopped at: {:p} ({}:{})", regs.eip, symbol_at_ip.value().library_name, symbol_at_ip.value().symbol); + else + outln("Program is stopped at: {:p}", regs.eip); + + if (source_position.has_value()) { + previous_source_position = source_position.value(); + outln("Source location: {}:{}", source_position.value().file_path, source_position.value().line_number); + } else { + outln("(No source location information for the current instruction)"); + } + + for (;;) { + auto command_result = editor->get_line("(sdb) "); + + if (command_result.is_error()) + return Debug::DebugSession::DebugDecision::Detach; + + auto& command = command_result.value(); + + bool success = false; + Optional<Debug::DebugSession::DebugDecision> decision; + + if (command.is_empty() && !editor->history().is_empty()) { + command = editor->history().last().entry; + } + if (command == "cont") { + decision = Debug::DebugSession::DebugDecision::Continue; + success = true; + } else if (command == "si") { + decision = Debug::DebugSession::DebugDecision::SingleStep; + success = true; + } else if (command == "sl") { + if (source_position.has_value()) { + decision = Debug::DebugSession::DebugDecision::SingleStep; + in_step_line = true; + success = true; + } else { + outln("No source location information for the current instruction"); + } + } else if (command == "regs") { + handle_print_registers(regs); + success = true; + + } else if (command.starts_with("dis")) { + success = handle_disassemble_command(command, reinterpret_cast<void*>(regs.eip)); + + } else if (command.starts_with("bp")) { + success = handle_breakpoint_command(command); + } else if (command.starts_with("x")) { + success = handle_examine_command(command); + } + + if (success && !command.is_empty()) { + // Don't add repeated commands to history + if (editor->history().is_empty() || editor->history().last().entry != command) + editor->add_to_history(command); + } + if (!success) { + print_help(); + } + if (decision.has_value()) + return decision.value(); + } + }); +} diff --git a/Userland/Applications/DisplaySettings/CMakeLists.txt b/Userland/Applications/DisplaySettings/CMakeLists.txt new file mode 100644 index 0000000000..b541894194 --- /dev/null +++ b/Userland/Applications/DisplaySettings/CMakeLists.txt @@ -0,0 +1,11 @@ +compile_gml(DisplaySettingsWindow.gml DisplaySettingsWindowGML.h display_settings_window_gml) + +set(SOURCES + DisplaySettings.cpp + DisplaySettingsWindowGML.h + main.cpp + MonitorWidget.cpp +) + +serenity_app(DisplaySettings ICON app-display-settings) +target_link_libraries(DisplaySettings LibGUI) diff --git a/Userland/Applications/DisplaySettings/DisplaySettings.cpp b/Userland/Applications/DisplaySettings/DisplaySettings.cpp new file mode 100644 index 0000000000..ad68ebf827 --- /dev/null +++ b/Userland/Applications/DisplaySettings/DisplaySettings.cpp @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2019-2020, Jesse Buhagiar <jooster669@gmail.com> + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "DisplaySettings.h" +#include <AK/StringBuilder.h> +#include <Applications/DisplaySettings/DisplaySettingsWindowGML.h> +#include <LibCore/ConfigFile.h> +#include <LibCore/DirIterator.h> +#include <LibGUI/Application.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/ComboBox.h> +#include <LibGUI/Desktop.h> +#include <LibGUI/FilePicker.h> +#include <LibGUI/ItemListModel.h> +#include <LibGUI/Label.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/WindowServerConnection.h> +#include <LibGfx/Palette.h> +#include <LibGfx/SystemTheme.h> + +REGISTER_WIDGET(DisplaySettings, MonitorWidget) + +DisplaySettingsWidget::DisplaySettingsWidget() +{ + create_resolution_list(); + create_wallpaper_list(); + + create_frame(); + + load_current_settings(); +} + +void DisplaySettingsWidget::create_resolution_list() +{ + // TODO: Find a better way to get the default resolution + m_resolutions.append({ 640, 480 }); + m_resolutions.append({ 800, 600 }); + m_resolutions.append({ 1024, 768 }); + m_resolutions.append({ 1280, 720 }); + m_resolutions.append({ 1280, 768 }); + m_resolutions.append({ 1280, 1024 }); + m_resolutions.append({ 1360, 768 }); + m_resolutions.append({ 1368, 768 }); + m_resolutions.append({ 1440, 900 }); + m_resolutions.append({ 1600, 900 }); + m_resolutions.append({ 1920, 1080 }); + m_resolutions.append({ 2560, 1080 }); +} + +void DisplaySettingsWidget::create_wallpaper_list() +{ + Core::DirIterator iterator("/res/wallpapers/", Core::DirIterator::Flags::SkipDots); + + m_wallpapers.append("Use background color"); + + while (iterator.has_next()) { + m_wallpapers.append(iterator.next_path()); + } + + m_modes.append("simple"); + m_modes.append("tile"); + m_modes.append("center"); + m_modes.append("scaled"); +} + +void DisplaySettingsWidget::create_frame() +{ + load_from_gml(display_settings_window_gml); + + m_monitor_widget = *find_descendant_of_type_named<DisplaySettings::MonitorWidget>("monitor_widget"); + + m_wallpaper_combo = *find_descendant_of_type_named<GUI::ComboBox>("wallpaper_combo"); + m_wallpaper_combo->set_only_allow_values_from_model(true); + m_wallpaper_combo->set_model(*GUI::ItemListModel<AK::String>::create(m_wallpapers)); + m_wallpaper_combo->on_change = [this](auto& text, const GUI::ModelIndex& index) { + String path = text; + if (path.starts_with("/") && m_monitor_widget->set_wallpaper(path)) { + m_monitor_widget->update(); + return; + } + + if (index.row() == 0) { + path = ""; + } else { + if (index.is_valid()) { + StringBuilder builder; + builder.append("/res/wallpapers/"); + builder.append(path); + path = builder.to_string(); + } + } + + m_monitor_widget->set_wallpaper(path); + m_monitor_widget->update(); + }; + + auto& button = *find_descendant_of_type_named<GUI::Button>("wallpaper_open_button"); + button.set_icon(Gfx::Bitmap::load_from_file("/res/icons/16x16/open.png")); + button.on_click = [this](auto) { + Optional<String> open_path = GUI::FilePicker::get_open_filepath(nullptr, "Select wallpaper from file system."); + + if (!open_path.has_value()) + return; + + m_wallpaper_combo->set_only_allow_values_from_model(false); + m_wallpaper_combo->set_text(open_path.value()); + m_wallpaper_combo->set_only_allow_values_from_model(true); + }; + + m_mode_combo = *find_descendant_of_type_named<GUI::ComboBox>("mode_combo"); + m_mode_combo->set_only_allow_values_from_model(true); + m_mode_combo->set_model(*GUI::ItemListModel<AK::String>::create(m_modes)); + m_mode_combo->on_change = [this](auto&, const GUI::ModelIndex& index) { + m_monitor_widget->set_wallpaper_mode(m_modes.at(index.row())); + m_monitor_widget->update(); + }; + + m_resolution_combo = *find_descendant_of_type_named<GUI::ComboBox>("resolution_combo"); + m_resolution_combo->set_only_allow_values_from_model(true); + m_resolution_combo->set_model(*GUI::ItemListModel<Gfx::IntSize>::create(m_resolutions)); + m_resolution_combo->on_change = [this](auto&, const GUI::ModelIndex& index) { + m_monitor_widget->set_desktop_resolution(m_resolutions.at(index.row())); + m_monitor_widget->update(); + }; + + m_color_input = *find_descendant_of_type_named<GUI::ColorInput>("color_input"); + m_color_input->set_color_has_alpha_channel(false); + m_color_input->set_color_picker_title("Select color for desktop"); + m_color_input->on_change = [this] { + m_monitor_widget->set_background_color(m_color_input->color()); + m_monitor_widget->update(); + }; + + auto& ok_button = *find_descendant_of_type_named<GUI::Button>("ok_button"); + ok_button.on_click = [this](auto) { + send_settings_to_window_server(); + GUI::Application::the()->quit(); + }; + + auto& cancel_button = *find_descendant_of_type_named<GUI::Button>("cancel_button"); + cancel_button.on_click = [](auto) { + GUI::Application::the()->quit(); + }; + + auto& apply_button = *find_descendant_of_type_named<GUI::Button>("apply_button"); + apply_button.on_click = [this](auto) { + send_settings_to_window_server(); + }; +} + +void DisplaySettingsWidget::load_current_settings() +{ + auto ws_config(Core::ConfigFile::open("/etc/WindowServer/WindowServer.ini")); + auto wm_config = Core::ConfigFile::get_for_app("WindowManager"); + + /// Wallpaper path //////////////////////////////////////////////////////////////////////////// + /// Read wallpaper path from config file and set value to monitor widget and combo box. + auto selected_wallpaper = wm_config->read_entry("Background", "Wallpaper", ""); + if (!selected_wallpaper.is_empty()) { + m_monitor_widget->set_wallpaper(selected_wallpaper); + + Optional<size_t> optional_index; + if (selected_wallpaper.starts_with("/res/wallpapers/")) { + auto name_parts = selected_wallpaper.split('/', true); + optional_index = m_wallpapers.find_first_index(name_parts[2]); + + if (optional_index.has_value()) { + m_wallpaper_combo->set_selected_index(optional_index.value()); + } + } + + if (!optional_index.has_value()) { + m_wallpaper_combo->set_only_allow_values_from_model(false); + m_wallpaper_combo->set_text(selected_wallpaper); + m_wallpaper_combo->set_only_allow_values_from_model(true); + } + } else { + m_wallpaper_combo->set_selected_index(0); + } + + size_t index; + + /// Mode ////////////////////////////////////////////////////////////////////////////////////// + auto mode = ws_config->read_entry("Background", "Mode", "simple"); + if (!m_modes.contains_slow(mode)) { + warnln("Invalid background mode '{}' in WindowServer config, falling back to 'simple'", mode); + mode = "simple"; + } + m_monitor_widget->set_wallpaper_mode(mode); + index = m_modes.find_first_index(mode).value(); + m_mode_combo->set_selected_index(index); + + /// Resolution //////////////////////////////////////////////////////////////////////////////// + Gfx::IntSize find_size; + + // Let's attempt to find the current resolution and select it! + find_size.set_width(ws_config->read_num_entry("Screen", "Width", 1024)); + find_size.set_height(ws_config->read_num_entry("Screen", "Height", 768)); + + index = m_resolutions.find_first_index(find_size).value_or(0); + Gfx::IntSize m_current_resolution = m_resolutions.at(index); + m_monitor_widget->set_desktop_resolution(m_current_resolution); + m_resolution_combo->set_selected_index(index); + + /// Color ///////////////////////////////////////////////////////////////////////////////////// + /// If presend read from config file. If not paint with palet color. + Color palette_desktop_color = palette().desktop_background(); + + auto background_color = ws_config->read_entry("Background", "Color", ""); + if (!background_color.is_empty()) { + auto opt_color = Color::from_string(background_color); + if (opt_color.has_value()) + palette_desktop_color = opt_color.value(); + } + + m_color_input->set_color(palette_desktop_color); + m_monitor_widget->set_background_color(palette_desktop_color); + + m_monitor_widget->update(); +} + +void DisplaySettingsWidget::send_settings_to_window_server() +{ + auto result = GUI::WindowServerConnection::the().send_sync<Messages::WindowServer::SetResolution>(m_monitor_widget->desktop_resolution()); + if (!result->success()) { + GUI::MessageBox::show(nullptr, String::formatted("Reverting to resolution {}x{}", result->resolution().width(), result->resolution().height()), + "Unable to set resolution", GUI::MessageBox::Type::Error); + } + + if (!m_monitor_widget->wallpaper().is_empty()) { + GUI::Desktop::the().set_wallpaper(m_monitor_widget->wallpaper()); + } else { + dbgln("Setting color input: __{}__", m_color_input->text()); + GUI::Desktop::the().set_wallpaper(""); + GUI::Desktop::the().set_background_color(m_color_input->text()); + } + + GUI::Desktop::the().set_wallpaper_mode(m_monitor_widget->wallpaper_mode()); +} diff --git a/Userland/Applications/DisplaySettings/DisplaySettings.h b/Userland/Applications/DisplaySettings/DisplaySettings.h new file mode 100644 index 0000000000..30fb26ebed --- /dev/null +++ b/Userland/Applications/DisplaySettings/DisplaySettings.h @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2019-2020, Jesse Buhagiar <jooster669@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "MonitorWidget.h" +#include <LibGUI/ColorInput.h> +#include <LibGUI/ComboBox.h> + +class DisplaySettingsWidget : public GUI::Widget { + C_OBJECT(DisplaySettingsWidget); + +public: + DisplaySettingsWidget(); + +private: + void create_frame(); + void create_wallpaper_list(); + void create_resolution_list(); + void load_current_settings(); + void send_settings_to_window_server(); // Apply the settings to the Window Server + + Vector<String> m_wallpapers; + Vector<String> m_modes; + Vector<Gfx::IntSize> m_resolutions; + + RefPtr<DisplaySettings::MonitorWidget> m_monitor_widget; + RefPtr<GUI::ComboBox> m_wallpaper_combo; + RefPtr<GUI::ComboBox> m_mode_combo; + RefPtr<GUI::ComboBox> m_resolution_combo; + RefPtr<GUI::ColorInput> m_color_input; +}; diff --git a/Userland/Applications/DisplaySettings/DisplaySettingsWindow.gml b/Userland/Applications/DisplaySettings/DisplaySettingsWindow.gml new file mode 100644 index 0000000000..af9f204f03 --- /dev/null +++ b/Userland/Applications/DisplaySettings/DisplaySettingsWindow.gml @@ -0,0 +1,117 @@ +@GUI::Widget { + fill_with_background_color: true + + layout: @GUI::VerticalBoxLayout { + margins: [4, 4, 4, 4] + } + + @DisplaySettings::MonitorWidget { + name: "monitor_widget" + fixed_width: 338 + fixed_height: 248 + } + + @GUI::Widget { + shrink_to_fit: true + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::Label { + text: "Wallpaper:" + text_alignment: "CenterLeft" + fixed_width: 70 + } + + @GUI::ComboBox { + name: "wallpaper_combo" + } + + @GUI::Button { + name: "wallpaper_open_button" + tooltip: "Select wallpaper from file system." + button_style: "CoolBar" + fixed_width: 22 + fixed_height: 22 + } + } + + @GUI::Widget { + shrink_to_fit: true + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::Label { + text: "Modes:" + text_alignment: "CenterLeft" + fixed_width: 70 + } + + @GUI::ComboBox { + name: "mode_combo" + } + } + + @GUI::Widget { + shrink_to_fit: true + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::Label { + text: "Resolution:" + text_alignment: "CenterLeft" + fixed_width: 70 + } + + @GUI::ComboBox { + name: "resolution_combo" + } + } + + + @GUI::Widget { + shrink_to_fit: true + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::Label { + text: "Color:" + text_alignment: "CenterLeft" + fixed_width: 70 + } + + @GUI::ColorInput { + name: "color_input" + fixed_width: 90 + } + } + + @GUI::Widget { + } + + @GUI::Widget { + shrink_to_fit: true + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::Widget { + } + + @GUI::Button { + name: "ok_button" + text: "OK" + fixed_width: 60 + } + + @GUI::Button { + name: "cancel_button" + text: "Cancel" + fixed_width: 60 + } + + @GUI::Button { + name: "apply_button" + text: "Apply" + fixed_width: 60 + } + } +} diff --git a/Userland/Applications/DisplaySettings/MonitorWidget.cpp b/Userland/Applications/DisplaySettings/MonitorWidget.cpp new file mode 100644 index 0000000000..d6b809d449 --- /dev/null +++ b/Userland/Applications/DisplaySettings/MonitorWidget.cpp @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2020, Hüseyin Aslıtürk <asliturk@hotmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "MonitorWidget.h" +#include <LibGUI/Painter.h> +#include <LibGfx/Bitmap.h> + +namespace DisplaySettings { + +MonitorWidget::MonitorWidget() +{ + m_monitor_bitmap = Gfx::Bitmap::load_from_file("/res/graphics/monitor.png"); + m_monitor_rect = { 8, 9, 320, 180 }; +} + +bool MonitorWidget::set_wallpaper(String path) +{ + auto bitmap_ptr = Gfx::Bitmap::load_from_file(path); + if (!bitmap_ptr && !path.is_empty()) + return false; + m_desktop_wallpaper_path = path; + m_desktop_wallpaper_bitmap = bitmap_ptr; + return true; +} + +String MonitorWidget::wallpaper() +{ + return m_desktop_wallpaper_path; +} + +void MonitorWidget::set_wallpaper_mode(String mode) +{ + m_desktop_wallpaper_mode = mode; +} + +String MonitorWidget::wallpaper_mode() +{ + return m_desktop_wallpaper_mode; +} + +void MonitorWidget::set_desktop_resolution(Gfx::IntSize resolution) +{ + m_desktop_resolution = resolution; +} + +Gfx::IntSize MonitorWidget::desktop_resolution() +{ + return m_desktop_resolution; +} + +void MonitorWidget::set_background_color(Gfx::Color color) +{ + m_desktop_color = color; +} + +Gfx::Color MonitorWidget::background_color() +{ + return m_desktop_color; +} + +void MonitorWidget::paint_event(GUI::PaintEvent& event) +{ + Gfx::IntRect screen_rect = { 0, 0, m_desktop_resolution.width(), m_desktop_resolution.height() }; + auto screen_bitmap = Gfx::Bitmap::create(m_monitor_bitmap->format(), m_desktop_resolution); + GUI::Painter screen_painter(*screen_bitmap); + screen_painter.fill_rect(screen_rect, m_desktop_color); + + if (!m_desktop_wallpaper_bitmap.is_null()) { + if (m_desktop_wallpaper_mode == "simple") { + screen_painter.blit({ 0, 0 }, *m_desktop_wallpaper_bitmap, m_desktop_wallpaper_bitmap->rect()); + } else if (m_desktop_wallpaper_mode == "center") { + Gfx::IntPoint offset { screen_rect.width() / 2 - m_desktop_wallpaper_bitmap->size().width() / 2, screen_rect.height() / 2 - m_desktop_wallpaper_bitmap->size().height() / 2 }; + screen_painter.blit_offset(screen_rect.location(), *m_desktop_wallpaper_bitmap, screen_rect, offset); + } else if (m_desktop_wallpaper_mode == "tile") { + screen_painter.draw_tiled_bitmap(screen_bitmap->rect(), *m_desktop_wallpaper_bitmap); + } else if (m_desktop_wallpaper_mode == "scaled") { + screen_painter.draw_scaled_bitmap(screen_bitmap->rect(), *m_desktop_wallpaper_bitmap, m_desktop_wallpaper_bitmap->rect()); + } else { + ASSERT_NOT_REACHED(); + } + } + + GUI::Painter painter(*this); + painter.add_clip_rect(event.rect()); + + painter.blit({ 0, 0 }, *m_monitor_bitmap, m_monitor_bitmap->rect()); + painter.draw_scaled_bitmap(m_monitor_rect, *screen_bitmap, screen_bitmap->rect()); + + if (!m_desktop_resolution.is_null()) { + painter.draw_text(m_monitor_rect.translated(1, 1), m_desktop_resolution.to_string(), Gfx::TextAlignment::Center, Color::Black); + painter.draw_text(m_monitor_rect, m_desktop_resolution.to_string(), Gfx::TextAlignment::Center, Color::White); + } +} + +} diff --git a/Userland/Applications/DisplaySettings/MonitorWidget.h b/Userland/Applications/DisplaySettings/MonitorWidget.h new file mode 100644 index 0000000000..1961e91330 --- /dev/null +++ b/Userland/Applications/DisplaySettings/MonitorWidget.h @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2020, Hüseyin Aslıtürk <asliturk@hotmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Widget.h> + +namespace DisplaySettings { + +class MonitorWidget final : public GUI::Widget { + C_OBJECT(MonitorWidget); + +public: + bool set_wallpaper(String path); + String wallpaper(); + + void set_wallpaper_mode(String mode); + String wallpaper_mode(); + + void set_desktop_resolution(Gfx::IntSize resolution); + Gfx::IntSize desktop_resolution(); + + void set_background_color(Gfx::Color background_color); + Gfx::Color background_color(); + +private: + MonitorWidget(); + + virtual void paint_event(GUI::PaintEvent& event) override; + + Gfx::IntRect m_monitor_rect; + RefPtr<Gfx::Bitmap> m_monitor_bitmap; + + String m_desktop_wallpaper_path; + RefPtr<Gfx::Bitmap> m_desktop_wallpaper_bitmap; + String m_desktop_wallpaper_mode; + Gfx::IntSize m_desktop_resolution; + Gfx::Color m_desktop_color; +}; + +} diff --git a/Userland/Applications/DisplaySettings/main.cpp b/Userland/Applications/DisplaySettings/main.cpp new file mode 100644 index 0000000000..5018893470 --- /dev/null +++ b/Userland/Applications/DisplaySettings/main.cpp @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2019-2020, Jesse Buhagiar <jooster669@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "DisplaySettings.h" +#include <LibGUI/Action.h> +#include <LibGUI/Application.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Icon.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/TabWidget.h> +#include <LibGUI/Widget.h> +#include <LibGUI/Window.h> +#include <LibGfx/Bitmap.h> +#include <stdio.h> + +int main(int argc, char** argv) +{ + if (pledge("stdio thread shared_buffer rpath accept cpath wpath unix fattr", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio thread shared_buffer rpath accept cpath wpath", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app_icon = GUI::Icon::default_icon("app-display-settings"); + + // Let's create the tab pane that we'll hook our widgets up to :^) + auto tab_widget = GUI::TabWidget::construct(); + tab_widget->add_tab<DisplaySettingsWidget>("Display Settings"); + tab_widget->set_fill_with_background_color(true); // No black backgrounds! + + auto window = GUI::Window::construct(); + dbgln("main window: {}", window); + window->set_title("Display Settings"); + window->resize(360, 410); + window->set_resizable(false); + window->set_main_widget(tab_widget.ptr()); + window->set_icon(app_icon.bitmap_for_size(16)); + + auto menubar = GUI::MenuBar::construct(); + + auto& app_menu = menubar->add_menu("Display Settings"); + app_menu.add_action(GUI::CommonActions::make_quit_action([&](const GUI::Action&) { + app->quit(); + })); + + auto& help_menu = menubar->add_menu("Help"); + help_menu.add_action(GUI::CommonActions::make_about_action("Display Settings", app_icon)); + + app->set_menubar(move(menubar)); + window->show(); + return app->exec(); +} diff --git a/Userland/Applications/FileManager/CMakeLists.txt b/Userland/Applications/FileManager/CMakeLists.txt new file mode 100644 index 0000000000..d81fc89bef --- /dev/null +++ b/Userland/Applications/FileManager/CMakeLists.txt @@ -0,0 +1,13 @@ +compile_gml(FileManagerWindow.gml FileManagerWindowGML.h file_manager_window_gml) + +set(SOURCES + DesktopWidget.cpp + DirectoryView.cpp + FileManagerWindowGML.h + FileUtils.cpp + main.cpp + PropertiesWindow.cpp +) + +serenity_app(FileManager ICON filetype-folder) +target_link_libraries(FileManager LibGUI LibDesktop) diff --git a/Userland/Applications/FileManager/DesktopWidget.cpp b/Userland/Applications/FileManager/DesktopWidget.cpp new file mode 100644 index 0000000000..7ea01de9e8 --- /dev/null +++ b/Userland/Applications/FileManager/DesktopWidget.cpp @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "DesktopWidget.h" +#include <LibGUI/Painter.h> + +namespace FileManager { + +DesktopWidget::DesktopWidget() +{ +} + +DesktopWidget::~DesktopWidget() +{ +} + +void DesktopWidget::paint_event(GUI::PaintEvent& event) +{ + GUI::Painter painter(*this); + painter.add_clip_rect(event.rect()); + painter.clear_rect(event.rect(), Color(0, 0, 0, 0)); +} + +} diff --git a/Userland/Applications/FileManager/DesktopWidget.h b/Userland/Applications/FileManager/DesktopWidget.h new file mode 100644 index 0000000000..a6fd5b614b --- /dev/null +++ b/Userland/Applications/FileManager/DesktopWidget.h @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Widget.h> + +namespace FileManager { + +class DesktopWidget final : public GUI::Widget { + C_OBJECT(DesktopWidget); + +public: + virtual ~DesktopWidget() override; + +private: + virtual void paint_event(GUI::PaintEvent&) override; + + DesktopWidget(); +}; + +} diff --git a/Userland/Applications/FileManager/DirectoryView.cpp b/Userland/Applications/FileManager/DirectoryView.cpp new file mode 100644 index 0000000000..6d47d89671 --- /dev/null +++ b/Userland/Applications/FileManager/DirectoryView.cpp @@ -0,0 +1,591 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "DirectoryView.h" +#include "FileUtils.h" +#include <AK/LexicalPath.h> +#include <AK/NumberFormat.h> +#include <AK/StringBuilder.h> +#include <LibCore/MimeData.h> +#include <LibCore/StandardPaths.h> +#include <LibGUI/FileIconProvider.h> +#include <LibGUI/InputBox.h> +#include <LibGUI/Label.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/ModelEditingDelegate.h> +#include <LibGUI/SortingProxyModel.h> +#include <serenity.h> +#include <spawn.h> +#include <stdio.h> +#include <unistd.h> + +namespace FileManager { + +NonnullRefPtr<GUI::Action> LauncherHandler::create_launch_action(Function<void(const LauncherHandler&)> launch_handler) +{ + auto icon = GUI::FileIconProvider::icon_for_executable(details().executable).bitmap_for_size(16); + return GUI::Action::create(details().name, move(icon), [this, launch_handler = move(launch_handler)](auto&) { + launch_handler(*this); + }); +} + +RefPtr<LauncherHandler> DirectoryView::get_default_launch_handler(const NonnullRefPtrVector<LauncherHandler>& handlers) +{ + // If this is an application, pick it first + for (size_t i = 0; i < handlers.size(); i++) { + if (handlers[i].details().launcher_type == Desktop::Launcher::LauncherType::Application) + return handlers[i]; + } + // If there's a handler preferred by the user, pick this first + for (size_t i = 0; i < handlers.size(); i++) { + if (handlers[i].details().launcher_type == Desktop::Launcher::LauncherType::UserPreferred) + return handlers[i]; + } + // Otherwise, use the user's default, if available + for (size_t i = 0; i < handlers.size(); i++) { + if (handlers[i].details().launcher_type == Desktop::Launcher::LauncherType::UserDefault) + return handlers[i]; + } + // If still no match, use the first one we find + if (!handlers.is_empty()) { + return handlers[0]; + } + + return {}; +} + +NonnullRefPtrVector<LauncherHandler> DirectoryView::get_launch_handlers(const URL& url) +{ + NonnullRefPtrVector<LauncherHandler> handlers; + for (auto& h : Desktop::Launcher::get_handlers_with_details_for_url(url)) { + handlers.append(adopt(*new LauncherHandler(h))); + } + return handlers; +} + +NonnullRefPtrVector<LauncherHandler> DirectoryView::get_launch_handlers(const String& path) +{ + return get_launch_handlers(URL::create_with_file_protocol(path)); +} + +void DirectoryView::handle_activation(const GUI::ModelIndex& index) +{ + if (!index.is_valid()) + return; + dbgln("on activation: {},{}, this={:p}, m_model={:p}", index.row(), index.column(), this, m_model.ptr()); + auto& node = this->node(index); + auto path = node.full_path(); + + struct stat st; + if (stat(path.characters(), &st) < 0) { + perror("stat"); + return; + } + + if (S_ISDIR(st.st_mode)) { + if (is_desktop()) { + Desktop::Launcher::open(URL::create_with_file_protocol(path)); + return; + } + open(path); + return; + } + + auto url = URL::create_with_file_protocol(path); + auto launcher_handlers = get_launch_handlers(url); + auto default_launcher = get_default_launch_handler(launcher_handlers); + if (default_launcher) { + launch(url, *default_launcher); + } else { + auto error_message = String::format("Could not open %s", path.characters()); + GUI::MessageBox::show(window(), error_message, "File Manager", GUI::MessageBox::Type::Error); + } +} + +DirectoryView::DirectoryView(Mode mode) + : m_mode(mode) + , m_model(GUI::FileSystemModel::create({})) + , m_sorting_model(GUI::SortingProxyModel::create(m_model)) +{ + set_active_widget(nullptr); + set_content_margins({ 2, 2, 2, 2 }); + + setup_actions(); + + m_error_label = add<GUI::Label>(); + m_error_label->set_font(m_error_label->font().bold_variant()); + + setup_model(); + + setup_icon_view(); + if (mode != Mode::Desktop) { + setup_columns_view(); + setup_table_view(); + } + + set_view_mode(ViewMode::Icon); +} + +const GUI::FileSystemModel::Node& DirectoryView::node(const GUI::ModelIndex& index) const +{ + return model().node(m_sorting_model->map_to_source(index)); +} + +void DirectoryView::setup_model() +{ + m_model->on_error = [this](int, const char* error_string) { + auto failed_path = m_model->root_path(); + auto error_message = String::formatted("Could not read {}:\n{}", failed_path, error_string); + m_error_label->set_text(error_message); + set_active_widget(m_error_label); + + m_mkdir_action->set_enabled(false); + m_touch_action->set_enabled(false); + + add_path_to_history(model().root_path()); + + if (on_path_change) + on_path_change(failed_path, false); + }; + + m_model->on_complete = [this] { + if (m_table_view) + m_table_view->selection().clear(); + if (m_icon_view) + m_icon_view->selection().clear(); + + add_path_to_history(model().root_path()); + + bool can_write_in_path = access(model().root_path().characters(), W_OK) == 0; + + m_mkdir_action->set_enabled(can_write_in_path); + m_touch_action->set_enabled(can_write_in_path); + + if (on_path_change) + on_path_change(model().root_path(), can_write_in_path); + }; + + m_model->register_client(*this); + + m_model->on_thumbnail_progress = [this](int done, int total) { + if (on_thumbnail_progress) + on_thumbnail_progress(done, total); + }; + + if (is_desktop()) + m_model->set_root_path(Core::StandardPaths::desktop_directory()); +} + +void DirectoryView::setup_icon_view() +{ + m_icon_view = add<GUI::IconView>(); + m_icon_view->set_should_hide_unnecessary_scrollbars(true); + m_icon_view->set_selection_mode(GUI::AbstractView::SelectionMode::MultiSelection); + m_icon_view->set_editable(true); + m_icon_view->set_edit_triggers(GUI::AbstractView::EditTrigger::EditKeyPressed); + m_icon_view->aid_create_editing_delegate = [](auto&) { + return make<GUI::StringModelEditingDelegate>(); + }; + + if (is_desktop()) { + m_icon_view->set_frame_shape(Gfx::FrameShape::NoFrame); + m_icon_view->set_scrollbars_enabled(false); + m_icon_view->set_fill_with_background_color(false); + m_icon_view->set_draw_item_text_with_shadow(true); + m_icon_view->set_flow_direction(GUI::IconView::FlowDirection::TopToBottom); + } + + m_icon_view->set_model(m_sorting_model); + m_icon_view->set_model_column(GUI::FileSystemModel::Column::Name); + m_icon_view->on_activation = [&](auto& index) { + handle_activation(index); + }; + m_icon_view->on_selection_change = [this] { + handle_selection_change(); + }; + m_icon_view->on_context_menu_request = [this](auto& index, auto& event) { + if (on_context_menu_request) + on_context_menu_request(index, event); + }; + m_icon_view->on_drop = [this](auto& index, auto& event) { + handle_drop(index, event); + }; +} + +void DirectoryView::setup_columns_view() +{ + m_columns_view = add<GUI::ColumnsView>(); + m_columns_view->set_should_hide_unnecessary_scrollbars(true); + m_columns_view->set_selection_mode(GUI::AbstractView::SelectionMode::MultiSelection); + m_columns_view->set_editable(true); + m_columns_view->set_edit_triggers(GUI::AbstractView::EditTrigger::EditKeyPressed); + m_columns_view->aid_create_editing_delegate = [](auto&) { + return make<GUI::StringModelEditingDelegate>(); + }; + + m_columns_view->set_model(m_sorting_model); + m_columns_view->set_model_column(GUI::FileSystemModel::Column::Name); + + m_columns_view->on_activation = [&](auto& index) { + handle_activation(index); + }; + + m_columns_view->on_selection_change = [this] { + handle_selection_change(); + }; + + m_columns_view->on_context_menu_request = [this](auto& index, auto& event) { + if (on_context_menu_request) + on_context_menu_request(index, event); + }; + + m_columns_view->on_drop = [this](auto& index, auto& event) { + handle_drop(index, event); + }; +} + +void DirectoryView::setup_table_view() +{ + m_table_view = add<GUI::TableView>(); + m_table_view->set_should_hide_unnecessary_scrollbars(true); + m_table_view->set_selection_mode(GUI::AbstractView::SelectionMode::MultiSelection); + m_table_view->set_editable(true); + m_table_view->set_edit_triggers(GUI::AbstractView::EditTrigger::EditKeyPressed); + m_table_view->aid_create_editing_delegate = [](auto&) { + return make<GUI::StringModelEditingDelegate>(); + }; + + m_table_view->set_model(m_sorting_model); + m_table_view->set_key_column_and_sort_order(GUI::FileSystemModel::Column::Name, GUI::SortOrder::Ascending); + + m_table_view->on_activation = [&](auto& index) { + handle_activation(index); + }; + + m_table_view->on_selection_change = [this] { + handle_selection_change(); + }; + + m_table_view->on_context_menu_request = [this](auto& index, auto& event) { + if (on_context_menu_request) + on_context_menu_request(index, event); + }; + + m_table_view->on_drop = [this](auto& index, auto& event) { + handle_drop(index, event); + }; +} + +DirectoryView::~DirectoryView() +{ + m_model->unregister_client(*this); +} + +void DirectoryView::model_did_update(unsigned flags) +{ + if (flags & GUI::Model::UpdateFlag::InvalidateAllIndexes) { + for_each_view_implementation([](auto& view) { + view.selection().clear(); + }); + } + update_statusbar(); +} + +void DirectoryView::set_view_mode(ViewMode mode) +{ + if (m_view_mode == mode) + return; + m_view_mode = mode; + update(); + if (mode == ViewMode::Table) { + set_active_widget(m_table_view); + return; + } + if (mode == ViewMode::Columns) { + set_active_widget(m_columns_view); + return; + } + if (mode == ViewMode::Icon) { + set_active_widget(m_icon_view); + return; + } + ASSERT_NOT_REACHED(); +} + +void DirectoryView::add_path_to_history(const StringView& path) +{ + if (m_path_history.size() && m_path_history.at(m_path_history_position) == path) + return; + + if (m_path_history_position < m_path_history.size()) + m_path_history.resize(m_path_history_position + 1); + + m_path_history.append(path); + m_path_history_position = m_path_history.size() - 1; +} + +void DirectoryView::open(const StringView& path) +{ + if (model().root_path() == path) { + model().update(); + return; + } + + set_active_widget(¤t_view()); + model().set_root_path(path); +} + +void DirectoryView::set_status_message(const StringView& message) +{ + if (on_status_message) + on_status_message(message); +} + +void DirectoryView::open_parent_directory() +{ + auto path = String::formatted("{}/..", model().root_path()); + model().set_root_path(path); +} + +void DirectoryView::refresh() +{ + model().update(); +} + +void DirectoryView::open_previous_directory() +{ + if (m_path_history_position > 0) { + set_active_widget(¤t_view()); + m_path_history_position--; + model().set_root_path(m_path_history[m_path_history_position]); + } +} +void DirectoryView::open_next_directory() +{ + if (m_path_history_position < m_path_history.size() - 1) { + set_active_widget(¤t_view()); + m_path_history_position++; + model().set_root_path(m_path_history[m_path_history_position]); + } +} + +void DirectoryView::update_statusbar() +{ + // If we're triggered during widget construction, just ignore it. + if (m_view_mode == ViewMode::Invalid) + return; + + size_t total_size = model().node({}).total_size; + if (current_view().selection().is_empty()) { + set_status_message(String::formatted("{} item(s) ({})", + model().row_count(), + human_readable_size(total_size))); + return; + } + + int selected_item_count = current_view().selection().size(); + size_t selected_byte_count = 0; + + current_view().selection().for_each_index([&](auto& index) { + auto& model = *current_view().model(); + auto size_index = model.index(index.row(), GUI::FileSystemModel::Column::Size, model.parent_index(index)); + auto file_size = size_index.data().to_i32(); + selected_byte_count += file_size; + }); + + StringBuilder builder; + builder.append(String::number(selected_item_count)); + builder.append(" item"); + if (selected_item_count != 1) + builder.append('s'); + builder.append(" selected ("); + builder.append(human_readable_size(selected_byte_count).characters()); + builder.append(')'); + + if (selected_item_count == 1) { + auto& node = this->node(current_view().selection().first()); + if (!node.symlink_target.is_empty()) { + builder.append(" -> "); + builder.append(node.symlink_target); + } + } + + set_status_message(builder.to_string()); +} + +void DirectoryView::set_should_show_dotfiles(bool show_dotfiles) +{ + m_model->set_should_show_dotfiles(show_dotfiles); +} + +void DirectoryView::launch(const URL&, const LauncherHandler& launcher_handler) +{ + pid_t child; + if (launcher_handler.details().launcher_type == Desktop::Launcher::LauncherType::Application) { + const char* argv[] = { launcher_handler.details().name.characters(), nullptr }; + posix_spawn(&child, launcher_handler.details().executable.characters(), nullptr, nullptr, const_cast<char**>(argv), environ); + if (disown(child) < 0) + perror("disown"); + } else { + for (auto& path : selected_file_paths()) { + const char* argv[] = { launcher_handler.details().name.characters(), path.characters(), nullptr }; + posix_spawn(&child, launcher_handler.details().executable.characters(), nullptr, nullptr, const_cast<char**>(argv), environ); + if (disown(child) < 0) + perror("disown"); + } + } +} + +Vector<String> DirectoryView::selected_file_paths() const +{ + Vector<String> paths; + auto& view = current_view(); + auto& model = *view.model(); + view.selection().for_each_index([&](const GUI::ModelIndex& index) { + auto parent_index = model.parent_index(index); + auto name_index = model.index(index.row(), GUI::FileSystemModel::Column::Name, parent_index); + auto path = name_index.data(GUI::ModelRole::Custom).to_string(); + paths.append(path); + }); + return paths; +} + +void DirectoryView::do_delete(bool should_confirm) +{ + auto paths = selected_file_paths(); + ASSERT(!paths.is_empty()); + FileUtils::delete_paths(paths, should_confirm, window()); +} + +void DirectoryView::handle_selection_change() +{ + update_statusbar(); + + bool can_delete = !current_view().selection().is_empty() && access(path().characters(), W_OK) == 0; + m_delete_action->set_enabled(can_delete); + m_force_delete_action->set_enabled(can_delete); + + if (on_selection_change) + on_selection_change(current_view()); +} + +void DirectoryView::setup_actions() +{ + m_mkdir_action = GUI::Action::create("New directory...", { Mod_Ctrl | Mod_Shift, Key_N }, Gfx::Bitmap::load_from_file("/res/icons/16x16/mkdir.png"), [&](const GUI::Action&) { + String value; + if (GUI::InputBox::show(value, window(), "Enter name:", "New directory") == GUI::InputBox::ExecOK && !value.is_empty()) { + auto new_dir_path = LexicalPath::canonicalized_path(String::formatted("{}/{}", path(), value)); + int rc = mkdir(new_dir_path.characters(), 0777); + if (rc < 0) { + auto saved_errno = errno; + GUI::MessageBox::show(window(), String::formatted("mkdir(\"{}\") failed: {}", new_dir_path, strerror(saved_errno)), "Error", GUI::MessageBox::Type::Error); + } + } + }); + + m_touch_action = GUI::Action::create("New file...", { Mod_Ctrl | Mod_Shift, Key_F }, Gfx::Bitmap::load_from_file("/res/icons/16x16/new.png"), [&](const GUI::Action&) { + String value; + if (GUI::InputBox::show(value, window(), "Enter name:", "New file") == GUI::InputBox::ExecOK && !value.is_empty()) { + auto new_file_path = LexicalPath::canonicalized_path(String::formatted("{}/{}", path(), value)); + struct stat st; + int rc = stat(new_file_path.characters(), &st); + if ((rc < 0 && errno != ENOENT)) { + auto saved_errno = errno; + GUI::MessageBox::show(window(), String::formatted("stat(\"{}\") failed: {}", new_file_path, strerror(saved_errno)), "Error", GUI::MessageBox::Type::Error); + return; + } + if (rc == 0) { + GUI::MessageBox::show(window(), String::formatted("{}: Already exists", new_file_path), "Error", GUI::MessageBox::Type::Error); + return; + } + int fd = creat(new_file_path.characters(), 0666); + if (fd < 0) { + auto saved_errno = errno; + GUI::MessageBox::show(window(), String::formatted("creat(\"{}\") failed: {}", new_file_path, strerror(saved_errno)), "Error", GUI::MessageBox::Type::Error); + return; + } + rc = close(fd); + ASSERT(rc >= 0); + } + }); + + m_open_terminal_action = GUI::Action::create("Open Terminal here", Gfx::Bitmap::load_from_file("/res/icons/16x16/app-terminal.png"), [&](auto&) { + posix_spawn_file_actions_t spawn_actions; + posix_spawn_file_actions_init(&spawn_actions); + posix_spawn_file_actions_addchdir(&spawn_actions, path().characters()); + pid_t pid; + const char* argv[] = { "Terminal", nullptr }; + if ((errno = posix_spawn(&pid, "/bin/Terminal", &spawn_actions, nullptr, const_cast<char**>(argv), environ))) { + perror("posix_spawn"); + } else { + if (disown(pid) < 0) + perror("disown"); + } + posix_spawn_file_actions_destroy(&spawn_actions); + }); + + m_delete_action = GUI::CommonActions::make_delete_action([this](auto&) { do_delete(true); }, window()); + + m_force_delete_action = GUI::Action::create( + "Delete without confirmation", { Mod_Shift, Key_Delete }, + [this](auto&) { do_delete(false); }, + window()); +} + +void DirectoryView::handle_drop(const GUI::ModelIndex& index, const GUI::DropEvent& event) +{ + if (!event.mime_data().has_urls()) + return; + auto urls = event.mime_data().urls(); + if (urls.is_empty()) { + dbgln("No files to drop"); + return; + } + + auto& target_node = node(index); + if (!target_node.is_directory()) + return; + + bool had_accepted_drop = false; + for (auto& url_to_copy : urls) { + if (!url_to_copy.is_valid() || url_to_copy.path() == target_node.full_path()) + continue; + auto new_path = String::formatted("{}/{}", target_node.full_path(), LexicalPath(url_to_copy.path()).basename()); + if (url_to_copy.path() == new_path) + continue; + + if (!FileUtils::copy_file_or_directory(url_to_copy.path(), new_path)) { + auto error_message = String::formatted("Could not copy {} into {}.", url_to_copy.to_string(), new_path); + GUI::MessageBox::show(window(), error_message, "File Manager", GUI::MessageBox::Type::Error); + } else { + had_accepted_drop = true; + } + } + if (had_accepted_drop && on_accepted_drop) + on_accepted_drop(); +} + +} diff --git a/Userland/Applications/FileManager/DirectoryView.h b/Userland/Applications/FileManager/DirectoryView.h new file mode 100644 index 0000000000..a72b63aaf5 --- /dev/null +++ b/Userland/Applications/FileManager/DirectoryView.h @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/URL.h> +#include <AK/Vector.h> +#include <LibDesktop/Launcher.h> +#include <LibGUI/Action.h> +#include <LibGUI/ColumnsView.h> +#include <LibGUI/FileSystemModel.h> +#include <LibGUI/IconView.h> +#include <LibGUI/StackWidget.h> +#include <LibGUI/TableView.h> +#include <sys/stat.h> + +namespace FileManager { + +class LauncherHandler : public RefCounted<LauncherHandler> { +public: + LauncherHandler(const NonnullRefPtr<Desktop::Launcher::Details>& details) + : m_details(details) + { + } + + NonnullRefPtr<GUI::Action> create_launch_action(Function<void(const LauncherHandler&)>); + const Desktop::Launcher::Details& details() const { return *m_details; } + +private: + NonnullRefPtr<Desktop::Launcher::Details> m_details; +}; + +class DirectoryView final + : public GUI::StackWidget + , private GUI::ModelClient { + C_OBJECT(DirectoryView); + +public: + enum class Mode { + Desktop, + Normal, + }; + + virtual ~DirectoryView() override; + + void open(const StringView& path); + String path() const { return model().root_path(); } + void open_parent_directory(); + void open_previous_directory(); + void open_next_directory(); + int path_history_size() const { return m_path_history.size(); } + int path_history_position() const { return m_path_history_position; } + static RefPtr<LauncherHandler> get_default_launch_handler(const NonnullRefPtrVector<LauncherHandler>& handlers); + NonnullRefPtrVector<LauncherHandler> get_launch_handlers(const URL& url); + NonnullRefPtrVector<LauncherHandler> get_launch_handlers(const String& path); + + void refresh(); + + void launch(const AK::URL&, const LauncherHandler&); + + Function<void(const StringView& path, bool can_write_in_path)> on_path_change; + Function<void(GUI::AbstractView&)> on_selection_change; + Function<void(const GUI::ModelIndex&, const GUI::ContextMenuEvent&)> on_context_menu_request; + Function<void(const StringView&)> on_status_message; + Function<void(int done, int total)> on_thumbnail_progress; + Function<void()> on_accepted_drop; + + enum ViewMode { + Invalid, + Table, + Columns, + Icon + }; + void set_view_mode(ViewMode); + ViewMode view_mode() const { return m_view_mode; } + + GUI::AbstractView& current_view() + { + switch (m_view_mode) { + case ViewMode::Table: + return *m_table_view; + case ViewMode::Columns: + return *m_columns_view; + case ViewMode::Icon: + return *m_icon_view; + default: + ASSERT_NOT_REACHED(); + } + } + + const GUI::AbstractView& current_view() const + { + return const_cast<DirectoryView*>(this)->current_view(); + } + + template<typename Callback> + void for_each_view_implementation(Callback callback) + { + if (m_icon_view) + callback(*m_icon_view); + if (m_table_view) + callback(*m_table_view); + if (m_columns_view) + callback(*m_columns_view); + } + + void set_should_show_dotfiles(bool); + + const GUI::FileSystemModel::Node& node(const GUI::ModelIndex&) const; + + bool is_desktop() const { return m_mode == Mode::Desktop; } + + Vector<String> selected_file_paths() const; + + GUI::Action& mkdir_action() { return *m_mkdir_action; } + GUI::Action& touch_action() { return *m_touch_action; } + GUI::Action& open_terminal_action() { return *m_open_terminal_action; } + GUI::Action& delete_action() { return *m_delete_action; } + GUI::Action& force_delete_action() { return *m_force_delete_action; } + +private: + explicit DirectoryView(Mode); + + const GUI::FileSystemModel& model() const { return *m_model; } + GUI::FileSystemModel& model() { return *m_model; } + + void handle_selection_change(); + void handle_drop(const GUI::ModelIndex&, const GUI::DropEvent&); + void do_delete(bool should_confirm); + + // ^GUI::ModelClient + virtual void model_did_update(unsigned) override; + + void setup_actions(); + void setup_model(); + void setup_icon_view(); + void setup_columns_view(); + void setup_table_view(); + + void handle_activation(const GUI::ModelIndex&); + + void set_status_message(const StringView&); + void update_statusbar(); + + Mode m_mode { Mode::Normal }; + ViewMode m_view_mode { Invalid }; + + NonnullRefPtr<GUI::FileSystemModel> m_model; + NonnullRefPtr<GUI::SortingProxyModel> m_sorting_model; + size_t m_path_history_position { 0 }; + Vector<String> m_path_history; + void add_path_to_history(const StringView& path); + + RefPtr<GUI::Label> m_error_label; + + RefPtr<GUI::TableView> m_table_view; + RefPtr<GUI::IconView> m_icon_view; + RefPtr<GUI::ColumnsView> m_columns_view; + + RefPtr<GUI::Action> m_mkdir_action; + RefPtr<GUI::Action> m_touch_action; + RefPtr<GUI::Action> m_open_terminal_action; + RefPtr<GUI::Action> m_delete_action; + RefPtr<GUI::Action> m_force_delete_action; +}; + +} diff --git a/Userland/Applications/FileManager/FileManagerWindow.gml b/Userland/Applications/FileManager/FileManagerWindow.gml new file mode 100644 index 0000000000..a13218dd16 --- /dev/null +++ b/Userland/Applications/FileManager/FileManagerWindow.gml @@ -0,0 +1,53 @@ +@GUI::Widget { + fill_with_background_color: true + layout: @GUI::VerticalBoxLayout { + spacing: 2 + } + + @GUI::ToolBarContainer { + @GUI::ToolBar { + name: "main_toolbar" + } + @GUI::ToolBar { + name: "location_toolbar" + visible: false + + @GUI::Label { + text: "Location: " + autosize: true + } + + @GUI::TextBox { + name: "location_textbox" + fixed_height: 22 + } + } + @GUI::ToolBar { + name: "breadcrumb_toolbar" + + @GUI::BreadcrumbBar { + name: "breadcrumb_bar" + } + } + } + + @GUI::HorizontalSplitter { + name: "splitter" + + @GUI::TreeView { + name: "tree_view" + fixed_width: 175 + } + + } + + @GUI::StatusBar { + name: "statusbar" + + @GUI::ProgressBar { + name: "progressbar" + text: "Generating thumbnails: " + visible: false + } + } +} diff --git a/Userland/Applications/FileManager/FileUtils.cpp b/Userland/Applications/FileManager/FileUtils.cpp new file mode 100644 index 0000000000..76d2ee0079 --- /dev/null +++ b/Userland/Applications/FileManager/FileUtils.cpp @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "FileUtils.h" +#include <AK/LexicalPath.h> +#include <AK/ScopeGuard.h> +#include <AK/StringBuilder.h> +#include <LibCore/DirIterator.h> +#include <LibCore/File.h> +#include <LibGUI/MessageBox.h> +#include <stdio.h> +#include <stdlib.h> +#include <sys/stat.h> +#include <unistd.h> + +namespace FileUtils { + +void delete_path(const String& path, GUI::Window* parent_window) +{ + struct stat st; + if (lstat(path.characters(), &st)) { + GUI::MessageBox::show(parent_window, + String::formatted("lstat({}) failed: {}", path, strerror(errno)), + "Delete failed", + GUI::MessageBox::Type::Error); + } + + if (S_ISDIR(st.st_mode)) { + String error_path; + int error = FileUtils::delete_directory(path, error_path); + + if (error) { + GUI::MessageBox::show(parent_window, + String::formatted("Failed to delete directory \"{}\": {}", error_path, strerror(error)), + "Delete failed", + GUI::MessageBox::Type::Error); + } + } else if (unlink(path.characters()) < 0) { + int saved_errno = errno; + GUI::MessageBox::show(parent_window, + String::formatted("unlink(\"{}\") failed: {}", path, strerror(saved_errno)), + "Delete failed", + GUI::MessageBox::Type::Error); + } +} + +void delete_paths(const Vector<String>& paths, bool should_confirm, GUI::Window* parent_window) +{ + String message; + if (paths.size() == 1) { + message = String::formatted("Really delete {}?", LexicalPath(paths[0]).basename()); + } else { + message = String::formatted("Really delete {} files?", paths.size()); + } + + if (should_confirm) { + auto result = GUI::MessageBox::show(parent_window, + message, + "Confirm deletion", + GUI::MessageBox::Type::Warning, + GUI::MessageBox::InputType::OKCancel); + if (result == GUI::MessageBox::ExecCancel) + return; + } + + for (auto& path : paths) { + delete_path(path, parent_window); + } +} + +int delete_directory(String directory, String& file_that_caused_error) +{ + Core::DirIterator iterator(directory, Core::DirIterator::SkipDots); + if (iterator.has_error()) { + file_that_caused_error = directory; + return -1; + } + + while (iterator.has_next()) { + auto file_to_delete = String::formatted("{}/{}", directory, iterator.next_path()); + struct stat st; + + if (lstat(file_to_delete.characters(), &st)) { + file_that_caused_error = file_to_delete; + return errno; + } + + if (S_ISDIR(st.st_mode)) { + if (delete_directory(file_to_delete, file_to_delete)) { + file_that_caused_error = file_to_delete; + return errno; + } + } else if (unlink(file_to_delete.characters())) { + file_that_caused_error = file_to_delete; + return errno; + } + } + + if (rmdir(directory.characters())) { + file_that_caused_error = directory; + return errno; + } + + return 0; +} + +bool copy_file_or_directory(const String& src_path, const String& dst_path) +{ + int duplicate_count = 0; + while (access(get_duplicate_name(dst_path, duplicate_count).characters(), F_OK) == 0) { + ++duplicate_count; + } + if (duplicate_count != 0) { + return copy_file_or_directory(src_path, get_duplicate_name(dst_path, duplicate_count)); + } + + auto source_or_error = Core::File::open(src_path, Core::IODevice::ReadOnly); + if (source_or_error.is_error()) + return false; + + auto& source = *source_or_error.value(); + + struct stat src_stat; + int rc = fstat(source.fd(), &src_stat); + if (rc < 0) + return false; + + if (source.is_directory()) + return copy_directory(src_path, dst_path, src_stat); + + return copy_file(dst_path, src_stat, source); +} + +bool copy_directory(const String& src_path, const String& dst_path, const struct stat& src_stat) +{ + int rc = mkdir(dst_path.characters(), 0755); + if (rc < 0) { + return false; + } + Core::DirIterator di(src_path, Core::DirIterator::SkipDots); + if (di.has_error()) { + return false; + } + while (di.has_next()) { + String filename = di.next_path(); + bool is_copied = copy_file_or_directory( + String::formatted("{}/{}", src_path, filename), + String::formatted("{}/{}", dst_path, filename)); + if (!is_copied) { + return false; + } + } + + auto my_umask = umask(0); + umask(my_umask); + rc = chmod(dst_path.characters(), src_stat.st_mode & ~my_umask); + if (rc < 0) { + return false; + } + return true; +} + +bool copy_file(const String& dst_path, const struct stat& src_stat, Core::File& source) +{ + int dst_fd = creat(dst_path.characters(), 0666); + if (dst_fd < 0) { + if (errno != EISDIR) { + return false; + } + auto dst_dir_path = String::formatted("{}/{}", dst_path, LexicalPath(source.filename()).basename()); + dst_fd = creat(dst_dir_path.characters(), 0666); + if (dst_fd < 0) { + return false; + } + } + + ScopeGuard close_fd_guard([dst_fd]() { close(dst_fd); }); + + if (src_stat.st_size > 0) { + if (ftruncate(dst_fd, src_stat.st_size) < 0) { + perror("cp: ftruncate"); + return false; + } + } + + for (;;) { + char buffer[32768]; + ssize_t nread = read(source.fd(), buffer, sizeof(buffer)); + if (nread < 0) { + return false; + } + if (nread == 0) + break; + ssize_t remaining_to_write = nread; + char* bufptr = buffer; + while (remaining_to_write) { + ssize_t nwritten = write(dst_fd, bufptr, remaining_to_write); + if (nwritten < 0) { + return false; + } + assert(nwritten > 0); + remaining_to_write -= nwritten; + bufptr += nwritten; + } + } + + auto my_umask = umask(0); + umask(my_umask); + int rc = fchmod(dst_fd, src_stat.st_mode & ~my_umask); + if (rc < 0) { + return false; + } + + return true; +} + +bool link_file(const String& src_path, const String& dst_path) +{ + int duplicate_count = 0; + while (access(get_duplicate_name(dst_path, duplicate_count).characters(), F_OK) == 0) { + ++duplicate_count; + } + if (duplicate_count != 0) { + return link_file(src_path, get_duplicate_name(dst_path, duplicate_count)); + } + int rc = symlink(src_path.characters(), dst_path.characters()); + if (rc < 0) { + return false; + } + + return true; +} + +String get_duplicate_name(const String& path, int duplicate_count) +{ + if (duplicate_count == 0) { + return path; + } + LexicalPath lexical_path(path); + StringBuilder duplicated_name; + duplicated_name.append('/'); + for (size_t i = 0; i < lexical_path.parts().size() - 1; ++i) { + duplicated_name.appendff("{}/", lexical_path.parts()[i]); + } + auto prev_duplicate_tag = String::formatted("({})", duplicate_count); + auto title = lexical_path.title(); + if (title.ends_with(prev_duplicate_tag)) { + // remove the previous duplicate tag "(n)" so we can add a new tag. + title = title.substring(0, title.length() - prev_duplicate_tag.length()); + } + duplicated_name.appendff("{} ({})", lexical_path.title(), duplicate_count); + if (!lexical_path.extension().is_empty()) { + duplicated_name.appendff(".{}", lexical_path.extension()); + } + return duplicated_name.build(); +} +} diff --git a/Userland/Applications/FileManager/FileUtils.h b/Userland/Applications/FileManager/FileUtils.h new file mode 100644 index 0000000000..fcf6b483ac --- /dev/null +++ b/Userland/Applications/FileManager/FileUtils.h @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/String.h> +#include <LibCore/Forward.h> +#include <LibGUI/Forward.h> +#include <sys/stat.h> + +namespace FileUtils { + +enum class FileOperation { + Copy = 0, + Cut +}; + +void delete_path(const String&, GUI::Window*); +void delete_paths(const Vector<String>&, bool should_confirm, GUI::Window*); +int delete_directory(String directory, String& file_that_caused_error); +bool copy_file_or_directory(const String& src_path, const String& dst_path); +String get_duplicate_name(const String& path, int duplicate_count); +bool copy_file(const String& dst_path, const struct stat& src_stat, Core::File&); +bool copy_directory(const String& src_path, const String& dst_path, const struct stat& src_stat); +bool link_file(const String& src_path, const String& dst_path); + +} diff --git a/Userland/Applications/FileManager/PropertiesWindow.cpp b/Userland/Applications/FileManager/PropertiesWindow.cpp new file mode 100644 index 0000000000..8c0ff9f74e --- /dev/null +++ b/Userland/Applications/FileManager/PropertiesWindow.cpp @@ -0,0 +1,304 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "PropertiesWindow.h" +#include <AK/LexicalPath.h> +#include <AK/StringBuilder.h> +#include <LibDesktop/Launcher.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/CheckBox.h> +#include <LibGUI/FileIconProvider.h> +#include <LibGUI/FilePicker.h> +#include <LibGUI/LinkLabel.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/SeparatorWidget.h> +#include <LibGUI/TabWidget.h> +#include <grp.h> +#include <limits.h> +#include <pwd.h> +#include <stdio.h> +#include <string.h> +#include <unistd.h> + +PropertiesWindow::PropertiesWindow(const String& path, bool disable_rename, Window* parent_window) + : Window(parent_window) +{ + auto lexical_path = LexicalPath(path); + ASSERT(lexical_path.is_valid()); + + auto& main_widget = set_main_widget<GUI::Widget>(); + main_widget.set_layout<GUI::VerticalBoxLayout>(); + main_widget.layout()->set_margins({ 4, 4, 4, 4 }); + main_widget.set_fill_with_background_color(true); + + set_rect({ 0, 0, 360, 420 }); + set_resizable(false); + + auto& tab_widget = main_widget.add<GUI::TabWidget>(); + + auto& general_tab = tab_widget.add_tab<GUI::Widget>("General"); + general_tab.set_layout<GUI::VerticalBoxLayout>(); + general_tab.layout()->set_margins({ 12, 8, 12, 8 }); + general_tab.layout()->set_spacing(10); + + auto& file_container = general_tab.add<GUI::Widget>(); + file_container.set_layout<GUI::HorizontalBoxLayout>(); + file_container.layout()->set_spacing(20); + file_container.set_fixed_height(34); + + m_icon = file_container.add<GUI::ImageWidget>(); + m_icon->set_fixed_size(32, 32); + + m_name = lexical_path.basename(); + m_path = lexical_path.string(); + m_parent_path = lexical_path.dirname(); + + m_name_box = file_container.add<GUI::TextBox>(); + m_name_box->set_text(m_name); + m_name_box->set_mode(disable_rename ? GUI::TextBox::Mode::DisplayOnly : GUI::TextBox::Mode::Editable); + m_name_box->on_change = [&]() { + m_name_dirty = m_name != m_name_box->text(); + m_apply_button->set_enabled(m_name_dirty || m_permissions_dirty); + }; + + set_icon(Gfx::Bitmap::load_from_file("/res/icons/16x16/properties.png")); + general_tab.add<GUI::SeparatorWidget>(Gfx::Orientation::Horizontal); + + struct stat st; + if (lstat(path.characters(), &st)) { + perror("stat"); + return; + } + + String owner_name; + String group_name; + + if (auto* pw = getpwuid(st.st_uid)) { + owner_name = pw->pw_name; + } else { + owner_name = "n/a"; + } + + if (auto* gr = getgrgid(st.st_gid)) { + group_name = gr->gr_name; + } else { + group_name = "n/a"; + } + + m_mode = st.st_mode; + m_old_mode = st.st_mode; + + auto properties = Vector<PropertyValuePair>(); + properties.append({ "Type:", get_description(m_mode) }); + auto parent_link = URL::create_with_file_protocol(m_parent_path); + properties.append(PropertyValuePair { "Location:", path, Optional(parent_link) }); + + if (S_ISLNK(m_mode)) { + auto link_destination = Core::File::read_link(path); + if (link_destination.is_null()) { + perror("readlink"); + } else { + auto link_directory = LexicalPath(link_destination); + ASSERT(link_directory.is_valid()); + auto link_parent = URL::create_with_file_protocol(link_directory.dirname()); + properties.append({ "Link target:", link_destination, Optional(link_parent) }); + } + } + + properties.append({ "Size:", String::formatted("{} bytes", st.st_size) }); + properties.append({ "Owner:", String::formatted("{} ({})", owner_name, st.st_uid) }); + properties.append({ "Group:", String::formatted("{} ({})", group_name, st.st_gid) }); + properties.append({ "Created at:", GUI::FileSystemModel::timestamp_string(st.st_ctime) }); + properties.append({ "Last modified:", GUI::FileSystemModel::timestamp_string(st.st_mtime) }); + + make_property_value_pairs(properties, general_tab); + + general_tab.add<GUI::SeparatorWidget>(Gfx::Orientation::Horizontal); + + make_permission_checkboxes(general_tab, { S_IRUSR, S_IWUSR, S_IXUSR }, "Owner:", m_mode); + make_permission_checkboxes(general_tab, { S_IRGRP, S_IWGRP, S_IXGRP }, "Group:", m_mode); + make_permission_checkboxes(general_tab, { S_IROTH, S_IWOTH, S_IXOTH }, "Others:", m_mode); + + general_tab.layout()->add_spacer(); + + auto& button_widget = main_widget.add<GUI::Widget>(); + button_widget.set_layout<GUI::HorizontalBoxLayout>(); + button_widget.set_fixed_height(24); + button_widget.layout()->set_spacing(5); + + button_widget.layout()->add_spacer(); + + make_button("OK", button_widget).on_click = [this](auto) { + if (apply_changes()) + close(); + }; + make_button("Cancel", button_widget).on_click = [this](auto) { + close(); + }; + + m_apply_button = make_button("Apply", button_widget); + m_apply_button->on_click = [this](auto) { apply_changes(); }; + m_apply_button->set_enabled(false); + + update(); +} + +PropertiesWindow::~PropertiesWindow() +{ +} + +void PropertiesWindow::update() +{ + m_icon->set_bitmap(GUI::FileIconProvider::icon_for_path(make_full_path(m_name), m_mode).bitmap_for_size(32)); + set_title(String::formatted("{} - Properties", m_name)); +} + +void PropertiesWindow::permission_changed(mode_t mask, bool set) +{ + if (set) { + m_mode |= mask; + } else { + m_mode &= ~mask; + } + + m_permissions_dirty = m_mode != m_old_mode; + m_apply_button->set_enabled(m_name_dirty || m_permissions_dirty); +} + +String PropertiesWindow::make_full_path(const String& name) +{ + return String::formatted("{}/{}", m_parent_path, name); +} + +bool PropertiesWindow::apply_changes() +{ + if (m_name_dirty) { + String new_name = m_name_box->text(); + String new_file = make_full_path(new_name).characters(); + + if (GUI::FilePicker::file_exists(new_file)) { + GUI::MessageBox::show(this, String::formatted("A file \"{}\" already exists!", new_name), "Error", GUI::MessageBox::Type::Error); + return false; + } + + if (rename(make_full_path(m_name).characters(), new_file.characters())) { + GUI::MessageBox::show(this, String::formatted("Could not rename file: {}!", strerror(errno)), "Error", GUI::MessageBox::Type::Error); + return false; + } + + m_name = new_name; + m_name_dirty = false; + update(); + } + + if (m_permissions_dirty) { + if (chmod(make_full_path(m_name).characters(), m_mode)) { + GUI::MessageBox::show(this, String::formatted("Could not update permissions: {}!", strerror(errno)), "Error", GUI::MessageBox::Type::Error); + return false; + } + + m_old_mode = m_mode; + m_permissions_dirty = false; + } + + update(); + m_apply_button->set_enabled(false); + return true; +} + +void PropertiesWindow::make_permission_checkboxes(GUI::Widget& parent, PermissionMasks masks, String label_string, mode_t mode) +{ + auto& widget = parent.add<GUI::Widget>(); + widget.set_layout<GUI::HorizontalBoxLayout>(); + widget.set_fixed_height(16); + widget.layout()->set_spacing(10); + + auto& label = widget.add<GUI::Label>(label_string); + label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + + struct stat st; + if (lstat(m_path.characters(), &st)) { + perror("stat"); + return; + } + + auto can_edit_checkboxes = st.st_uid == getuid(); + + auto& box_read = widget.add<GUI::CheckBox>("Read"); + box_read.set_checked(mode & masks.read); + box_read.on_checked = [&, masks](bool checked) { permission_changed(masks.read, checked); }; + box_read.set_enabled(can_edit_checkboxes); + + auto& box_write = widget.add<GUI::CheckBox>("Write"); + box_write.set_checked(mode & masks.write); + box_write.on_checked = [&, masks](bool checked) { permission_changed(masks.write, checked); }; + box_write.set_enabled(can_edit_checkboxes); + + auto& box_execute = widget.add<GUI::CheckBox>("Execute"); + box_execute.set_checked(mode & masks.execute); + box_execute.on_checked = [&, masks](bool checked) { permission_changed(masks.execute, checked); }; + box_execute.set_enabled(can_edit_checkboxes); +} + +void PropertiesWindow::make_property_value_pairs(const Vector<PropertyValuePair>& pairs, GUI::Widget& parent) +{ + int max_width = 0; + Vector<NonnullRefPtr<GUI::Label>> property_labels; + + property_labels.ensure_capacity(pairs.size()); + for (auto pair : pairs) { + auto& label_container = parent.add<GUI::Widget>(); + label_container.set_layout<GUI::HorizontalBoxLayout>(); + label_container.set_fixed_height(14); + label_container.layout()->set_spacing(12); + + auto& label_property = label_container.add<GUI::Label>(pair.property); + label_property.set_text_alignment(Gfx::TextAlignment::CenterLeft); + + if (!pair.link.has_value()) { + label_container.add<GUI::Label>(pair.value).set_text_alignment(Gfx::TextAlignment::CenterLeft); + } else { + auto& link = label_container.add<GUI::LinkLabel>(pair.value); + link.set_text_alignment(Gfx::TextAlignment::CenterLeft); + link.on_click = [pair]() { + Desktop::Launcher::open(pair.link.value()); + }; + } + + max_width = max(max_width, label_property.font().width(pair.property)); + property_labels.append(label_property); + } + + for (auto label : property_labels) + label->set_fixed_width(max_width); +} + +GUI::Button& PropertiesWindow::make_button(String text, GUI::Widget& parent) +{ + auto& button = parent.add<GUI::Button>(text); + button.set_fixed_size(70, 22); + return button; +} diff --git a/Userland/Applications/FileManager/PropertiesWindow.h b/Userland/Applications/FileManager/PropertiesWindow.h new file mode 100644 index 0000000000..d2801f8049 --- /dev/null +++ b/Userland/Applications/FileManager/PropertiesWindow.h @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibCore/File.h> +#include <LibGUI/Button.h> +#include <LibGUI/Dialog.h> +#include <LibGUI/FileSystemModel.h> +#include <LibGUI/ImageWidget.h> +#include <LibGUI/Label.h> +#include <LibGUI/TextBox.h> + +class PropertiesWindow final : public GUI::Window { + C_OBJECT(PropertiesWindow); + +public: + virtual ~PropertiesWindow() override; + +private: + PropertiesWindow(const String& path, bool disable_rename, Window* parent = nullptr); + + struct PropertyValuePair { + String property; + String value; + Optional<URL> link = {}; + }; + + struct PermissionMasks { + mode_t read; + mode_t write; + mode_t execute; + }; + + static const String get_description(const mode_t mode) + { + if (S_ISREG(mode)) + return "File"; + if (S_ISDIR(mode)) + return "Directory"; + if (S_ISLNK(mode)) + return "Symbolic link"; + if (S_ISCHR(mode)) + return "Character device"; + if (S_ISBLK(mode)) + return "Block device"; + if (S_ISFIFO(mode)) + return "FIFO (named pipe)"; + if (S_ISSOCK(mode)) + return "Socket"; + if (mode & S_IXUSR) + return "Executable"; + + return "Unknown"; + } + + GUI::Button& make_button(String, GUI::Widget& parent); + void make_property_value_pairs(const Vector<PropertyValuePair>& pairs, GUI::Widget& parent); + void make_permission_checkboxes(GUI::Widget& parent, PermissionMasks, String label_string, mode_t mode); + void permission_changed(mode_t mask, bool set); + bool apply_changes(); + void update(); + String make_full_path(const String& name); + + RefPtr<GUI::Button> m_apply_button; + RefPtr<GUI::TextBox> m_name_box; + RefPtr<GUI::ImageWidget> m_icon; + String m_name; + String m_parent_path; + String m_path; + mode_t m_mode { 0 }; + mode_t m_old_mode { 0 }; + bool m_permissions_dirty { false }; + bool m_name_dirty { false }; +}; diff --git a/Userland/Applications/FileManager/main.cpp b/Userland/Applications/FileManager/main.cpp new file mode 100644 index 0000000000..9766876f21 --- /dev/null +++ b/Userland/Applications/FileManager/main.cpp @@ -0,0 +1,974 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "DesktopWidget.h" +#include "DirectoryView.h" +#include "FileUtils.h" +#include "PropertiesWindow.h" +#include <AK/LexicalPath.h> +#include <AK/StringBuilder.h> +#include <AK/URL.h> +#include <Applications/FileManager/FileManagerWindowGML.h> +#include <LibCore/ConfigFile.h> +#include <LibCore/MimeData.h> +#include <LibCore/StandardPaths.h> +#include <LibDesktop/Launcher.h> +#include <LibGUI/Action.h> +#include <LibGUI/ActionGroup.h> +#include <LibGUI/Application.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/BreadcrumbBar.h> +#include <LibGUI/Clipboard.h> +#include <LibGUI/Desktop.h> +#include <LibGUI/FileIconProvider.h> +#include <LibGUI/FileSystemModel.h> +#include <LibGUI/InputBox.h> +#include <LibGUI/Label.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/Painter.h> +#include <LibGUI/ProgressBar.h> +#include <LibGUI/Splitter.h> +#include <LibGUI/StatusBar.h> +#include <LibGUI/TextEditor.h> +#include <LibGUI/ToolBar.h> +#include <LibGUI/ToolBarContainer.h> +#include <LibGUI/TreeView.h> +#include <LibGUI/Widget.h> +#include <LibGUI/Window.h> +#include <LibGfx/Palette.h> +#include <pthread.h> +#include <serenity.h> +#include <signal.h> +#include <spawn.h> +#include <stdio.h> +#include <string.h> +#include <unistd.h> + +using namespace FileManager; + +static int run_in_desktop_mode(RefPtr<Core::ConfigFile>); +static int run_in_windowed_mode(RefPtr<Core::ConfigFile>, String initial_location); +static void do_copy(const Vector<String>& selected_file_paths, FileUtils::FileOperation file_operation); +static void do_paste(const String& target_directory, GUI::Window* window); +static void do_create_link(const Vector<String>& selected_file_paths, GUI::Window* window); +static void show_properties(const String& container_dir_path, const String& path, const Vector<String>& selected, GUI::Window* window); + +int main(int argc, char** argv) +{ + if (pledge("stdio thread shared_buffer accept unix cpath rpath wpath fattr proc exec sigaction", nullptr) < 0) { + perror("pledge"); + return 1; + } + + struct sigaction act; + memset(&act, 0, sizeof(act)); + act.sa_flags = SA_NOCLDWAIT; + act.sa_handler = SIG_IGN; + int rc = sigaction(SIGCHLD, &act, nullptr); + if (rc < 0) { + perror("sigaction"); + return 1; + } + + RefPtr<Core::ConfigFile> config = Core::ConfigFile::get_for_app("FileManager"); + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio thread shared_buffer accept cpath rpath wpath fattr proc exec unix", nullptr) < 0) { + perror("pledge"); + return 1; + } + + if (app->args().contains_slow("--desktop") || app->args().contains_slow("-d")) + return run_in_desktop_mode(move(config)); + + // our initial location is defined as, in order of precedence: + // 1. the first command-line argument (e.g. FileManager /bin) + // 2. the user's home directory + // 3. the root directory + String initial_location; + + if (argc >= 2) { + char* buffer = realpath(argv[1], nullptr); + initial_location = buffer; + free(buffer); + } + + if (initial_location.is_empty()) + initial_location = Core::StandardPaths::home_directory(); + + if (initial_location.is_empty()) + initial_location = "/"; + + return run_in_windowed_mode(move(config), initial_location); +} + +void do_copy(const Vector<String>& selected_file_paths, FileUtils::FileOperation file_operation) +{ + if (selected_file_paths.is_empty()) + ASSERT_NOT_REACHED(); + + StringBuilder copy_text; + if (file_operation == FileUtils::FileOperation::Cut) { + copy_text.append("#cut\n"); // This exploits the comment lines in the text/uri-list specification, which might be a bit hackish + } + for (auto& path : selected_file_paths) { + auto url = URL::create_with_file_protocol(path); + copy_text.appendff("{}\n", url); + } + GUI::Clipboard::the().set_data(copy_text.build().bytes(), "text/uri-list"); +} + +void do_paste(const String& target_directory, GUI::Window* window) +{ + auto data_and_type = GUI::Clipboard::the().data_and_type(); + if (data_and_type.mime_type != "text/uri-list") { + dbgln("Cannot paste clipboard type {}", data_and_type.mime_type); + return; + } + auto copied_lines = String::copy(data_and_type.data).split('\n'); + if (copied_lines.is_empty()) { + dbgln("No files to paste"); + return; + } + + bool should_delete_src = false; + if (copied_lines[0] == "#cut") { // cut operation encoded as a text/uri-list commen + should_delete_src = true; + copied_lines.remove(0); + } + + for (auto& uri_as_string : copied_lines) { + if (uri_as_string.is_empty()) + continue; + URL url = uri_as_string; + if (!url.is_valid() || url.protocol() != "file") { + dbgln("Cannot paste URI {}", uri_as_string); + continue; + } + + auto new_path = String::formatted("{}/{}", target_directory, url.basename()); + if (!FileUtils::copy_file_or_directory(url.path(), new_path)) { + auto error_message = String::formatted("Could not paste {}.", url.path()); + GUI::MessageBox::show(window, error_message, "File Manager", GUI::MessageBox::Type::Error); + } else if (should_delete_src) { + FileUtils::delete_path(url.path(), window); + } + } +} + +void do_create_link(const Vector<String>& selected_file_paths, GUI::Window* window) +{ + auto path = selected_file_paths.first(); + auto destination = String::formatted("{}/{}", Core::StandardPaths::desktop_directory(), LexicalPath { path }.basename()); + if (!FileUtils::link_file(path, destination)) { + GUI::MessageBox::show(window, "Could not create desktop shortcut", "File Manager", + GUI::MessageBox::Type::Error); + } +} + +void show_properties(const String& container_dir_path, const String& path, const Vector<String>& selected, GUI::Window* window) +{ + RefPtr<PropertiesWindow> properties; + if (selected.is_empty()) { + properties = window->add<PropertiesWindow>(path, true); + } else { + properties = window->add<PropertiesWindow>(selected.first(), access(container_dir_path.characters(), W_OK) != 0); + } + properties->on_close = [properties = properties.ptr()] { + properties->remove_from_parent(); + }; + properties->center_on_screen(); + properties->show(); +} + +int run_in_desktop_mode([[maybe_unused]] RefPtr<Core::ConfigFile> config) +{ + static constexpr const char* process_name = "FileManager (Desktop)"; + set_process_name(process_name, strlen(process_name)); + pthread_setname_np(pthread_self(), process_name); + + auto window = GUI::Window::construct(); + window->set_title("Desktop Manager"); + window->set_window_type(GUI::WindowType::Desktop); + window->set_has_alpha_channel(true); + + auto& desktop_widget = window->set_main_widget<FileManager::DesktopWidget>(); + desktop_widget.set_layout<GUI::VerticalBoxLayout>(); + + [[maybe_unused]] auto& directory_view = desktop_widget.add<DirectoryView>(DirectoryView::Mode::Desktop); + + auto copy_action = GUI::CommonActions::make_copy_action( + [&](auto&) { + auto paths = directory_view.selected_file_paths(); + + if (paths.is_empty()) + ASSERT_NOT_REACHED(); + + do_copy(paths, FileUtils::FileOperation::Copy); + }, + window); + copy_action->set_enabled(false); + + auto cut_action = GUI::CommonActions::make_cut_action( + [&](auto&) { + auto paths = directory_view.selected_file_paths(); + + if (paths.is_empty()) + ASSERT_NOT_REACHED(); + + do_copy(paths, FileUtils::FileOperation::Cut); + }, + window); + cut_action->set_enabled(false); + + directory_view.on_selection_change = [&](const GUI::AbstractView& view) { + copy_action->set_enabled(!view.selection().is_empty()); + cut_action->set_enabled(!view.selection().is_empty()); + }; + + auto properties_action + = GUI::Action::create( + "Properties", { Mod_Alt, Key_Return }, Gfx::Bitmap::load_from_file("/res/icons/16x16/properties.png"), [&](const GUI::Action&) { + String path = directory_view.path(); + Vector<String> selected = directory_view.selected_file_paths(); + + show_properties(path, path, selected, directory_view.window()); + }, + window); + + auto paste_action = GUI::CommonActions::make_paste_action( + [&](const GUI::Action&) { + do_paste(directory_view.path(), directory_view.window()); + }, + window); + paste_action->set_enabled(GUI::Clipboard::the().mime_type() == "text/uri-list" && access(directory_view.path().characters(), W_OK) == 0); + + GUI::Clipboard::the().on_change = [&](const String& data_type) { + paste_action->set_enabled(data_type == "text/uri-list" && access(directory_view.path().characters(), W_OK) == 0); + }; + + auto desktop_view_context_menu = GUI::Menu::construct("Directory View"); + + auto file_manager_action = GUI::Action::create("Show in File Manager", {}, Gfx::Bitmap::load_from_file("/res/icons/16x16/filetype-folder.png"), [&](const GUI::Action&) { + Desktop::Launcher::open(URL::create_with_file_protocol(directory_view.path())); + }); + + auto display_properties_action = GUI::Action::create("Display Settings", {}, Gfx::Bitmap::load_from_file("/res/icons/16x16/app-display-settings.png"), [&](const GUI::Action&) { + Desktop::Launcher::open(URL::create_with_file_protocol("/bin/DisplaySettings")); + }); + + desktop_view_context_menu->add_action(directory_view.mkdir_action()); + desktop_view_context_menu->add_action(directory_view.touch_action()); + desktop_view_context_menu->add_action(paste_action); + desktop_view_context_menu->add_separator(); + desktop_view_context_menu->add_action(file_manager_action); + desktop_view_context_menu->add_action(directory_view.open_terminal_action()); + desktop_view_context_menu->add_separator(); + desktop_view_context_menu->add_action(display_properties_action); + + auto desktop_context_menu = GUI::Menu::construct("Directory View Directory"); + desktop_context_menu->add_action(copy_action); + desktop_context_menu->add_action(cut_action); + desktop_context_menu->add_action(paste_action); + desktop_context_menu->add_action(directory_view.delete_action()); + desktop_context_menu->add_separator(); + desktop_context_menu->add_action(properties_action); + + directory_view.on_context_menu_request = [&](const GUI::ModelIndex& index, const GUI::ContextMenuEvent& event) { + if (!index.is_valid()) + desktop_view_context_menu->popup(event.screen_position()); + else + desktop_context_menu->popup(event.screen_position()); + }; + + auto wm_config = Core::ConfigFile::get_for_app("WindowManager"); + auto selected_wallpaper = wm_config->read_entry("Background", "Wallpaper", ""); + if (!selected_wallpaper.is_empty()) { + GUI::Desktop::the().set_wallpaper(selected_wallpaper, false); + } + + window->show(); + return GUI::Application::the()->exec(); +} + +int run_in_windowed_mode(RefPtr<Core::ConfigFile> config, String initial_location) +{ + auto window = GUI::Window::construct(); + window->set_title("File Manager"); + + auto left = config->read_num_entry("Window", "Left", 150); + auto top = config->read_num_entry("Window", "Top", 75); + auto width = config->read_num_entry("Window", "Width", 640); + auto height = config->read_num_entry("Window", "Height", 480); + window->set_rect({ left, top, width, height }); + + auto& widget = window->set_main_widget<GUI::Widget>(); + + widget.load_from_gml(file_manager_window_gml); + + auto& main_toolbar = *widget.find_descendant_of_type_named<GUI::ToolBar>("main_toolbar"); + auto& location_toolbar = *widget.find_descendant_of_type_named<GUI::ToolBar>("location_toolbar"); + location_toolbar.layout()->set_margins({ 6, 3, 6, 3 }); + + auto& location_textbox = *widget.find_descendant_of_type_named<GUI::TextBox>("location_textbox"); + + auto& breadcrumb_toolbar = *widget.find_descendant_of_type_named<GUI::ToolBar>("breadcrumb_toolbar"); + breadcrumb_toolbar.layout()->set_margins({}); + auto& breadcrumb_bar = *widget.find_descendant_of_type_named<GUI::BreadcrumbBar>("breadcrumb_bar"); + + location_textbox.on_focusout = [&] { + location_toolbar.set_visible(false); + breadcrumb_toolbar.set_visible(true); + }; + + auto& splitter = *widget.find_descendant_of_type_named<GUI::HorizontalSplitter>("splitter"); + auto& tree_view = *widget.find_descendant_of_type_named<GUI::TreeView>("tree_view"); + + auto directories_model = GUI::FileSystemModel::create({}, GUI::FileSystemModel::Mode::DirectoriesOnly); + tree_view.set_model(directories_model); + tree_view.set_column_hidden(GUI::FileSystemModel::Column::Icon, true); + tree_view.set_column_hidden(GUI::FileSystemModel::Column::Size, true); + tree_view.set_column_hidden(GUI::FileSystemModel::Column::Owner, true); + tree_view.set_column_hidden(GUI::FileSystemModel::Column::Group, true); + tree_view.set_column_hidden(GUI::FileSystemModel::Column::Permissions, true); + tree_view.set_column_hidden(GUI::FileSystemModel::Column::ModificationTime, true); + tree_view.set_column_hidden(GUI::FileSystemModel::Column::Inode, true); + tree_view.set_column_hidden(GUI::FileSystemModel::Column::SymlinkTarget, true); + bool is_reacting_to_tree_view_selection_change = false; + + auto& directory_view = splitter.add<DirectoryView>(DirectoryView::Mode::Normal); + + location_textbox.on_escape_pressed = [&] { + directory_view.set_focus(true); + }; + + // Open the root directory. FIXME: This is awkward. + tree_view.toggle_index(directories_model->index(0, 0, {})); + + auto& statusbar = *widget.find_descendant_of_type_named<GUI::StatusBar>("statusbar"); + + auto& progressbar = *widget.find_descendant_of_type_named<GUI::ProgressBar>("progressbar"); + progressbar.set_format(GUI::ProgressBar::Format::ValueSlashMax); + progressbar.set_frame_shape(Gfx::FrameShape::Panel); + progressbar.set_frame_shadow(Gfx::FrameShadow::Sunken); + progressbar.set_frame_thickness(1); + + location_textbox.on_return_pressed = [&] { + directory_view.open(location_textbox.text()); + }; + + auto refresh_tree_view = [&] { + directories_model->update(); + + auto current_path = directory_view.path(); + + struct stat st; + // If the directory no longer exists, we find a parent that does. + while (stat(current_path.characters(), &st) != 0) { + directory_view.open_parent_directory(); + current_path = directory_view.path(); + if (current_path == directories_model->root_path()) { + break; + } + } + + // Reselect the existing folder in the tree. + auto new_index = directories_model->index(current_path, GUI::FileSystemModel::Column::Name); + if (new_index.is_valid()) { + tree_view.expand_all_parents_of(new_index); + tree_view.set_cursor(new_index, GUI::AbstractView::SelectionUpdate::Set, true); + } + + directory_view.refresh(); + }; + + auto directory_context_menu = GUI::Menu::construct("Directory View Directory"); + auto directory_view_context_menu = GUI::Menu::construct("Directory View"); + auto tree_view_directory_context_menu = GUI::Menu::construct("Tree View Directory"); + auto tree_view_context_menu = GUI::Menu::construct("Tree View"); + + auto open_parent_directory_action = GUI::Action::create("Open parent directory", { Mod_Alt, Key_Up }, Gfx::Bitmap::load_from_file("/res/icons/16x16/open-parent-directory.png"), [&](const GUI::Action&) { + directory_view.open_parent_directory(); + }); + + RefPtr<GUI::Action> view_as_table_action; + RefPtr<GUI::Action> view_as_icons_action; + RefPtr<GUI::Action> view_as_columns_action; + + view_as_icons_action = GUI::Action::create_checkable( + "Icon view", { Mod_Ctrl, KeyCode::Key_1 }, Gfx::Bitmap::load_from_file("/res/icons/16x16/icon-view.png"), [&](const GUI::Action&) { + directory_view.set_view_mode(DirectoryView::ViewMode::Icon); + config->write_entry("DirectoryView", "ViewMode", "Icon"); + config->sync(); + }, + window); + + view_as_table_action = GUI::Action::create_checkable( + "Table view", { Mod_Ctrl, KeyCode::Key_2 }, Gfx::Bitmap::load_from_file("/res/icons/16x16/table-view.png"), [&](const GUI::Action&) { + directory_view.set_view_mode(DirectoryView::ViewMode::Table); + config->write_entry("DirectoryView", "ViewMode", "Table"); + config->sync(); + }, + window); + + view_as_columns_action = GUI::Action::create_checkable( + "Columns view", { Mod_Ctrl, KeyCode::Key_3 }, Gfx::Bitmap::load_from_file("/res/icons/16x16/columns-view.png"), [&](const GUI::Action&) { + directory_view.set_view_mode(DirectoryView::ViewMode::Columns); + config->write_entry("DirectoryView", "ViewMode", "Columns"); + config->sync(); + }, + window); + + auto view_type_action_group = make<GUI::ActionGroup>(); + view_type_action_group->set_exclusive(true); + view_type_action_group->add_action(*view_as_icons_action); + view_type_action_group->add_action(*view_as_table_action); + view_type_action_group->add_action(*view_as_columns_action); + + auto tree_view_selected_file_paths = [&] { + Vector<String> paths; + auto& view = tree_view; + view.selection().for_each_index([&](const GUI::ModelIndex& index) { + paths.append(directories_model->full_path(index)); + }); + return paths; + }; + + auto select_all_action = GUI::Action::create("Select all", { Mod_Ctrl, KeyCode::Key_A }, [&](const GUI::Action&) { + directory_view.current_view().select_all(); + }); + + auto copy_action = GUI::CommonActions::make_copy_action( + [&](auto&) { + auto paths = directory_view.selected_file_paths(); + + if (paths.is_empty()) + paths = tree_view_selected_file_paths(); + + if (paths.is_empty()) + ASSERT_NOT_REACHED(); + + do_copy(paths, FileUtils::FileOperation::Copy); + refresh_tree_view(); + }, + window); + copy_action->set_enabled(false); + + auto cut_action = GUI::CommonActions::make_cut_action( + [&](auto&) { + auto paths = directory_view.selected_file_paths(); + + if (paths.is_empty()) + paths = tree_view_selected_file_paths(); + + if (paths.is_empty()) + ASSERT_NOT_REACHED(); + + do_copy(paths, FileUtils::FileOperation::Cut); + refresh_tree_view(); + }, + window); + cut_action->set_enabled(false); + + auto shortcut_action + = GUI::Action::create( + "Create desktop shortcut", + {}, + Gfx::Bitmap::load_from_file("/res/icons/16x16/filetype-symlink.png"), + [&](const GUI::Action&) { + auto paths = directory_view.selected_file_paths(); + if (paths.is_empty()) { + return; + } + do_create_link(paths, directory_view.window()); + }, + window); + + auto properties_action + = GUI::Action::create( + "Properties", { Mod_Alt, Key_Return }, Gfx::Bitmap::load_from_file("/res/icons/16x16/properties.png"), [&](const GUI::Action& action) { + String container_dir_path; + String path; + Vector<String> selected; + if (action.activator() == directory_context_menu || directory_view.active_widget()->is_focused()) { + path = directory_view.path(); + container_dir_path = path; + selected = directory_view.selected_file_paths(); + } else { + path = directories_model->full_path(tree_view.selection().first()); + container_dir_path = LexicalPath(path).basename(); + selected = tree_view_selected_file_paths(); + } + + show_properties(container_dir_path, path, selected, directory_view.window()); + }, + window); + + auto paste_action = GUI::CommonActions::make_paste_action( + [&](const GUI::Action& action) { + String target_directory; + if (action.activator() == directory_context_menu) + target_directory = directory_view.selected_file_paths()[0]; + else + target_directory = directory_view.path(); + do_paste(target_directory, directory_view.window()); + refresh_tree_view(); + }, + window); + + auto folder_specific_paste_action = GUI::CommonActions::make_paste_action( + [&](const GUI::Action& action) { + String target_directory; + if (action.activator() == directory_context_menu) + target_directory = directory_view.selected_file_paths()[0]; + else + target_directory = directory_view.path(); + do_paste(target_directory, directory_view.window()); + refresh_tree_view(); + }, + window); + + auto go_back_action = GUI::CommonActions::make_go_back_action( + [&](auto&) { + directory_view.open_previous_directory(); + }, + window); + + auto go_forward_action = GUI::CommonActions::make_go_forward_action( + [&](auto&) { + directory_view.open_next_directory(); + }, + window); + + auto go_home_action = GUI::CommonActions::make_go_home_action( + [&](auto&) { + directory_view.open(Core::StandardPaths::home_directory()); + }, + window); + + GUI::Clipboard::the().on_change = [&](const String& data_type) { + auto current_location = directory_view.path(); + paste_action->set_enabled(data_type == "text/uri-list" && access(current_location.characters(), W_OK) == 0); + }; + + auto tree_view_delete_action = GUI::CommonActions::make_delete_action( + [&](auto&) { + FileUtils::delete_paths(tree_view_selected_file_paths(), true, window); + refresh_tree_view(); + }, + &tree_view); + + // This is a little awkward. The menu action does something different depending on which view has focus. + // It would be nice to find a good abstraction for this instead of creating a branching action like this. + auto focus_dependent_delete_action = GUI::CommonActions::make_delete_action([&](auto&) { + if (tree_view.is_focused()) + tree_view_delete_action->activate(); + else + directory_view.delete_action().activate(); + refresh_tree_view(); + }); + focus_dependent_delete_action->set_enabled(false); + + auto mkdir_action = GUI::Action::create("New directory...", { Mod_Ctrl | Mod_Shift, Key_N }, Gfx::Bitmap::load_from_file("/res/icons/16x16/mkdir.png"), [&](const GUI::Action&) { + directory_view.mkdir_action().activate(); + refresh_tree_view(); + }); + + auto touch_action = GUI::Action::create("New file...", { Mod_Ctrl | Mod_Shift, Key_F }, Gfx::Bitmap::load_from_file("/res/icons/16x16/new.png"), [&](const GUI::Action&) { + directory_view.touch_action().activate(); + refresh_tree_view(); + }); + + auto menubar = GUI::MenuBar::construct(); + + auto& app_menu = menubar->add_menu("File Manager"); + app_menu.add_action(mkdir_action); + app_menu.add_action(touch_action); + app_menu.add_action(copy_action); + app_menu.add_action(cut_action); + app_menu.add_action(paste_action); + app_menu.add_action(focus_dependent_delete_action); + app_menu.add_action(directory_view.open_terminal_action()); + app_menu.add_separator(); + app_menu.add_action(properties_action); + app_menu.add_separator(); + app_menu.add_action(GUI::CommonActions::make_quit_action([](auto&) { + GUI::Application::the()->quit(); + })); + + auto action_show_dotfiles = GUI::Action::create_checkable("Show dotfiles", { Mod_Ctrl, Key_H }, [&](auto& action) { + directory_view.set_should_show_dotfiles(action.is_checked()); + refresh_tree_view(); + }); + + auto& view_menu = menubar->add_menu("View"); + view_menu.add_action(*view_as_icons_action); + view_menu.add_action(*view_as_table_action); + view_menu.add_action(*view_as_columns_action); + view_menu.add_separator(); + view_menu.add_action(action_show_dotfiles); + + auto go_to_location_action = GUI::Action::create("Go to location...", { Mod_Ctrl, Key_L }, [&](auto&) { + location_toolbar.set_visible(true); + breadcrumb_toolbar.set_visible(false); + location_textbox.select_all(); + location_textbox.set_focus(true); + }); + + auto& go_menu = menubar->add_menu("Go"); + go_menu.add_action(go_back_action); + go_menu.add_action(go_forward_action); + go_menu.add_action(open_parent_directory_action); + go_menu.add_action(go_home_action); + go_menu.add_action(go_to_location_action); + + auto& help_menu = menubar->add_menu("Help"); + help_menu.add_action(GUI::CommonActions::make_about_action("File Manager", GUI::Icon::default_icon("filetype-folder"))); + + GUI::Application::the()->set_menubar(move(menubar)); + + main_toolbar.add_action(go_back_action); + main_toolbar.add_action(go_forward_action); + main_toolbar.add_action(open_parent_directory_action); + main_toolbar.add_action(go_home_action); + + main_toolbar.add_separator(); + main_toolbar.add_action(mkdir_action); + main_toolbar.add_action(touch_action); + main_toolbar.add_action(copy_action); + main_toolbar.add_action(cut_action); + main_toolbar.add_action(paste_action); + main_toolbar.add_action(focus_dependent_delete_action); + main_toolbar.add_action(directory_view.open_terminal_action()); + + main_toolbar.add_separator(); + main_toolbar.add_action(*view_as_icons_action); + main_toolbar.add_action(*view_as_table_action); + main_toolbar.add_action(*view_as_columns_action); + + directory_view.on_path_change = [&](const String& new_path, bool can_write_in_path) { + auto icon = GUI::FileIconProvider::icon_for_path(new_path); + auto* bitmap = icon.bitmap_for_size(16); + window->set_icon(bitmap); + location_textbox.set_icon(bitmap); + + window->set_title(String::formatted("{} - File Manager", new_path)); + location_textbox.set_text(new_path); + + { + LexicalPath lexical_path(new_path); + + auto segment_index_of_new_path_in_breadcrumb_bar = [&]() -> Optional<size_t> { + for (size_t i = 0; i < breadcrumb_bar.segment_count(); ++i) { + if (breadcrumb_bar.segment_data(i) == new_path) + return i; + } + return {}; + }(); + + if (segment_index_of_new_path_in_breadcrumb_bar.has_value()) { + breadcrumb_bar.set_selected_segment(segment_index_of_new_path_in_breadcrumb_bar.value()); + } else { + breadcrumb_bar.clear_segments(); + + breadcrumb_bar.append_segment("/", GUI::FileIconProvider::icon_for_path("/").bitmap_for_size(16), "/"); + StringBuilder builder; + + for (auto& part : lexical_path.parts()) { + // NOTE: We rebuild the path as we go, so we have something to pass to GUI::FileIconProvider. + builder.append('/'); + builder.append(part); + + breadcrumb_bar.append_segment(part, GUI::FileIconProvider::icon_for_path(builder.string_view()).bitmap_for_size(16), builder.string_view()); + } + + breadcrumb_bar.set_selected_segment(breadcrumb_bar.segment_count() - 1); + + breadcrumb_bar.on_segment_click = [&](size_t segment_index) { + directory_view.open(breadcrumb_bar.segment_data(segment_index)); + }; + } + } + + if (!is_reacting_to_tree_view_selection_change) { + auto new_index = directories_model->index(new_path, GUI::FileSystemModel::Column::Name); + if (new_index.is_valid()) { + tree_view.expand_all_parents_of(new_index); + tree_view.set_cursor(new_index, GUI::AbstractView::SelectionUpdate::Set); + } + } + + struct stat st; + if (lstat(new_path.characters(), &st)) { + perror("stat"); + return; + } + + paste_action->set_enabled(can_write_in_path && GUI::Clipboard::the().mime_type() == "text/uri-list"); + go_forward_action->set_enabled(directory_view.path_history_position() < directory_view.path_history_size() - 1); + go_back_action->set_enabled(directory_view.path_history_position() > 0); + open_parent_directory_action->set_enabled(new_path != "/"); + }; + + directory_view.on_accepted_drop = [&]() { + refresh_tree_view(); + }; + + directory_view.on_status_message = [&](const StringView& message) { + statusbar.set_text(message); + }; + + directory_view.on_thumbnail_progress = [&](int done, int total) { + if (done == total) { + progressbar.set_visible(false); + return; + } + progressbar.set_range(0, total); + progressbar.set_value(done); + progressbar.set_visible(true); + }; + + directory_view.on_selection_change = [&](GUI::AbstractView& view) { + auto& selection = view.selection(); + copy_action->set_enabled(!selection.is_empty()); + cut_action->set_enabled(!selection.is_empty()); + focus_dependent_delete_action->set_enabled((!tree_view.selection().is_empty() && tree_view.is_focused()) + || !directory_view.current_view().selection().is_empty()); + }; + + directory_context_menu->add_action(copy_action); + directory_context_menu->add_action(cut_action); + directory_context_menu->add_action(folder_specific_paste_action); + directory_context_menu->add_action(directory_view.delete_action()); + directory_context_menu->add_action(shortcut_action); + directory_context_menu->add_separator(); + directory_context_menu->add_action(properties_action); + + directory_view_context_menu->add_action(mkdir_action); + directory_view_context_menu->add_action(touch_action); + directory_view_context_menu->add_action(paste_action); + directory_view_context_menu->add_action(directory_view.open_terminal_action()); + directory_view_context_menu->add_separator(); + directory_view_context_menu->add_action(action_show_dotfiles); + directory_view_context_menu->add_separator(); + directory_view_context_menu->add_action(properties_action); + + tree_view_directory_context_menu->add_action(copy_action); + tree_view_directory_context_menu->add_action(cut_action); + tree_view_directory_context_menu->add_action(paste_action); + tree_view_directory_context_menu->add_action(tree_view_delete_action); + tree_view_directory_context_menu->add_separator(); + tree_view_directory_context_menu->add_action(properties_action); + tree_view_directory_context_menu->add_separator(); + tree_view_directory_context_menu->add_action(mkdir_action); + tree_view_directory_context_menu->add_action(touch_action); + + RefPtr<GUI::Menu> file_context_menu; + NonnullRefPtrVector<LauncherHandler> current_file_handlers; + RefPtr<GUI::Action> file_context_menu_action_default_action; + + directory_view.on_context_menu_request = [&](const GUI::ModelIndex& index, const GUI::ContextMenuEvent& event) { + if (index.is_valid()) { + auto& node = directory_view.node(index); + + if (node.is_directory()) { + auto should_get_enabled = access(node.full_path().characters(), W_OK) == 0 && GUI::Clipboard::the().mime_type() == "text/uri-list"; + folder_specific_paste_action->set_enabled(should_get_enabled); + directory_context_menu->popup(event.screen_position()); + } else { + auto full_path = node.full_path(); + current_file_handlers = directory_view.get_launch_handlers(full_path); + + file_context_menu = GUI::Menu::construct("Directory View File"); + file_context_menu->add_action(copy_action); + file_context_menu->add_action(cut_action); + file_context_menu->add_action(paste_action); + file_context_menu->add_action(directory_view.delete_action()); + file_context_menu->add_action(shortcut_action); + + file_context_menu->add_separator(); + bool added_open_menu_items = false; + auto default_file_handler = directory_view.get_default_launch_handler(current_file_handlers); + if (default_file_handler) { + auto file_open_action = default_file_handler->create_launch_action([&, full_path = move(full_path)](auto& launcher_handler) { + directory_view.launch(URL::create_with_file_protocol(full_path), launcher_handler); + }); + if (default_file_handler->details().launcher_type == Desktop::Launcher::LauncherType::Application) + file_open_action->set_text(String::formatted("Run {}", file_open_action->text())); + else + file_open_action->set_text(String::formatted("Open in {}", file_open_action->text())); + + file_context_menu_action_default_action = file_open_action; + + file_context_menu->add_action(move(file_open_action)); + added_open_menu_items = true; + } else { + file_context_menu_action_default_action.clear(); + } + + if (current_file_handlers.size() > 1) { + added_open_menu_items = true; + auto& file_open_with_menu = file_context_menu->add_submenu("Open with"); + for (auto& handler : current_file_handlers) { + if (&handler == default_file_handler.ptr()) + continue; + file_open_with_menu.add_action(handler.create_launch_action([&, full_path = move(full_path)](auto& launcher_handler) { + directory_view.launch(URL::create_with_file_protocol(full_path), launcher_handler); + })); + } + } + + if (added_open_menu_items) + file_context_menu->add_separator(); + + file_context_menu->add_action(properties_action); + file_context_menu->popup(event.screen_position(), file_context_menu_action_default_action); + } + } else { + directory_view_context_menu->popup(event.screen_position()); + } + }; + + tree_view.on_selection = [&](const GUI::ModelIndex& index) { + if (directories_model->m_previously_selected_index.is_valid()) + directories_model->update_node_on_selection(directories_model->m_previously_selected_index, false); + + directories_model->update_node_on_selection(index, true); + directories_model->m_previously_selected_index = index; + }; + + tree_view.on_selection_change = [&] { + focus_dependent_delete_action->set_enabled((!tree_view.selection().is_empty() && tree_view.is_focused()) + || !directory_view.current_view().selection().is_empty()); + + if (tree_view.selection().is_empty()) + return; + auto path = directories_model->full_path(tree_view.selection().first()); + if (directory_view.path() == path) + return; + TemporaryChange change(is_reacting_to_tree_view_selection_change, true); + directory_view.open(path); + copy_action->set_enabled(!tree_view.selection().is_empty()); + cut_action->set_enabled(!tree_view.selection().is_empty()); + directory_view.delete_action().set_enabled(!tree_view.selection().is_empty()); + }; + + tree_view.on_focus_change = [&]([[maybe_unused]] const bool has_focus, [[maybe_unused]] const GUI::FocusSource source) { + focus_dependent_delete_action->set_enabled((!tree_view.selection().is_empty() && has_focus) + || !directory_view.current_view().selection().is_empty()); + }; + + tree_view.on_context_menu_request = [&](const GUI::ModelIndex& index, const GUI::ContextMenuEvent& event) { + if (index.is_valid()) { + tree_view_directory_context_menu->popup(event.screen_position()); + } + }; + + auto copy_urls_to_directory = [&](const Vector<URL>& urls, const String& directory) { + if (urls.is_empty()) { + dbgln("No files to copy"); + return; + } + bool had_accepted_copy = false; + for (auto& url_to_copy : urls) { + if (!url_to_copy.is_valid() || url_to_copy.path() == directory) + continue; + auto new_path = String::formatted("{}/{}", directory, LexicalPath(url_to_copy.path()).basename()); + if (url_to_copy.path() == new_path) + continue; + + if (!FileUtils::copy_file_or_directory(url_to_copy.path(), new_path)) { + auto error_message = String::formatted("Could not copy {} into {}.", url_to_copy.to_string(), new_path); + GUI::MessageBox::show(window, error_message, "File Manager", GUI::MessageBox::Type::Error); + } else { + had_accepted_copy = true; + } + } + if (had_accepted_copy) + refresh_tree_view(); + }; + + breadcrumb_bar.on_segment_drop = [&](size_t segment_index, const GUI::DropEvent& event) { + if (!event.mime_data().has_urls()) + return; + copy_urls_to_directory(event.mime_data().urls(), breadcrumb_bar.segment_data(segment_index)); + }; + + breadcrumb_bar.on_segment_drag_enter = [&](size_t, GUI::DragEvent& event) { + if (event.mime_types().contains_slow("text/uri-list")) + event.accept(); + }; + + breadcrumb_bar.on_doubleclick = [&](const GUI::MouseEvent&) { + go_to_location_action->activate(); + }; + + tree_view.on_drop = [&](const GUI::ModelIndex& index, const GUI::DropEvent& event) { + if (!event.mime_data().has_urls()) + return; + auto& target_node = directories_model->node(index); + if (!target_node.is_directory()) + return; + copy_urls_to_directory(event.mime_data().urls(), target_node.full_path()); + }; + + directory_view.open(initial_location); + directory_view.set_focus(true); + + paste_action->set_enabled(GUI::Clipboard::the().mime_type() == "text/uri-list" && access(initial_location.characters(), W_OK) == 0); + + window->show(); + + // Read directory read mode from config. + auto dir_view_mode = config->read_entry("DirectoryView", "ViewMode", "Icon"); + + if (dir_view_mode.contains("Table")) { + directory_view.set_view_mode(DirectoryView::ViewMode::Table); + view_as_table_action->set_checked(true); + } else if (dir_view_mode.contains("Columns")) { + directory_view.set_view_mode(DirectoryView::ViewMode::Columns); + view_as_columns_action->set_checked(true); + } else { + directory_view.set_view_mode(DirectoryView::ViewMode::Icon); + view_as_icons_action->set_checked(true); + } + + // Write window position to config file on close request. + window->on_close_request = [&] { + config->write_num_entry("Window", "Left", window->x()); + config->write_num_entry("Window", "Top", window->y()); + config->write_num_entry("Window", "Width", window->width()); + config->write_num_entry("Window", "Height", window->height()); + config->sync(); + + return GUI::Window::CloseRequestDecision::Close; + }; + + return GUI::Application::the()->exec(); +} diff --git a/Userland/Applications/FontEditor/.gitignore b/Userland/Applications/FontEditor/.gitignore new file mode 100644 index 0000000000..0e7af5b54f --- /dev/null +++ b/Userland/Applications/FontEditor/.gitignore @@ -0,0 +1 @@ +UI_*.h diff --git a/Userland/Applications/FontEditor/CMakeLists.txt b/Userland/Applications/FontEditor/CMakeLists.txt new file mode 100644 index 0000000000..569ab9a9bd --- /dev/null +++ b/Userland/Applications/FontEditor/CMakeLists.txt @@ -0,0 +1,11 @@ +include_directories(${CMAKE_CURRENT_BINARY_DIR}) + +set(SOURCES + FontEditor.cpp + GlyphEditorWidget.cpp + GlyphMapWidget.cpp + main.cpp +) + +serenity_app(FontEditor ICON app-font-editor) +target_link_libraries(FontEditor LibGUI LibDesktop LibGfx) diff --git a/Userland/Applications/FontEditor/FontEditor.cpp b/Userland/Applications/FontEditor/FontEditor.cpp new file mode 100644 index 0000000000..56cbf3e06b --- /dev/null +++ b/Userland/Applications/FontEditor/FontEditor.cpp @@ -0,0 +1,372 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "FontEditor.h" +#include "GlyphEditorWidget.h" +#include "GlyphMapWidget.h" +#include <AK/StringBuilder.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/CheckBox.h> +#include <LibGUI/GroupBox.h> +#include <LibGUI/Label.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/Painter.h> +#include <LibGUI/SpinBox.h> +#include <LibGUI/TextBox.h> +#include <LibGUI/Window.h> +#include <LibGfx/BitmapFont.h> +#include <LibGfx/Palette.h> +#include <stdlib.h> + +FontEditorWidget::FontEditorWidget(const String& path, RefPtr<Gfx::BitmapFont>&& edited_font) + : m_edited_font(move(edited_font)) + , m_path(path) +{ + set_fill_with_background_color(true); + set_layout<GUI::VerticalBoxLayout>(); + + // Top + auto& main_container = add<GUI::Widget>(); + main_container.set_layout<GUI::HorizontalBoxLayout>(); + main_container.layout()->set_margins({ 4, 4, 4, 4 }); + main_container.set_background_role(Gfx::ColorRole::SyntaxKeyword); + + // Top-Left Glyph Editor and info + auto& editor_container = main_container.add<GUI::Widget>(); + editor_container.set_layout<GUI::VerticalBoxLayout>(); + editor_container.layout()->set_margins({ 4, 4, 4, 4 }); + editor_container.set_background_role(Gfx::ColorRole::SyntaxKeyword); + + m_glyph_editor_widget = editor_container.add<GlyphEditorWidget>(*m_edited_font); + m_glyph_editor_widget->set_fixed_size(m_glyph_editor_widget->preferred_width(), m_glyph_editor_widget->preferred_height()); + + editor_container.set_fixed_width(m_glyph_editor_widget->preferred_width()); + + auto& glyph_width_label = editor_container.add<GUI::Label>(); + glyph_width_label.set_fixed_height(22); + glyph_width_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + glyph_width_label.set_text("Glyph width:"); + + auto& glyph_width_spinbox = editor_container.add<GUI::SpinBox>(); + glyph_width_spinbox.set_min(0); + glyph_width_spinbox.set_max(32); + glyph_width_spinbox.set_value(0); + glyph_width_spinbox.set_enabled(!m_edited_font->is_fixed_width()); + + auto& info_label = editor_container.add<GUI::Label>(); + info_label.set_fixed_height(22); + info_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + info_label.set_text("info_label"); + + /// Top-Right glyph map and font meta data + + auto& map_and_test_container = main_container.add<GUI::Widget>(); + map_and_test_container.set_layout<GUI::VerticalBoxLayout>(); + map_and_test_container.layout()->set_margins({ 4, 4, 4, 4 }); + + m_glyph_map_widget = map_and_test_container.add<GlyphMapWidget>(*m_edited_font); + m_glyph_map_widget->set_fixed_size(m_glyph_map_widget->preferred_width(), m_glyph_map_widget->preferred_height()); + + auto& font_mtest_group_box = map_and_test_container.add<GUI::GroupBox>(); + font_mtest_group_box.set_layout<GUI::VerticalBoxLayout>(); + font_mtest_group_box.layout()->set_margins({ 5, 15, 5, 5 }); + font_mtest_group_box.set_fixed_height(2 * m_edited_font->glyph_height() + 50); + font_mtest_group_box.set_title("Test"); + + auto& demo_label_1 = font_mtest_group_box.add<GUI::Label>(); + demo_label_1.set_font(m_edited_font); + demo_label_1.set_text("quick fox jumps nightly above wizard."); + + auto& demo_label_2 = font_mtest_group_box.add<GUI::Label>(); + demo_label_2.set_font(m_edited_font); + demo_label_2.set_text("QUICK FOX JUMPS NIGHTLY ABOVE WIZARD!"); + + auto& font_metadata_group_box = map_and_test_container.add<GUI::GroupBox>(); + font_metadata_group_box.set_layout<GUI::VerticalBoxLayout>(); + font_metadata_group_box.layout()->set_margins({ 5, 15, 5, 5 }); + font_metadata_group_box.set_fixed_height(275); + font_metadata_group_box.set_title("Font metadata"); + + //// Name Row + auto& namecontainer = font_metadata_group_box.add<GUI::Widget>(); + namecontainer.set_layout<GUI::HorizontalBoxLayout>(); + namecontainer.set_fixed_height(22); + + auto& name_label = namecontainer.add<GUI::Label>(); + name_label.set_fixed_width(100); + name_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + name_label.set_text("Name:"); + + auto& name_textbox = namecontainer.add<GUI::TextBox>(); + name_textbox.set_text(m_edited_font->name()); + name_textbox.on_change = [&] { + m_edited_font->set_name(name_textbox.text()); + }; + + //// Family Row + auto& family_container = font_metadata_group_box.add<GUI::Widget>(); + family_container.set_layout<GUI::HorizontalBoxLayout>(); + family_container.set_fixed_height(22); + + auto& family_label = family_container.add<GUI::Label>(); + family_label.set_fixed_width(100); + family_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + family_label.set_text("Family:"); + + auto& family_textbox = family_container.add<GUI::TextBox>(); + family_textbox.set_text(m_edited_font->family()); + family_textbox.on_change = [&] { + m_edited_font->set_family(family_textbox.text()); + }; + + //// Presentation size Row + auto& presentation_size_container = font_metadata_group_box.add<GUI::Widget>(); + presentation_size_container.set_layout<GUI::HorizontalBoxLayout>(); + presentation_size_container.set_fixed_height(22); + + auto& presentation_size_label = presentation_size_container.add<GUI::Label>(); + presentation_size_label.set_fixed_width(100); + presentation_size_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + presentation_size_label.set_text("Presentation size:"); + + auto& presentation_size_spinbox = presentation_size_container.add<GUI::SpinBox>(); + presentation_size_spinbox.set_min(0); + presentation_size_spinbox.set_max(255); + presentation_size_spinbox.set_value(m_edited_font->presentation_size()); + + //// Weight Row + auto& weight_container = font_metadata_group_box.add<GUI::Widget>(); + weight_container.set_layout<GUI::HorizontalBoxLayout>(); + weight_container.set_fixed_height(22); + + auto& weight_label = weight_container.add<GUI::Label>(); + weight_label.set_fixed_width(100); + weight_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + weight_label.set_text("Weight:"); + + auto& weight_spinbox = weight_container.add<GUI::SpinBox>(); + weight_spinbox.set_min(0); + weight_spinbox.set_max(65535); + weight_spinbox.set_value(m_edited_font->weight()); + + //// Glyph spacing Row + auto& glyph_spacing_container = font_metadata_group_box.add<GUI::Widget>(); + glyph_spacing_container.set_layout<GUI::HorizontalBoxLayout>(); + glyph_spacing_container.set_fixed_height(22); + + auto& glyph_spacing = glyph_spacing_container.add<GUI::Label>(); + glyph_spacing.set_fixed_width(100); + glyph_spacing.set_text_alignment(Gfx::TextAlignment::CenterLeft); + glyph_spacing.set_text("Glyph spacing:"); + + auto& spacing_spinbox = glyph_spacing_container.add<GUI::SpinBox>(); + spacing_spinbox.set_min(0); + spacing_spinbox.set_max(255); + spacing_spinbox.set_value(m_edited_font->glyph_spacing()); + + //// Glyph Height Row + auto& glyph_height_container = font_metadata_group_box.add<GUI::Widget>(); + glyph_height_container.set_layout<GUI::HorizontalBoxLayout>(); + glyph_height_container.set_fixed_height(22); + + auto& glyph_height = glyph_height_container.add<GUI::Label>(); + glyph_height.set_fixed_width(100); + glyph_height.set_text_alignment(Gfx::TextAlignment::CenterLeft); + glyph_height.set_text("Glyph height:"); + + auto& glyph_height_spinbox = glyph_height_container.add<GUI::SpinBox>(); + glyph_height_spinbox.set_min(0); + glyph_height_spinbox.set_max(255); + glyph_height_spinbox.set_value(m_edited_font->glyph_height()); + glyph_height_spinbox.set_enabled(false); + + //// Glyph width Row + auto& glyph_weight_container = font_metadata_group_box.add<GUI::Widget>(); + glyph_weight_container.set_layout<GUI::HorizontalBoxLayout>(); + glyph_weight_container.set_fixed_height(22); + + auto& glyph_header_width_label = glyph_weight_container.add<GUI::Label>(); + glyph_header_width_label.set_fixed_width(100); + glyph_header_width_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + glyph_header_width_label.set_text("Glyph width:"); + + auto& glyph_header_width_spinbox = glyph_weight_container.add<GUI::SpinBox>(); + glyph_header_width_spinbox.set_min(0); + glyph_header_width_spinbox.set_max(255); + glyph_header_width_spinbox.set_value(m_edited_font->glyph_fixed_width()); + glyph_header_width_spinbox.set_enabled(false); + + //// Baseline Row + auto& baseline_container = font_metadata_group_box.add<GUI::Widget>(); + baseline_container.set_layout<GUI::HorizontalBoxLayout>(); + baseline_container.set_fixed_height(22); + + auto& baseline_label = baseline_container.add<GUI::Label>(); + baseline_label.set_fixed_width(100); + baseline_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + baseline_label.set_text("Baseline:"); + + auto& baseline_spinbox = baseline_container.add<GUI::SpinBox>(); + baseline_spinbox.set_min(0); + baseline_spinbox.set_max(m_edited_font->glyph_height() - 1); + baseline_spinbox.set_value(m_edited_font->baseline()); + + //// Mean line Row + auto& mean_line_container = font_metadata_group_box.add<GUI::Widget>(); + mean_line_container.set_layout<GUI::HorizontalBoxLayout>(); + mean_line_container.set_fixed_height(22); + + auto& mean_line_label = mean_line_container.add<GUI::Label>(); + mean_line_label.set_fixed_width(100); + mean_line_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + mean_line_label.set_text("Mean Line:"); + + auto& mean_line_spinbox = mean_line_container.add<GUI::SpinBox>(); + mean_line_spinbox.set_min(0); + mean_line_spinbox.set_max(m_edited_font->glyph_height() - 1); + mean_line_spinbox.set_value(m_edited_font->mean_line()); + + //// Fixed checkbox Row + auto& fixed_width_checkbox = font_metadata_group_box.add<GUI::CheckBox>(); + fixed_width_checkbox.set_text("Fixed width"); + fixed_width_checkbox.set_checked(m_edited_font->is_fixed_width()); + + // Bottom + auto& bottom_container = add<GUI::Widget>(); + bottom_container.set_layout<GUI::HorizontalBoxLayout>(); + bottom_container.layout()->set_margins({ 8, 0, 8, 8 }); + bottom_container.set_fixed_height(32); + + bottom_container.layout()->add_spacer(); + + auto& save_button = bottom_container.add<GUI::Button>(); + save_button.set_fixed_size(80, 22); + save_button.set_text("Save"); + save_button.on_click = [this](auto) { save_as(m_path); }; + + auto& quit_button = bottom_container.add<GUI::Button>(); + quit_button.set_fixed_size(80, 22); + quit_button.set_text("Quit"); + quit_button.on_click = [](auto) { + exit(0); + }; + + // Event hanglers + auto update_demo = [&] { + demo_label_1.update(); + demo_label_2.update(); + }; + + auto calculate_prefed_sizes = [&] { + int right_site_width = m_edited_font->width("QUICK FOX JUMPS NIGHTLY ABOVE WIZARD!") + 20; + right_site_width = max(right_site_width, m_glyph_map_widget->preferred_width()); + + m_preferred_width = m_glyph_editor_widget->width() + right_site_width + 20; + m_preferred_height = m_glyph_map_widget->relative_rect().height() + 2 * m_edited_font->glyph_height() + 380; + }; + + m_glyph_editor_widget->on_glyph_altered = [this, update_demo](u8 glyph) { + m_glyph_map_widget->update_glyph(glyph); + update_demo(); + }; + + m_glyph_map_widget->on_glyph_selected = [&](size_t glyph) { + m_glyph_editor_widget->set_glyph(glyph); + glyph_width_spinbox.set_value(m_edited_font->glyph_width(m_glyph_map_widget->selected_glyph())); + StringBuilder builder; + builder.appendff("{:#02x} (", glyph); + if (glyph < 128) { + builder.append(glyph); + } else { + builder.append(128 | 64 | (glyph / 64)); + builder.append(128 | (glyph % 64)); + } + builder.append(')'); + info_label.set_text(builder.to_string()); + }; + + fixed_width_checkbox.on_checked = [&, update_demo](bool checked) { + m_edited_font->set_fixed_width(checked); + glyph_width_spinbox.set_enabled(!m_edited_font->is_fixed_width()); + glyph_width_spinbox.set_value(m_edited_font->glyph_width(m_glyph_map_widget->selected_glyph())); + m_glyph_editor_widget->update(); + update_demo(); + }; + + glyph_width_spinbox.on_change = [this, update_demo](int value) { + m_edited_font->set_glyph_width(m_glyph_map_widget->selected_glyph(), value); + m_glyph_editor_widget->update(); + m_glyph_map_widget->update_glyph(m_glyph_map_widget->selected_glyph()); + update_demo(); + }; + + weight_spinbox.on_change = [this, update_demo](int value) { + m_edited_font->set_weight(value); + update_demo(); + }; + + presentation_size_spinbox.on_change = [this, update_demo](int value) { + m_edited_font->set_presentation_size(value); + update_demo(); + }; + + spacing_spinbox.on_change = [this, update_demo](int value) { + m_edited_font->set_glyph_spacing(value); + update_demo(); + }; + + baseline_spinbox.on_change = [this, update_demo](int value) { + m_edited_font->set_baseline(value); + m_glyph_editor_widget->update(); + update_demo(); + }; + + mean_line_spinbox.on_change = [this, update_demo](int value) { + m_edited_font->set_mean_line(value); + m_glyph_editor_widget->update(); + update_demo(); + }; + + // init widget + calculate_prefed_sizes(); + m_glyph_map_widget->set_selected_glyph('A'); +} + +FontEditorWidget::~FontEditorWidget() +{ +} + +bool FontEditorWidget::save_as(const String& path) +{ + auto ret_val = m_edited_font->write_to_file(path); + if (!ret_val) { + GUI::MessageBox::show(window(), "The font file could not be saved.", "Save failed", GUI::MessageBox::Type::Error); + return false; + } + m_path = path; + return true; +} diff --git a/Userland/Applications/FontEditor/FontEditor.h b/Userland/Applications/FontEditor/FontEditor.h new file mode 100644 index 0000000000..ed904e8e7d --- /dev/null +++ b/Userland/Applications/FontEditor/FontEditor.h @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/Function.h> +#include <LibGUI/Widget.h> +#include <LibGfx/BitmapFont.h> + +class GlyphEditorWidget; +class GlyphMapWidget; + +class FontEditorWidget final : public GUI::Widget { + C_OBJECT(FontEditorWidget) +public: + virtual ~FontEditorWidget() override; + + int preferred_width() { return m_preferred_width; } + int preferred_height() { return m_preferred_height; } + + bool save_as(const String&); + + const String& path() { return m_path; } + +private: + FontEditorWidget(const String& path, RefPtr<Gfx::BitmapFont>&&); + RefPtr<Gfx::BitmapFont> m_edited_font; + + RefPtr<GlyphMapWidget> m_glyph_map_widget; + RefPtr<GlyphEditorWidget> m_glyph_editor_widget; + + String m_path; + int m_preferred_width; + int m_preferred_height; +}; diff --git a/Userland/Applications/FontEditor/GlyphEditorWidget.cpp b/Userland/Applications/FontEditor/GlyphEditorWidget.cpp new file mode 100644 index 0000000000..6f545c1ccd --- /dev/null +++ b/Userland/Applications/FontEditor/GlyphEditorWidget.cpp @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "GlyphEditorWidget.h" +#include <LibGUI/Painter.h> +#include <LibGfx/BitmapFont.h> +#include <LibGfx/Palette.h> + +GlyphEditorWidget::GlyphEditorWidget(Gfx::BitmapFont& mutable_font) + : m_font(mutable_font) +{ + set_relative_rect({ 0, 0, preferred_width(), preferred_height() }); +} + +GlyphEditorWidget::~GlyphEditorWidget() +{ +} + +void GlyphEditorWidget::set_glyph(int glyph) +{ + if (m_glyph == glyph) + return; + m_glyph = glyph; + update(); +} + +void GlyphEditorWidget::paint_event(GUI::PaintEvent& event) +{ + GUI::Frame::paint_event(event); + + GUI::Painter painter(*this); + painter.add_clip_rect(frame_inner_rect()); + painter.add_clip_rect(event.rect()); + painter.fill_rect(frame_inner_rect(), palette().base()); + painter.translate(frame_thickness(), frame_thickness()); + + painter.translate(-1, -1); + for (int y = 1; y < font().glyph_height(); ++y) { + int y_below = y - 1; + bool bold_line = y_below == font().baseline() || y_below == font().mean_line(); + painter.draw_line({ 0, y * m_scale }, { font().max_glyph_width() * m_scale, y * m_scale }, palette().threed_shadow2(), bold_line ? 2 : 1); + } + + for (int x = 1; x < font().max_glyph_width(); ++x) + painter.draw_line({ x * m_scale, 0 }, { x * m_scale, font().glyph_height() * m_scale }, palette().threed_shadow2()); + + auto bitmap = font().glyph_bitmap(m_glyph); + + for (int y = 0; y < font().glyph_height(); ++y) { + for (int x = 0; x < font().max_glyph_width(); ++x) { + Gfx::IntRect rect { x * m_scale, y * m_scale, m_scale, m_scale }; + if (x >= font().glyph_width(m_glyph)) { + painter.fill_rect(rect, palette().threed_shadow1()); + } else { + if (bitmap.bit_at(x, y)) + painter.fill_rect(rect, palette().base_text()); + } + } + } +} + +void GlyphEditorWidget::mousedown_event(GUI::MouseEvent& event) +{ + draw_at_mouse(event); +} + +void GlyphEditorWidget::mousemove_event(GUI::MouseEvent& event) +{ + if (event.buttons() & (GUI::MouseButton::Left | GUI::MouseButton::Right)) + draw_at_mouse(event); +} + +void GlyphEditorWidget::draw_at_mouse(const GUI::MouseEvent& event) +{ + bool set = event.buttons() & GUI::MouseButton::Left; + bool unset = event.buttons() & GUI::MouseButton::Right; + if (!(set ^ unset)) + return; + int x = (event.x() - 1) / m_scale; + int y = (event.y() - 1) / m_scale; + auto bitmap = font().glyph_bitmap(m_glyph); + if (x < 0 || x >= bitmap.width()) + return; + if (y < 0 || y >= bitmap.height()) + return; + if (bitmap.bit_at(x, y) == set) + return; + bitmap.set_bit_at(x, y, set); + if (on_glyph_altered) + on_glyph_altered(m_glyph); + update(); +} + +int GlyphEditorWidget::preferred_width() const +{ + return frame_thickness() * 2 + font().max_glyph_width() * m_scale - 1; +} + +int GlyphEditorWidget::preferred_height() const +{ + return frame_thickness() * 2 + font().glyph_height() * m_scale - 1; +} diff --git a/Userland/Applications/FontEditor/GlyphEditorWidget.h b/Userland/Applications/FontEditor/GlyphEditorWidget.h new file mode 100644 index 0000000000..a54c551642 --- /dev/null +++ b/Userland/Applications/FontEditor/GlyphEditorWidget.h @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/Function.h> +#include <LibGUI/Frame.h> +#include <LibGfx/BitmapFont.h> + +class GlyphEditorWidget final : public GUI::Frame { + C_OBJECT(GlyphEditorWidget) +public: + virtual ~GlyphEditorWidget() override; + + int glyph() const { return m_glyph; } + void set_glyph(int); + + int preferred_width() const; + int preferred_height() const; + + Gfx::BitmapFont& font() { return *m_font; } + const Gfx::BitmapFont& font() const { return *m_font; } + + Function<void(u8)> on_glyph_altered; + +private: + GlyphEditorWidget(Gfx::BitmapFont&); + virtual void paint_event(GUI::PaintEvent&) override; + virtual void mousedown_event(GUI::MouseEvent&) override; + virtual void mousemove_event(GUI::MouseEvent&) override; + + void draw_at_mouse(const GUI::MouseEvent&); + + RefPtr<Gfx::BitmapFont> m_font; + int m_glyph { 0 }; + int m_scale { 10 }; +}; diff --git a/Userland/Applications/FontEditor/GlyphMapWidget.cpp b/Userland/Applications/FontEditor/GlyphMapWidget.cpp new file mode 100644 index 0000000000..ed6a65493f --- /dev/null +++ b/Userland/Applications/FontEditor/GlyphMapWidget.cpp @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "GlyphMapWidget.h" +#include <LibGUI/Painter.h> +#include <LibGfx/BitmapFont.h> +#include <LibGfx/Palette.h> + +GlyphMapWidget::GlyphMapWidget(Gfx::BitmapFont& mutable_font) + : m_font(mutable_font) +{ + m_glyph_count = mutable_font.glyph_count(); + set_relative_rect({ 0, 0, preferred_width(), preferred_height() }); + set_focus_policy(GUI::FocusPolicy::StrongFocus); +} + +GlyphMapWidget::~GlyphMapWidget() +{ +} + +int GlyphMapWidget::preferred_width() const +{ + return columns() * (font().max_glyph_width() + m_horizontal_spacing) + 2 + frame_thickness() * 2; +} + +int GlyphMapWidget::preferred_height() const +{ + return rows() * (font().glyph_height() + m_vertical_spacing) + 2 + frame_thickness() * 2; +} + +void GlyphMapWidget::set_selected_glyph(int glyph) +{ + if (m_selected_glyph == glyph) + return; + m_selected_glyph = glyph; + if (on_glyph_selected) + on_glyph_selected(glyph); + update(); +} + +Gfx::IntRect GlyphMapWidget::get_outer_rect(int glyph) const +{ + int row = glyph / columns(); + int column = glyph % columns(); + return Gfx::IntRect { + column * (font().max_glyph_width() + m_horizontal_spacing) + 1, + row * (font().glyph_height() + m_vertical_spacing) + 1, + font().max_glyph_width() + m_horizontal_spacing, + font().glyph_height() + m_horizontal_spacing + } + .translated(frame_thickness(), frame_thickness()); +} + +void GlyphMapWidget::update_glyph(int glyph) +{ + update(get_outer_rect(glyph)); +} + +void GlyphMapWidget::paint_event(GUI::PaintEvent& event) +{ + GUI::Frame::paint_event(event); + + GUI::Painter painter(*this); + painter.add_clip_rect(event.rect()); + + painter.set_font(font()); + painter.fill_rect(frame_inner_rect(), palette().base()); + + for (int glyph = 0; glyph < m_glyph_count; ++glyph) { + Gfx::IntRect outer_rect = get_outer_rect(glyph); + Gfx::IntRect inner_rect( + outer_rect.x() + m_horizontal_spacing / 2, + outer_rect.y() + m_vertical_spacing / 2, + font().max_glyph_width(), + font().glyph_height()); + if (glyph == m_selected_glyph) { + painter.fill_rect(outer_rect, is_focused() ? palette().selection() : palette().inactive_selection()); + painter.draw_glyph(inner_rect.location(), glyph, is_focused() ? palette().selection_text() : palette().inactive_selection_text()); + } else { + painter.draw_glyph(inner_rect.location(), glyph, palette().base_text()); + } + } +} + +void GlyphMapWidget::mousedown_event(GUI::MouseEvent& event) +{ + GUI::Frame::mousedown_event(event); + + // FIXME: This is a silly loop. + for (int glyph = 0; glyph < m_glyph_count; ++glyph) { + if (get_outer_rect(glyph).contains(event.position())) { + set_selected_glyph(glyph); + break; + } + } +} + +void GlyphMapWidget::keydown_event(GUI::KeyEvent& event) +{ + GUI::Frame::keydown_event(event); + + if (event.key() == KeyCode::Key_Up) { + if (selected_glyph() >= m_columns) { + set_selected_glyph(selected_glyph() - m_columns); + return; + } + } + if (event.key() == KeyCode::Key_Down) { + if (selected_glyph() < m_glyph_count - m_columns) { + set_selected_glyph(selected_glyph() + m_columns); + return; + } + } + if (event.key() == KeyCode::Key_Left) { + if (selected_glyph() > 0) { + set_selected_glyph(selected_glyph() - 1); + return; + } + } + if (event.key() == KeyCode::Key_Right) { + if (selected_glyph() < m_glyph_count - 1) { + set_selected_glyph(selected_glyph() + 1); + return; + } + } + if (event.ctrl() && event.key() == KeyCode::Key_Home) { + set_selected_glyph(0); + return; + } + if (event.ctrl() && event.key() == KeyCode::Key_End) { + set_selected_glyph(m_glyph_count - 1); + return; + } + if (!event.ctrl() && event.key() == KeyCode::Key_Home) { + set_selected_glyph(selected_glyph() / m_columns * m_columns); + return; + } + if (!event.ctrl() && event.key() == KeyCode::Key_End) { + int new_selection = selected_glyph() / m_columns * m_columns + (m_columns - 1); + int max = m_glyph_count - 1; + new_selection = clamp(new_selection, 0, max); + set_selected_glyph(new_selection); + return; + } +} diff --git a/Userland/Applications/FontEditor/GlyphMapWidget.h b/Userland/Applications/FontEditor/GlyphMapWidget.h new file mode 100644 index 0000000000..2c8443a251 --- /dev/null +++ b/Userland/Applications/FontEditor/GlyphMapWidget.h @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/Function.h> +#include <AK/StdLibExtras.h> +#include <LibGUI/Frame.h> +#include <LibGfx/BitmapFont.h> + +class GlyphMapWidget final : public GUI::Frame { + C_OBJECT(GlyphMapWidget) +public: + virtual ~GlyphMapWidget() override; + + int selected_glyph() const { return m_selected_glyph; } + void set_selected_glyph(int); + + int rows() const { return ceil_div(m_glyph_count, m_columns); } + int columns() const { return m_columns; } + + int preferred_width() const; + int preferred_height() const; + + Gfx::BitmapFont& font() { return *m_font; } + const Gfx::BitmapFont& font() const { return *m_font; } + + void update_glyph(int); + + Function<void(int)> on_glyph_selected; + +private: + explicit GlyphMapWidget(Gfx::BitmapFont&); + virtual void paint_event(GUI::PaintEvent&) override; + virtual void mousedown_event(GUI::MouseEvent&) override; + virtual void keydown_event(GUI::KeyEvent&) override; + + Gfx::IntRect get_outer_rect(int glyph) const; + + RefPtr<Gfx::BitmapFont> m_font; + int m_glyph_count; + int m_columns { 32 }; + int m_horizontal_spacing { 2 }; + int m_vertical_spacing { 2 }; + int m_selected_glyph { 0 }; +}; diff --git a/Userland/Applications/FontEditor/main.cpp b/Userland/Applications/FontEditor/main.cpp new file mode 100644 index 0000000000..f0a72d1d29 --- /dev/null +++ b/Userland/Applications/FontEditor/main.cpp @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "FontEditor.h" +#include <AK/URL.h> +#include <LibCore/ArgsParser.h> +#include <LibDesktop/Launcher.h> +#include <LibGUI/Action.h> +#include <LibGUI/Application.h> +#include <LibGUI/FilePicker.h> +#include <LibGUI/Icon.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/Window.h> +#include <LibGfx/Bitmap.h> +#include <LibGfx/BitmapFont.h> +#include <LibGfx/FontDatabase.h> +#include <LibGfx/Point.h> +#include <stdio.h> + +int main(int argc, char** argv) +{ + if (pledge("stdio shared_buffer thread rpath accept unix cpath wpath fattr unix", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio shared_buffer thread rpath accept cpath wpath unix", nullptr) < 0) { + perror("pledge"); + return 1; + } + + if (!Desktop::Launcher::add_allowed_handler_with_only_specific_urls( + "/bin/Help", + { URL::create_with_file_protocol("/usr/share/man/man1/FontEditor.md") }) + || !Desktop::Launcher::seal_allowlist()) { + warnln("Failed to set up allowed launch URLs"); + return 1; + } + + if (pledge("stdio shared_buffer thread rpath accept cpath wpath", nullptr) < 0) { + perror("pledge"); + return 1; + } + + const char* path = nullptr; + Core::ArgsParser args_parser; + args_parser.add_positional_argument(path, "The font file for editing.", "file", Core::ArgsParser::Required::No); + args_parser.parse(argc, argv); + + RefPtr<Gfx::BitmapFont> edited_font; + if (path == nullptr) { + path = "/tmp/saved.font"; + edited_font = static_ptr_cast<Gfx::BitmapFont>(Gfx::FontDatabase::default_font().clone()); + } else { + edited_font = static_ptr_cast<Gfx::BitmapFont>(Gfx::Font::load_from_file(path)->clone()); + if (!edited_font) { + String message = String::formatted("Couldn't load font: {}\n", path); + GUI::MessageBox::show(nullptr, message, "Font Editor", GUI::MessageBox::Type::Error); + return 1; + } + } + + auto app_icon = GUI::Icon::default_icon("app-font-editor"); + + auto window = GUI::Window::construct(); + window->set_icon(app_icon.bitmap_for_size(16)); + + auto set_edited_font = [&](const String& path, RefPtr<Gfx::BitmapFont>&& font, Gfx::IntPoint point) { + // Convert 256 char font to 384 char font. + if (font->type() == Gfx::FontTypes::Default) + font->set_type(Gfx::FontTypes::LatinExtendedA); + + window->set_title(String::formatted("{} - Font Editor", path)); + auto& font_editor_widget = window->set_main_widget<FontEditorWidget>(path, move(font)); + window->set_rect({ point, { font_editor_widget.preferred_width(), font_editor_widget.preferred_height() } }); + }; + set_edited_font(path, move(edited_font), window->position()); + + auto menubar = GUI::MenuBar::construct(); + + auto& app_menu = menubar->add_menu("Font Editor"); + app_menu.add_action(GUI::CommonActions::make_open_action([&](auto&) { + Optional<String> open_path = GUI::FilePicker::get_open_filepath(window); + if (!open_path.has_value()) + return; + + RefPtr<Gfx::BitmapFont> new_font = static_ptr_cast<Gfx::BitmapFont>(Gfx::Font::load_from_file(open_path.value())->clone()); + if (!new_font) { + String message = String::formatted("Couldn't load font: {}\n", open_path.value()); + GUI::MessageBox::show(window, message, "Font Editor", GUI::MessageBox::Type::Error); + return; + } + + set_edited_font(open_path.value(), move(new_font), window->position()); + })); + app_menu.add_action(GUI::CommonActions::make_save_action([&](auto&) { + FontEditorWidget* editor = static_cast<FontEditorWidget*>(window->main_widget()); + editor->save_as(editor->path()); + })); + app_menu.add_action(GUI::CommonActions::make_save_as_action([&](auto&) { + FontEditorWidget* editor = static_cast<FontEditorWidget*>(window->main_widget()); + LexicalPath lexical_path(editor->path()); + Optional<String> save_path = GUI::FilePicker::get_save_filepath(window, lexical_path.title(), lexical_path.extension()); + if (!save_path.has_value()) + return; + + if (editor->save_as(save_path.value())) + window->set_title(String::formatted("{} - Font Editor", save_path.value())); + })); + app_menu.add_separator(); + app_menu.add_action(GUI::CommonActions::make_quit_action([&](auto&) { + app->quit(); + return; + })); + + auto& help_menu = menubar->add_menu("Help"); + help_menu.add_action(GUI::CommonActions::make_help_action([](auto&) { + Desktop::Launcher::open(URL::create_with_file_protocol("/usr/share/man/man1/FontEditor.md"), "/bin/Help"); + })); + + help_menu.add_action(GUI::CommonActions::make_about_action("Font Editor", app_icon, window)); + + app->set_menubar(move(menubar)); + + window->show(); + + return app->exec(); +} diff --git a/Userland/Applications/Help/CMakeLists.txt b/Userland/Applications/Help/CMakeLists.txt new file mode 100644 index 0000000000..46a832c044 --- /dev/null +++ b/Userland/Applications/Help/CMakeLists.txt @@ -0,0 +1,10 @@ +set(SOURCES + History.cpp + main.cpp + ManualModel.cpp + ManualPageNode.cpp + ManualSectionNode.cpp +) + +serenity_app(Help ICON app-help) +target_link_libraries(Help LibWeb LibMarkdown LibGUI LibDesktop) diff --git a/Userland/Applications/Help/History.cpp b/Userland/Applications/Help/History.cpp new file mode 100644 index 0000000000..bb4c822dcb --- /dev/null +++ b/Userland/Applications/Help/History.cpp @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2019-2020, Sergey Bugaev <bugaevc@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "History.h" + +void History::push(const StringView& history_item) +{ + m_items.shrink(m_current_history_item + 1); + m_items.append(history_item); + m_current_history_item++; +} + +String History::current() +{ + if (m_current_history_item == -1) + return {}; + return m_items[m_current_history_item]; +} + +void History::go_back() +{ + ASSERT(can_go_back()); + m_current_history_item--; +} + +void History::go_forward() +{ + ASSERT(can_go_forward()); + m_current_history_item++; +} + +void History::clear() +{ + m_items = {}; + m_current_history_item = -1; +} diff --git a/Userland/Applications/Help/History.h b/Userland/Applications/Help/History.h new file mode 100644 index 0000000000..c4058fabfc --- /dev/null +++ b/Userland/Applications/Help/History.h @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2019-2020, Sergey Bugaev <bugaevc@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/String.h> +#include <AK/Vector.h> + +class History final { +public: + void push(const StringView& history_item); + String current(); + + void go_back(); + void go_forward(); + + bool can_go_back() { return m_current_history_item > 0; } + bool can_go_forward() { return m_current_history_item + 1 < static_cast<int>(m_items.size()); } + + void clear(); + +private: + Vector<String> m_items; + int m_current_history_item { -1 }; +}; diff --git a/Userland/Applications/Help/ManualModel.cpp b/Userland/Applications/Help/ManualModel.cpp new file mode 100644 index 0000000000..484b1b22c1 --- /dev/null +++ b/Userland/Applications/Help/ManualModel.cpp @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2019-2020, Sergey Bugaev <bugaevc@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "ManualModel.h" +#include "ManualNode.h" +#include "ManualPageNode.h" +#include "ManualSectionNode.h" +#include <AK/ByteBuffer.h> +#include <LibCore/File.h> +#include <LibGUI/FilteringProxyModel.h> + +static ManualSectionNode s_sections[] = { + { "1", "User programs" }, + { "2", "System calls" }, + { "3", "Libraries" }, + { "4", "Special files" }, + { "5", "File formats" }, + { "6", "Games" }, + { "7", "Miscellanea" }, + { "8", "Sysadmin tools" } +}; + +ManualModel::ManualModel() +{ + m_section_open_icon.set_bitmap_for_size(16, Gfx::Bitmap::load_from_file("/res/icons/16x16/book-open.png")); + m_section_icon.set_bitmap_for_size(16, Gfx::Bitmap::load_from_file("/res/icons/16x16/book.png")); + m_page_icon.set_bitmap_for_size(16, Gfx::Bitmap::load_from_file("/res/icons/16x16/filetype-unknown.png")); +} + +Optional<GUI::ModelIndex> ManualModel::index_from_path(const StringView& path) const +{ + for (int section = 0; section < row_count(); ++section) { + auto parent_index = index(section, 0); + for (int row = 0; row < row_count(parent_index); ++row) { + auto child_index = index(row, 0, parent_index); + auto* node = static_cast<const ManualNode*>(child_index.internal_data()); + if (!node->is_page()) + continue; + auto* page = static_cast<const ManualPageNode*>(node); + if (page->path() != path) + continue; + return child_index; + } + } + return {}; +} + +String ManualModel::page_path(const GUI::ModelIndex& index) const +{ + if (!index.is_valid()) + return {}; + auto* node = static_cast<const ManualNode*>(index.internal_data()); + if (!node->is_page()) + return {}; + auto* page = static_cast<const ManualPageNode*>(node); + return page->path(); +} + +Result<StringView, OSError> ManualModel::page_view(const String& path) const +{ + if (path.is_empty()) + return StringView {}; + + { + // Check if we've got it cached already. + auto mapped_file = m_mapped_files.get(path); + if (mapped_file.has_value()) + return StringView { mapped_file.value()->bytes() }; + } + + auto file_or_error = MappedFile::map(path); + if (file_or_error.is_error()) + return file_or_error.error(); + + StringView view { file_or_error.value()->bytes() }; + m_mapped_files.set(path, file_or_error.release_value()); + return view; +} + +String ManualModel::page_and_section(const GUI::ModelIndex& index) const +{ + if (!index.is_valid()) + return {}; + auto* node = static_cast<const ManualNode*>(index.internal_data()); + if (!node->is_page()) + return {}; + auto* page = static_cast<const ManualPageNode*>(node); + auto* section = static_cast<const ManualSectionNode*>(page->parent()); + return String::formatted("{}({})", page->name(), section->section_name()); +} + +GUI::ModelIndex ManualModel::index(int row, int column, const GUI::ModelIndex& parent_index) const +{ + if (!parent_index.is_valid()) + return create_index(row, column, &s_sections[row]); + auto* parent = static_cast<const ManualNode*>(parent_index.internal_data()); + auto* child = &parent->children()[row]; + return create_index(row, column, child); +} + +GUI::ModelIndex ManualModel::parent_index(const GUI::ModelIndex& index) const +{ + if (!index.is_valid()) + return {}; + auto* child = static_cast<const ManualNode*>(index.internal_data()); + auto* parent = child->parent(); + if (parent == nullptr) + return {}; + + if (parent->parent() == nullptr) { + for (size_t row = 0; row < sizeof(s_sections) / sizeof(s_sections[0]); row++) + if (&s_sections[row] == parent) + return create_index(row, 0, parent); + ASSERT_NOT_REACHED(); + } + for (size_t row = 0; row < parent->parent()->children().size(); row++) { + ManualNode* child_at_row = &parent->parent()->children()[row]; + if (child_at_row == parent) + return create_index(row, 0, parent); + } + ASSERT_NOT_REACHED(); +} + +int ManualModel::row_count(const GUI::ModelIndex& index) const +{ + if (!index.is_valid()) + return sizeof(s_sections) / sizeof(s_sections[0]); + auto* node = static_cast<const ManualNode*>(index.internal_data()); + return node->children().size(); +} + +int ManualModel::column_count(const GUI::ModelIndex&) const +{ + return 1; +} + +GUI::Variant ManualModel::data(const GUI::ModelIndex& index, GUI::ModelRole role) const +{ + auto* node = static_cast<const ManualNode*>(index.internal_data()); + switch (role) { + case GUI::ModelRole::Search: + if (!node->is_page()) + return {}; + return String(page_view(page_path(index)).value()); + case GUI::ModelRole::Display: + return node->name(); + case GUI::ModelRole::Icon: + if (node->is_page()) + return m_page_icon; + if (node->is_open()) + return m_section_open_icon; + return m_section_icon; + default: + return {}; + } +} + +void ManualModel::update_section_node_on_toggle(const GUI::ModelIndex& index, const bool open) +{ + auto* node = static_cast<ManualSectionNode*>(index.internal_data()); + node->set_open(open); +} + +TriState ManualModel::data_matches(const GUI::ModelIndex& index, GUI::Variant term) const +{ + auto view_result = page_view(page_path(index)); + if (view_result.is_error() || view_result.value().is_empty()) + return TriState::False; + + return view_result.value().contains(term.as_string()) ? TriState::True : TriState::False; +} + +void ManualModel::update() +{ + did_update(); +} diff --git a/Userland/Applications/Help/ManualModel.h b/Userland/Applications/Help/ManualModel.h new file mode 100644 index 0000000000..02efecaff0 --- /dev/null +++ b/Userland/Applications/Help/ManualModel.h @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2019-2020, Sergey Bugaev <bugaevc@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/NonnullRefPtr.h> +#include <AK/Optional.h> +#include <AK/Result.h> +#include <AK/String.h> +#include <LibGUI/Model.h> + +class ManualModel final : public GUI::Model { +public: + static NonnullRefPtr<ManualModel> create() + { + return adopt(*new ManualModel); + } + + virtual ~ManualModel() override {}; + + Optional<GUI::ModelIndex> index_from_path(const StringView&) const; + + String page_path(const GUI::ModelIndex&) const; + String page_and_section(const GUI::ModelIndex&) const; + Result<StringView, OSError> page_view(const String& path) const; + + void update_section_node_on_toggle(const GUI::ModelIndex&, const bool); + virtual int row_count(const GUI::ModelIndex& = GUI::ModelIndex()) const override; + virtual int column_count(const GUI::ModelIndex& = GUI::ModelIndex()) const override; + virtual GUI::Variant data(const GUI::ModelIndex&, GUI::ModelRole) const override; + virtual TriState data_matches(const GUI::ModelIndex&, GUI::Variant) const override; + virtual void update() override; + virtual GUI::ModelIndex parent_index(const GUI::ModelIndex&) const override; + virtual GUI::ModelIndex index(int row, int column = 0, const GUI::ModelIndex& parent = GUI::ModelIndex()) const override; + +private: + ManualModel(); + + GUI::Icon m_section_open_icon; + GUI::Icon m_section_icon; + GUI::Icon m_page_icon; + mutable HashMap<String, NonnullRefPtr<MappedFile>> m_mapped_files; +}; diff --git a/Userland/Applications/Help/ManualNode.h b/Userland/Applications/Help/ManualNode.h new file mode 100644 index 0000000000..b5901f9787 --- /dev/null +++ b/Userland/Applications/Help/ManualNode.h @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2019-2020, Sergey Bugaev <bugaevc@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/NonnullOwnPtrVector.h> +#include <AK/String.h> + +class ManualNode { +public: + virtual ~ManualNode() { } + + virtual NonnullOwnPtrVector<ManualNode>& children() const = 0; + virtual const ManualNode* parent() const = 0; + virtual String name() const = 0; + virtual bool is_page() const { return false; } + virtual bool is_open() const { return false; } +}; diff --git a/Userland/Applications/Help/ManualPageNode.cpp b/Userland/Applications/Help/ManualPageNode.cpp new file mode 100644 index 0000000000..4b30c4dca4 --- /dev/null +++ b/Userland/Applications/Help/ManualPageNode.cpp @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2019-2020, Sergey Bugaev <bugaevc@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "ManualPageNode.h" +#include "ManualSectionNode.h" + +const ManualNode* ManualPageNode::parent() const +{ + return &m_section; +} + +NonnullOwnPtrVector<ManualNode>& ManualPageNode::children() const +{ + static NonnullOwnPtrVector<ManualNode> empty_vector; + return empty_vector; +} + +String ManualPageNode::path() const +{ + return String::formatted("{}/{}.md", m_section.path(), m_page); +} diff --git a/Userland/Applications/Help/ManualPageNode.h b/Userland/Applications/Help/ManualPageNode.h new file mode 100644 index 0000000000..6de744e0e4 --- /dev/null +++ b/Userland/Applications/Help/ManualPageNode.h @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2019-2020, Sergey Bugaev <bugaevc@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "ManualNode.h" + +class ManualSectionNode; + +class ManualPageNode : public ManualNode { +public: + virtual ~ManualPageNode() override { } + + ManualPageNode(const ManualSectionNode& section, const StringView& page) + : m_section(section) + , m_page(page) + { + } + + virtual NonnullOwnPtrVector<ManualNode>& children() const override; + virtual const ManualNode* parent() const override; + virtual String name() const override { return m_page; }; + virtual bool is_page() const override { return true; } + + String path() const; + +private: + const ManualSectionNode& m_section; + String m_page; +}; diff --git a/Userland/Applications/Help/ManualSectionNode.cpp b/Userland/Applications/Help/ManualSectionNode.cpp new file mode 100644 index 0000000000..ce34eb3286 --- /dev/null +++ b/Userland/Applications/Help/ManualSectionNode.cpp @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2019-2020, Sergey Bugaev <bugaevc@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "ManualSectionNode.h" +#include "ManualPageNode.h" +#include <AK/LexicalPath.h> +#include <AK/QuickSort.h> +#include <AK/String.h> +#include <LibCore/DirIterator.h> + +String ManualSectionNode::path() const +{ + return String::formatted("/usr/share/man/man{}", m_section); +} + +void ManualSectionNode::reify_if_needed() const +{ + if (m_reified) + return; + m_reified = true; + + Core::DirIterator dir_iter { path(), Core::DirIterator::Flags::SkipDots }; + + Vector<String> page_names; + while (dir_iter.has_next()) { + LexicalPath lexical_path(dir_iter.next_path()); + if (lexical_path.extension() != "md") + continue; + page_names.append(lexical_path.title()); + } + + quick_sort(page_names); + + for (auto& page_name : page_names) + m_children.append(make<ManualPageNode>(*this, move(page_name))); +} + +void ManualSectionNode::set_open(bool open) +{ + if (m_open == open) + return; + m_open = open; +} diff --git a/Userland/Applications/Help/ManualSectionNode.h b/Userland/Applications/Help/ManualSectionNode.h new file mode 100644 index 0000000000..9b70ea9812 --- /dev/null +++ b/Userland/Applications/Help/ManualSectionNode.h @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2019-2020, Sergey Bugaev <bugaevc@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "ManualNode.h" + +class ManualSectionNode : public ManualNode { +public: + virtual ~ManualSectionNode() override { } + + ManualSectionNode(String section, String name) + : m_section(section) + , m_full_name(String::formatted("{}. {}", section, name)) + { + } + + virtual NonnullOwnPtrVector<ManualNode>& children() const override + { + reify_if_needed(); + return m_children; + } + + virtual const ManualNode* parent() const override { return nullptr; } + virtual String name() const override { return m_full_name; } + virtual bool is_open() const override { return m_open; } + void set_open(bool open); + + const String& section_name() const { return m_section; } + String path() const; + +private: + void reify_if_needed() const; + + String m_section; + String m_full_name; + mutable NonnullOwnPtrVector<ManualNode> m_children; + mutable bool m_reified { false }; + bool m_open { false }; +}; diff --git a/Userland/Applications/Help/main.cpp b/Userland/Applications/Help/main.cpp new file mode 100644 index 0000000000..d5a6405fbf --- /dev/null +++ b/Userland/Applications/Help/main.cpp @@ -0,0 +1,312 @@ +/* + * Copyright (c) 2019-2020, Sergey Bugaev <bugaevc@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "History.h" +#include "ManualModel.h" +#include <AK/URL.h> +#include <LibCore/ArgsParser.h> +#include <LibCore/File.h> +#include <LibDesktop/Launcher.h> +#include <LibGUI/Action.h> +#include <LibGUI/Application.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/FilteringProxyModel.h> +#include <LibGUI/ListView.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/Splitter.h> +#include <LibGUI/TabWidget.h> +#include <LibGUI/TextBox.h> +#include <LibGUI/ToolBar.h> +#include <LibGUI/ToolBarContainer.h> +#include <LibGUI/TreeView.h> +#include <LibGUI/Window.h> +#include <LibMarkdown/Document.h> +#include <LibWeb/OutOfProcessWebView.h> +#include <libgen.h> +#include <stdio.h> +#include <string.h> + +int main(int argc, char* argv[]) +{ + if (pledge("stdio shared_buffer accept rpath unix cpath fattr", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio shared_buffer accept rpath unix", nullptr) < 0) { + perror("pledge"); + return 1; + } + + if (unveil("/res", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/usr/share/man", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/tmp/portal/launch", "rw") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/tmp/portal/webcontent", "rw") < 0) { + perror("unveil"); + return 1; + } + + unveil(nullptr, nullptr); + + const char* start_page = nullptr; + + Core::ArgsParser args_parser; + args_parser.add_positional_argument(start_page, "Page to open at launch", "page", Core::ArgsParser::Required::No); + + args_parser.parse(argc, argv); + + auto app_icon = GUI::Icon::default_icon("app-help"); + + auto window = GUI::Window::construct(); + window->set_icon(app_icon.bitmap_for_size(16)); + window->set_title("Help"); + window->resize(570, 500); + + auto& widget = window->set_main_widget<GUI::Widget>(); + widget.set_layout<GUI::VerticalBoxLayout>(); + widget.set_fill_with_background_color(true); + widget.layout()->set_spacing(2); + + auto& toolbar_container = widget.add<GUI::ToolBarContainer>(); + auto& toolbar = toolbar_container.add<GUI::ToolBar>(); + + auto& splitter = widget.add<GUI::HorizontalSplitter>(); + + auto model = ManualModel::create(); + + auto& left_tab_bar = splitter.add<GUI::TabWidget>(); + auto& tree_view_container = left_tab_bar.add_tab<GUI::Widget>("Browse"); + tree_view_container.set_layout<GUI::VerticalBoxLayout>(); + tree_view_container.layout()->set_margins({ 4, 4, 4, 4 }); + auto& tree_view = tree_view_container.add<GUI::TreeView>(); + auto& search_view = left_tab_bar.add_tab<GUI::Widget>("Search"); + search_view.set_layout<GUI::VerticalBoxLayout>(); + search_view.layout()->set_margins({ 4, 4, 4, 4 }); + auto& search_box = search_view.add<GUI::TextBox>(); + auto& search_list_view = search_view.add<GUI::ListView>(); + search_box.set_fixed_height(20); + search_box.set_placeholder("Search..."); + search_box.on_change = [&] { + if (auto model = search_list_view.model()) { + auto& search_model = *static_cast<GUI::FilteringProxyModel*>(model); + search_model.set_filter_term(search_box.text()); + search_model.update(); + } + }; + search_list_view.set_model(GUI::FilteringProxyModel::construct(model)); + search_list_view.model()->update(); + + tree_view.set_model(model); + left_tab_bar.set_fixed_width(200); + + auto& page_view = splitter.add<Web::OutOfProcessWebView>(); + + History history; + + RefPtr<GUI::Action> go_back_action; + RefPtr<GUI::Action> go_forward_action; + + auto update_actions = [&]() { + go_back_action->set_enabled(history.can_go_back()); + go_forward_action->set_enabled(history.can_go_forward()); + }; + + auto open_page = [&](const String& path) { + if (path.is_null()) { + window->set_title("Help"); + page_view.load_empty_document(); + return; + } + + auto source_result = model->page_view(path); + if (source_result.is_error()) { + GUI::MessageBox::show(window, source_result.error().string(), "Failed to open man page", GUI::MessageBox::Type::Error); + return; + } + + auto source = source_result.value(); + String html; + { + auto md_document = Markdown::Document::parse(source); + ASSERT(md_document); + html = md_document->render_to_html(); + } + + auto url = URL::create_with_file_protocol(path); + page_view.load_html(html, url); + + auto tree_view_index = model->index_from_path(path); + if (tree_view_index.has_value()) + tree_view.expand_tree(tree_view_index.value().parent()); + + String page_and_section = model->page_and_section(tree_view_index.value()); + window->set_title(String::formatted("{} - Help", page_and_section)); + }; + + tree_view.on_selection_change = [&] { + String path = model->page_path(tree_view.selection().first()); + history.push(path); + update_actions(); + open_page(path); + }; + + tree_view.on_toggle = [&](const GUI::ModelIndex& index, const bool open) { + model->update_section_node_on_toggle(index, open); + }; + + auto open_external = [&](auto& url) { + if (!Desktop::Launcher::open(url)) { + GUI::MessageBox::show(window, + String::formatted("The link to '{}' could not be opened.", url), + "Failed to open link", + GUI::MessageBox::Type::Error); + } + }; + search_list_view.on_selection = [&](auto index) { + if (!index.is_valid()) + return; + + if (auto model = search_list_view.model()) { + auto& search_model = *static_cast<GUI::FilteringProxyModel*>(model); + index = search_model.map(index); + } else { + page_view.load_empty_document(); + return; + } + String path = model->page_path(index); + if (path.is_null()) { + page_view.load_empty_document(); + return; + } + tree_view.selection().clear(); + tree_view.selection().add(index); + history.push(path); + update_actions(); + open_page(path); + }; + + page_view.on_link_click = [&](auto& url, auto&, unsigned) { + if (url.protocol() != "file") { + open_external(url); + return; + } + auto path = Core::File::real_path_for(url.path()); + if (!path.starts_with("/usr/share/man/")) { + open_external(url); + return; + } + auto tree_view_index = model->index_from_path(path); + if (tree_view_index.has_value()) { + dbgln("Found path _{}_ in model at index {}", path, tree_view_index.value()); + tree_view.selection().set(tree_view_index.value()); + return; + } + history.push(path); + update_actions(); + open_page(path); + }; + + go_back_action = GUI::CommonActions::make_go_back_action([&](auto&) { + history.go_back(); + update_actions(); + open_page(history.current()); + }); + + go_forward_action = GUI::CommonActions::make_go_forward_action([&](auto&) { + history.go_forward(); + update_actions(); + open_page(history.current()); + }); + + go_back_action->set_enabled(false); + go_forward_action->set_enabled(false); + + auto go_home_action = GUI::CommonActions::make_go_home_action([&](auto&) { + String path = "/usr/share/man/man7/Help-index.md"; + history.push(path); + update_actions(); + open_page(path); + }); + + toolbar.add_action(*go_back_action); + toolbar.add_action(*go_forward_action); + toolbar.add_action(*go_home_action); + + auto menubar = GUI::MenuBar::construct(); + + auto& app_menu = menubar->add_menu("Help"); + app_menu.add_action(GUI::CommonActions::make_about_action("Help", app_icon, window)); + app_menu.add_separator(); + app_menu.add_action(GUI::CommonActions::make_quit_action([](auto&) { + GUI::Application::the()->quit(); + })); + + auto& go_menu = menubar->add_menu("Go"); + go_menu.add_action(*go_back_action); + go_menu.add_action(*go_forward_action); + go_menu.add_action(*go_home_action); + + app->set_menubar(move(menubar)); + + if (start_page) { + URL url = URL::create_with_url_or_path(start_page); + if (url.is_valid() && url.path().ends_with(".md")) { + history.push(url.path()); + update_actions(); + open_page(url.path()); + } else { + left_tab_bar.set_active_widget(&search_view); + search_box.set_text(start_page); + if (auto model = search_list_view.model()) { + auto& search_model = *static_cast<GUI::FilteringProxyModel*>(model); + search_model.set_filter_term(search_box.text()); + } + } + } else { + go_home_action->activate(); + } + + window->set_focused_widget(&left_tab_bar); + window->show(); + + return app->exec(); +} diff --git a/Userland/Applications/HexEditor/CMakeLists.txt b/Userland/Applications/HexEditor/CMakeLists.txt new file mode 100644 index 0000000000..ebe134863f --- /dev/null +++ b/Userland/Applications/HexEditor/CMakeLists.txt @@ -0,0 +1,8 @@ +set(SOURCES + HexEditor.cpp + HexEditorWidget.cpp + main.cpp +) + +serenity_app(HexEditor ICON app-hexeditor) +target_link_libraries(HexEditor LibGUI) diff --git a/Userland/Applications/HexEditor/HexEditor.cpp b/Userland/Applications/HexEditor/HexEditor.cpp new file mode 100644 index 0000000000..f4999661f8 --- /dev/null +++ b/Userland/Applications/HexEditor/HexEditor.cpp @@ -0,0 +1,583 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "HexEditor.h" +#include <AK/StringBuilder.h> +#include <LibGUI/Action.h> +#include <LibGUI/Clipboard.h> +#include <LibGUI/Menu.h> +#include <LibGUI/Painter.h> +#include <LibGUI/ScrollBar.h> +#include <LibGUI/TextEditor.h> +#include <LibGUI/Window.h> +#include <LibGfx/FontDatabase.h> +#include <LibGfx/Palette.h> +#include <ctype.h> +#include <fcntl.h> +#include <stdio.h> +#include <unistd.h> + +HexEditor::HexEditor() +{ + set_focus_policy(GUI::FocusPolicy::StrongFocus); + set_scrollbars_enabled(true); + set_font(Gfx::FontDatabase::default_fixed_width_font()); + set_background_role(ColorRole::Base); + set_foreground_role(ColorRole::BaseText); + vertical_scrollbar().set_step(line_height()); +} + +HexEditor::~HexEditor() +{ +} + +void HexEditor::set_readonly(bool readonly) +{ + if (m_readonly == readonly) + return; + m_readonly = readonly; +} + +void HexEditor::set_buffer(const ByteBuffer& buffer) +{ + m_buffer = buffer; + set_content_length(buffer.size()); + m_tracked_changes.clear(); + m_position = 0; + m_byte_position = 0; + update(); + update_status(); +} + +void HexEditor::fill_selection(u8 fill_byte) +{ + if (!has_selection()) + return; + + for (int i = m_selection_start; i <= m_selection_end; i++) { + m_tracked_changes.set(i, m_buffer.data()[i]); + m_buffer.data()[i] = fill_byte; + } + + update(); + did_change(); +} + +void HexEditor::set_position(int position) +{ + if (position > static_cast<int>(m_buffer.size())) + return; + + m_position = position; + m_byte_position = 0; + scroll_position_into_view(position); + update_status(); +} + +bool HexEditor::write_to_file(const StringView& path) +{ + if (m_buffer.is_empty()) + return true; + + int fd = open_with_path_length(path.characters_without_null_termination(), path.length(), O_WRONLY | O_CREAT | O_TRUNC, 0666); + if (fd < 0) { + perror("open"); + return false; + } + + int rc = ftruncate(fd, m_buffer.size()); + if (rc < 0) { + perror("ftruncate"); + return false; + } + + ssize_t nwritten = write(fd, m_buffer.data(), m_buffer.size()); + if (nwritten < 0) { + perror("write"); + close(fd); + return false; + } + + if (static_cast<size_t>(nwritten) == m_buffer.size()) { + m_tracked_changes.clear(); + update(); + } + + close(fd); + return true; +} + +bool HexEditor::copy_selected_hex_to_clipboard() +{ + if (!has_selection()) + return false; + + StringBuilder output_string_builder; + for (int i = m_selection_start; i <= m_selection_end; i++) + output_string_builder.appendff("{:02X} ", m_buffer.data()[i]); + + GUI::Clipboard::the().set_plain_text(output_string_builder.to_string()); + return true; +} + +bool HexEditor::copy_selected_text_to_clipboard() +{ + if (!has_selection()) + return false; + + StringBuilder output_string_builder; + for (int i = m_selection_start; i <= m_selection_end; i++) + output_string_builder.append(isprint(m_buffer.data()[i]) ? m_buffer[i] : '.'); + + GUI::Clipboard::the().set_plain_text(output_string_builder.to_string()); + return true; +} + +bool HexEditor::copy_selected_hex_to_clipboard_as_c_code() +{ + if (!has_selection()) + return false; + + StringBuilder output_string_builder; + output_string_builder.appendff("unsigned char raw_data[{}] = {{\n", (m_selection_end - m_selection_start) + 1); + output_string_builder.append(" "); + for (int i = m_selection_start, j = 1; i <= m_selection_end; i++, j++) { + output_string_builder.appendff("{:#02X}", m_buffer.data()[i]); + if (i != m_selection_end) + output_string_builder.append(", "); + if ((j % 12) == 0) { + output_string_builder.append("\n"); + output_string_builder.append(" "); + } + } + output_string_builder.append("\n};\n"); + + GUI::Clipboard::the().set_plain_text(output_string_builder.to_string()); + return true; +} + +void HexEditor::set_bytes_per_row(int bytes_per_row) +{ + m_bytes_per_row = bytes_per_row; + set_content_size({ offset_margin_width() + (m_bytes_per_row * (character_width() * 3)) + 10 + (m_bytes_per_row * character_width()) + 20, total_rows() * line_height() + 10 }); + update(); +} + +void HexEditor::set_content_length(int length) +{ + if (length == m_content_length) + return; + m_content_length = length; + set_content_size({ offset_margin_width() + (m_bytes_per_row * (character_width() * 3)) + 10 + (m_bytes_per_row * character_width()) + 20, total_rows() * line_height() + 10 }); +} + +void HexEditor::mousedown_event(GUI::MouseEvent& event) +{ + if (event.button() != GUI::MouseButton::Left) { + return; + } + + auto absolute_x = horizontal_scrollbar().value() + event.x(); + auto absolute_y = vertical_scrollbar().value() + event.y(); + + auto hex_start_x = frame_thickness() + 90; + auto hex_start_y = frame_thickness() + 5; + auto hex_end_x = hex_start_x + (bytes_per_row() * (character_width() * 3)); + auto hex_end_y = hex_start_y + 5 + (total_rows() * line_height()); + + auto text_start_x = frame_thickness() + 100 + (bytes_per_row() * (character_width() * 3)); + auto text_start_y = frame_thickness() + 5; + auto text_end_x = text_start_x + (bytes_per_row() * character_width()); + auto text_end_y = text_start_y + 5 + (total_rows() * line_height()); + + if (absolute_x >= hex_start_x && absolute_x <= hex_end_x && absolute_y >= hex_start_y && absolute_y <= hex_end_y) { + auto byte_x = (absolute_x - hex_start_x) / (character_width() * 3); + auto byte_y = (absolute_y - hex_start_y) / line_height(); + auto offset = (byte_y * m_bytes_per_row) + byte_x; + + if (offset < 0 || offset >= static_cast<int>(m_buffer.size())) + return; + +#ifdef HEX_DEBUG + outln("HexEditor::mousedown_event(hex): offset={}", offset); +#endif + + m_edit_mode = EditMode::Hex; + m_byte_position = 0; + m_position = offset; + m_in_drag_select = true; + m_selection_start = offset; + m_selection_end = offset; + update(); + update_status(); + } + + if (absolute_x >= text_start_x && absolute_x <= text_end_x && absolute_y >= text_start_y && absolute_y <= text_end_y) { + auto byte_x = (absolute_x - text_start_x) / character_width(); + auto byte_y = (absolute_y - text_start_y) / line_height(); + auto offset = (byte_y * m_bytes_per_row) + byte_x; + + if (offset < 0 || offset >= static_cast<int>(m_buffer.size())) + return; + +#ifdef HEX_DEBUG + outln("HexEditor::mousedown_event(text): offset={}", offset); +#endif + + m_position = offset; + m_byte_position = 0; + m_in_drag_select = true; + m_selection_start = offset; + m_selection_end = offset; + m_edit_mode = EditMode::Text; + update(); + update_status(); + } +} + +void HexEditor::mousemove_event(GUI::MouseEvent& event) +{ + auto absolute_x = horizontal_scrollbar().value() + event.x(); + auto absolute_y = vertical_scrollbar().value() + event.y(); + + auto hex_start_x = frame_thickness() + 90; + auto hex_start_y = frame_thickness() + 5; + auto hex_end_x = hex_start_x + (bytes_per_row() * (character_width() * 3)); + auto hex_end_y = hex_start_y + 5 + (total_rows() * line_height()); + + auto text_start_x = frame_thickness() + 100 + (bytes_per_row() * (character_width() * 3)); + auto text_start_y = frame_thickness() + 5; + auto text_end_x = text_start_x + (bytes_per_row() * character_width()); + auto text_end_y = text_start_y + 5 + (total_rows() * line_height()); + + if ((absolute_x >= hex_start_x && absolute_x <= hex_end_x + && absolute_y >= hex_start_y && absolute_y <= hex_end_y) + || (absolute_x >= text_start_x && absolute_x <= text_end_x + && absolute_y >= text_start_y && absolute_y <= text_end_y)) { + set_override_cursor(Gfx::StandardCursor::IBeam); + } else { + set_override_cursor(Gfx::StandardCursor::None); + } + + if (m_in_drag_select) { + if (absolute_x >= hex_start_x && absolute_x <= hex_end_x && absolute_y >= hex_start_y && absolute_y <= hex_end_y) { + auto byte_x = (absolute_x - hex_start_x) / (character_width() * 3); + auto byte_y = (absolute_y - hex_start_y) / line_height(); + auto offset = (byte_y * m_bytes_per_row) + byte_x; + + if (offset < 0 || offset > static_cast<int>(m_buffer.size())) + return; + + m_selection_end = offset; + scroll_position_into_view(offset); + } + + if (absolute_x >= text_start_x && absolute_x <= text_end_x && absolute_y >= text_start_y && absolute_y <= text_end_y) { + auto byte_x = (absolute_x - text_start_x) / character_width(); + auto byte_y = (absolute_y - text_start_y) / line_height(); + auto offset = (byte_y * m_bytes_per_row) + byte_x; + if (offset < 0 || offset > static_cast<int>(m_buffer.size())) + return; + + m_selection_end = offset; + scroll_position_into_view(offset); + } + update_status(); + update(); + return; + } +} + +void HexEditor::mouseup_event(GUI::MouseEvent& event) +{ + if (event.button() == GUI::MouseButton::Left) { + if (m_in_drag_select) { + if (m_selection_end < m_selection_start) { + // lets flip these around + auto start = m_selection_end; + m_selection_end = m_selection_start; + m_selection_start = start; + } + m_in_drag_select = false; + } + update(); + update_status(); + } +} + +void HexEditor::scroll_position_into_view(int position) +{ + int y = position / bytes_per_row(); + int x = position % bytes_per_row(); + Gfx::IntRect rect { + frame_thickness() + offset_margin_width() + (x * (character_width() * 3)) + 10, + frame_thickness() + 5 + (y * line_height()), + (character_width() * 3), + line_height() - m_line_spacing + }; + scroll_into_view(rect, true, true); +} + +void HexEditor::keydown_event(GUI::KeyEvent& event) +{ +#ifdef HEX_DEBUG + outln("HexEditor::keydown_event key={}", static_cast<u8>(event.key())); +#endif + + if (event.key() == KeyCode::Key_Up) { + if (m_position - bytes_per_row() >= 0) { + m_position -= bytes_per_row(); + m_byte_position = 0; + scroll_position_into_view(m_position); + update(); + update_status(); + } + return; + } + + if (event.key() == KeyCode::Key_Down) { + if (m_position + bytes_per_row() < static_cast<int>(m_buffer.size())) { + m_position += bytes_per_row(); + m_byte_position = 0; + scroll_position_into_view(m_position); + update(); + update_status(); + } + return; + } + + if (event.key() == KeyCode::Key_Left) { + if (m_position - 1 >= 0) { + m_position--; + m_byte_position = 0; + scroll_position_into_view(m_position); + update(); + update_status(); + } + return; + } + + if (event.key() == KeyCode::Key_Right) { + if (m_position + 1 < static_cast<int>(m_buffer.size())) { + m_position++; + m_byte_position = 0; + scroll_position_into_view(m_position); + update(); + update_status(); + } + return; + } + + if (event.key() == KeyCode::Key_Backspace) { + if (m_position > 0) { + m_position--; + m_byte_position = 0; + scroll_position_into_view(m_position); + update(); + update_status(); + } + return; + } + + if (!is_readonly() && !event.ctrl() && !event.alt() && !event.text().is_empty()) { + if (m_edit_mode == EditMode::Hex) { + hex_mode_keydown_event(event); + } else { + text_mode_keydown_event(event); + } + } +} + +void HexEditor::hex_mode_keydown_event(GUI::KeyEvent& event) +{ + if ((event.key() >= KeyCode::Key_0 && event.key() <= KeyCode::Key_9) || (event.key() >= KeyCode::Key_A && event.key() <= KeyCode::Key_F)) { + if (m_buffer.is_empty()) + return; + ASSERT(m_position >= 0); + ASSERT(m_position < static_cast<int>(m_buffer.size())); + + // yes, this is terrible... but it works. + auto value = (event.key() >= KeyCode::Key_0 && event.key() <= KeyCode::Key_9) + ? event.key() - KeyCode::Key_0 + : (event.key() - KeyCode::Key_A) + 0xA; + + if (m_byte_position == 0) { + m_tracked_changes.set(m_position, m_buffer.data()[m_position]); + m_buffer.data()[m_position] = value << 4 | (m_buffer.data()[m_position] & 0xF); // shift new value left 4 bits, OR with existing last 4 bits + m_byte_position++; + } else { + m_buffer.data()[m_position] = (m_buffer.data()[m_position] & 0xF0) | value; // save the first 4 bits, OR the new value in the last 4 + if (m_position + 1 < static_cast<int>(m_buffer.size())) + m_position++; + m_byte_position = 0; + } + + update(); + update_status(); + did_change(); + } +} + +void HexEditor::text_mode_keydown_event(GUI::KeyEvent& event) +{ + if (m_buffer.is_empty()) + return; + ASSERT(m_position >= 0); + ASSERT(m_position < static_cast<int>(m_buffer.size())); + + if (event.code_point() == 0) // This is a control key + return; + + m_tracked_changes.set(m_position, m_buffer.data()[m_position]); + m_buffer.data()[m_position] = event.code_point(); + if (m_position + 1 < static_cast<int>(m_buffer.size())) + m_position++; + m_byte_position = 0; + + update(); + update_status(); + did_change(); +} + +void HexEditor::update_status() +{ + if (on_status_change) + on_status_change(m_position, m_edit_mode, m_selection_start, m_selection_end); +} + +void HexEditor::did_change() +{ + if (on_change) + on_change(); +} + +void HexEditor::paint_event(GUI::PaintEvent& event) +{ + GUI::Frame::paint_event(event); + + GUI::Painter painter(*this); + painter.add_clip_rect(widget_inner_rect()); + painter.add_clip_rect(event.rect()); + painter.fill_rect(event.rect(), palette().color(background_role())); + + if (m_buffer.is_empty()) + return; + + painter.translate(frame_thickness(), frame_thickness()); + painter.translate(-horizontal_scrollbar().value(), -vertical_scrollbar().value()); + + Gfx::IntRect offset_clip_rect { + 0, + vertical_scrollbar().value(), + 85, + height() - height_occupied_by_horizontal_scrollbar() //(total_rows() * line_height()) + 5 + }; + painter.fill_rect(offset_clip_rect, palette().ruler()); + painter.draw_line(offset_clip_rect.top_right(), offset_clip_rect.bottom_right(), palette().ruler_border()); + + auto margin_and_hex_width = offset_margin_width() + (m_bytes_per_row * (character_width() * 3)) + 15; + painter.draw_line({ margin_and_hex_width, 0 }, + { margin_and_hex_width, vertical_scrollbar().value() + (height() - height_occupied_by_horizontal_scrollbar()) }, + palette().ruler_border()); + + auto view_height = (height() - height_occupied_by_horizontal_scrollbar()); + auto min_row = max(0, vertical_scrollbar().value() / line_height()); // if below 0 then use 0 + auto max_row = min(total_rows(), min_row + ceil_div(view_height, line_height())); // if above calculated rows, use calculated rows + + // paint offsets + for (int i = min_row; i < max_row; i++) { + Gfx::IntRect side_offset_rect { + frame_thickness() + 5, + frame_thickness() + 5 + (i * line_height()), + width() - width_occupied_by_vertical_scrollbar(), + height() - height_occupied_by_horizontal_scrollbar() + }; + + bool is_current_line = (m_position / bytes_per_row()) == i; + auto line = String::formatted("{:#08X}", i * bytes_per_row()); + painter.draw_text( + side_offset_rect, + line, + is_current_line ? Gfx::FontDatabase::default_bold_font() : font(), + Gfx::TextAlignment::TopLeft, + is_current_line ? palette().ruler_active_text() : palette().ruler_inactive_text()); + } + + for (int i = min_row; i < max_row; i++) { + for (int j = 0; j < bytes_per_row(); j++) { + auto byte_position = (i * bytes_per_row()) + j; + if (byte_position >= static_cast<int>(m_buffer.size())) + return; + + Color text_color = palette().color(foreground_role()); + if (m_tracked_changes.contains(byte_position)) { + text_color = Color::Red; + } + + auto highlight_flag = false; + if (m_selection_start > -1 && m_selection_end > -1) { + if (byte_position >= m_selection_start && byte_position <= m_selection_end) { + highlight_flag = true; + } + if (byte_position >= m_selection_end && byte_position <= m_selection_start) { + highlight_flag = true; + } + } + + Gfx::IntRect hex_display_rect { + frame_thickness() + offset_margin_width() + (j * (character_width() * 3)) + 10, + frame_thickness() + 5 + (i * line_height()), + (character_width() * 3), + line_height() - m_line_spacing + }; + if (highlight_flag) { + painter.fill_rect(hex_display_rect, palette().selection()); + text_color = text_color == Color::Red ? Color::from_rgb(0xFFC0CB) : palette().selection_text(); + } else if (byte_position == m_position) { + painter.fill_rect(hex_display_rect, palette().inactive_selection()); + text_color = palette().inactive_selection_text(); + } + + auto line = String::formatted("{:02X}", m_buffer[byte_position]); + painter.draw_text(hex_display_rect, line, Gfx::TextAlignment::TopLeft, text_color); + + Gfx::IntRect text_display_rect { + frame_thickness() + offset_margin_width() + (bytes_per_row() * (character_width() * 3)) + (j * character_width()) + 20, + frame_thickness() + 5 + (i * line_height()), + character_width(), + line_height() - m_line_spacing + }; + // selection highlighting. + if (highlight_flag) { + painter.fill_rect(text_display_rect, palette().selection()); + } else if (byte_position == m_position) { + painter.fill_rect(text_display_rect, palette().inactive_selection()); + } + + painter.draw_text(text_display_rect, String::formatted("{:c}", isprint(m_buffer[byte_position]) ? m_buffer[byte_position] : '.'), Gfx::TextAlignment::TopLeft, text_color); + } + } +} diff --git a/Userland/Applications/HexEditor/HexEditor.h b/Userland/Applications/HexEditor/HexEditor.h new file mode 100644 index 0000000000..e47eedeec3 --- /dev/null +++ b/Userland/Applications/HexEditor/HexEditor.h @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/ByteBuffer.h> +#include <AK/Function.h> +#include <AK/HashMap.h> +#include <AK/NonnullOwnPtrVector.h> +#include <AK/NonnullRefPtrVector.h> +#include <AK/StdLibExtras.h> +#include <LibGUI/ScrollableWidget.h> +#include <LibGfx/Font.h> +#include <LibGfx/TextAlignment.h> + +class HexEditor : public GUI::ScrollableWidget { + C_OBJECT(HexEditor) +public: + enum EditMode { + Hex, + Text + }; + + virtual ~HexEditor() override; + + bool is_readonly() const { return m_readonly; } + void set_readonly(bool); + + void set_buffer(const ByteBuffer&); + void fill_selection(u8 fill_byte); + bool write_to_file(const StringView& path); + + bool has_selection() const { return !(m_selection_start == -1 || m_selection_end == -1 || (m_selection_end - m_selection_start) < 0 || m_buffer.is_empty()); } + bool copy_selected_text_to_clipboard(); + bool copy_selected_hex_to_clipboard(); + bool copy_selected_hex_to_clipboard_as_c_code(); + + int bytes_per_row() const { return m_bytes_per_row; } + void set_bytes_per_row(int); + + void set_position(int position); + + Function<void(int, EditMode, int, int)> on_status_change; // position, edit mode, selection start, selection end + Function<void()> on_change; + +protected: + HexEditor(); + + virtual void paint_event(GUI::PaintEvent&) override; + virtual void mousedown_event(GUI::MouseEvent&) override; + virtual void mouseup_event(GUI::MouseEvent&) override; + virtual void mousemove_event(GUI::MouseEvent&) override; + virtual void keydown_event(GUI::KeyEvent&) override; + +private: + bool m_readonly { false }; + int m_line_spacing { 4 }; + int m_content_length { 0 }; + int m_bytes_per_row { 16 }; + ByteBuffer m_buffer; + bool m_in_drag_select { false }; + int m_selection_start { 0 }; + int m_selection_end { 0 }; + HashMap<int, u8> m_tracked_changes; + int m_position { 0 }; + int m_byte_position { 0 }; // 0 or 1 + EditMode m_edit_mode { Hex }; + + void scroll_position_into_view(int position); + + int total_rows() const { return ceil_div(m_content_length, m_bytes_per_row); } + int line_height() const { return font().glyph_height() + m_line_spacing; } + int character_width() const { return font().glyph_width('W'); } + int offset_margin_width() const { return 80; } + + void hex_mode_keydown_event(GUI::KeyEvent&); + void text_mode_keydown_event(GUI::KeyEvent&); + + void set_content_length(int); // I might make this public if I add fetching data on demand. + void update_status(); + void did_change(); +}; diff --git a/Userland/Applications/HexEditor/HexEditorWidget.cpp b/Userland/Applications/HexEditor/HexEditorWidget.cpp new file mode 100644 index 0000000000..e6f6711884 --- /dev/null +++ b/Userland/Applications/HexEditor/HexEditorWidget.cpp @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "HexEditorWidget.h" +#include <AK/Optional.h> +#include <AK/StringBuilder.h> +#include <LibCore/File.h> +#include <LibGUI/Action.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/FilePicker.h> +#include <LibGUI/InputBox.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/StatusBar.h> +#include <LibGUI/TextBox.h> +#include <LibGUI/TextEditor.h> +#include <LibGUI/ToolBar.h> +#include <stdio.h> +#include <string.h> + +HexEditorWidget::HexEditorWidget() +{ + set_fill_with_background_color(true); + set_layout<GUI::VerticalBoxLayout>(); + layout()->set_spacing(2); + + m_editor = add<HexEditor>(); + + m_editor->on_status_change = [this](int position, HexEditor::EditMode edit_mode, int selection_start, int selection_end) { + m_statusbar->set_text(0, String::formatted("Offset: {:#08X}", position)); + m_statusbar->set_text(1, String::formatted("Edit Mode: {}", edit_mode == HexEditor::EditMode::Hex ? "Hex" : "Text")); + m_statusbar->set_text(2, String::formatted("Selection Start: {}", selection_start)); + m_statusbar->set_text(3, String::formatted("Selection End: {}", selection_end)); + m_statusbar->set_text(4, String::formatted("Selected Bytes: {}", abs(selection_end - selection_start) + 1)); + }; + + m_editor->on_change = [this] { + bool was_dirty = m_document_dirty; + m_document_dirty = true; + if (!was_dirty) + update_title(); + }; + + m_statusbar = add<GUI::StatusBar>(5); + + m_new_action = GUI::Action::create("New", { Mod_Ctrl, Key_N }, Gfx::Bitmap::load_from_file("/res/icons/16x16/new.png"), [this](const GUI::Action&) { + if (m_document_dirty) { + if (GUI::MessageBox::show(window(), "Save changes to current file first?", "Warning", GUI::MessageBox::Type::Warning, GUI::MessageBox::InputType::OKCancel) != GUI::Dialog::ExecResult::ExecOK) + return; + m_save_action->activate(); + } + + String value; + if (GUI::InputBox::show(value, window(), "Enter new file size:", "New file size") == GUI::InputBox::ExecOK && !value.is_empty()) { + auto file_size = value.to_int(); + if (file_size.has_value() && file_size.value() > 0) { + m_document_dirty = false; + m_editor->set_buffer(ByteBuffer::create_zeroed(file_size.value())); + set_path(LexicalPath()); + update_title(); + } else { + GUI::MessageBox::show(window(), "Invalid file size entered.", "Error", GUI::MessageBox::Type::Error); + } + } + }); + + m_open_action = GUI::CommonActions::make_open_action([this](auto&) { + Optional<String> open_path = GUI::FilePicker::get_open_filepath(window()); + + if (!open_path.has_value()) + return; + + open_file(open_path.value()); + }); + + m_save_action = GUI::CommonActions::make_save_action([&](auto&) { + if (!m_path.is_empty()) { + if (!m_editor->write_to_file(m_path)) { + GUI::MessageBox::show(window(), "Unable to save file.\n", "Error", GUI::MessageBox::Type::Error); + } else { + m_document_dirty = false; + update_title(); + } + return; + } + + m_save_as_action->activate(); + }); + + m_save_as_action = GUI::CommonActions::make_save_as_action([&](auto&) { + Optional<String> save_path = GUI::FilePicker::get_save_filepath(window(), m_name.is_null() ? "Untitled" : m_name, m_extension.is_null() ? "bin" : m_extension); + if (!save_path.has_value()) + return; + + if (!m_editor->write_to_file(save_path.value())) { + GUI::MessageBox::show(window(), "Unable to save file.\n", "Error", GUI::MessageBox::Type::Error); + return; + } + + m_document_dirty = false; + set_path(LexicalPath(save_path.value())); + dbgln("Wrote document to {}", save_path.value()); + }); + + auto menubar = GUI::MenuBar::construct(); + auto& app_menu = menubar->add_menu("Hex Editor"); + app_menu.add_action(*m_new_action); + app_menu.add_action(*m_open_action); + app_menu.add_action(*m_save_action); + app_menu.add_action(*m_save_as_action); + app_menu.add_separator(); + app_menu.add_action(GUI::CommonActions::make_quit_action([this](auto&) { + if (!request_close()) + return; + GUI::Application::the()->quit(); + })); + + m_goto_decimal_offset_action = GUI::Action::create("Go To Offset (Decimal)...", { Mod_Ctrl | Mod_Shift, Key_G }, Gfx::Bitmap::load_from_file("/res/icons/16x16/go-forward.png"), [this](const GUI::Action&) { + String value; + if (GUI::InputBox::show(value, window(), "Enter Decimal offset:", "Go To") == GUI::InputBox::ExecOK && !value.is_empty()) { + auto new_offset = value.to_int(); + if (new_offset.has_value()) + m_editor->set_position(new_offset.value()); + } + }); + + m_goto_hex_offset_action = GUI::Action::create("Go To Offset (Hex)...", { Mod_Ctrl, Key_G }, Gfx::Bitmap::load_from_file("/res/icons/16x16/go-forward.png"), [this](const GUI::Action&) { + String value; + if (GUI::InputBox::show(value, window(), "Enter Hex offset:", "Go To") == GUI::InputBox::ExecOK && !value.is_empty()) { + auto new_offset = strtol(value.characters(), nullptr, 16); + m_editor->set_position(new_offset); + } + }); + + auto& edit_menu = menubar->add_menu("Edit"); + edit_menu.add_action(GUI::Action::create("Fill selection...", { Mod_Ctrl, Key_B }, [&](const GUI::Action&) { + String value; + if (GUI::InputBox::show(value, window(), "Fill byte (hex):", "Fill Selection") == GUI::InputBox::ExecOK && !value.is_empty()) { + auto fill_byte = strtol(value.characters(), nullptr, 16); + m_editor->fill_selection(fill_byte); + } + })); + edit_menu.add_separator(); + edit_menu.add_action(*m_goto_decimal_offset_action); + edit_menu.add_action(*m_goto_hex_offset_action); + edit_menu.add_separator(); + edit_menu.add_action(GUI::Action::create("Copy Hex", { Mod_Ctrl, Key_C }, [&](const GUI::Action&) { + m_editor->copy_selected_hex_to_clipboard(); + })); + edit_menu.add_action(GUI::Action::create("Copy Text", { Mod_Ctrl | Mod_Shift, Key_C }, [&](const GUI::Action&) { + m_editor->copy_selected_text_to_clipboard(); + })); + edit_menu.add_separator(); + edit_menu.add_action(GUI::Action::create("Copy As C Code", { Mod_Alt | Mod_Shift, Key_C }, [&](const GUI::Action&) { + m_editor->copy_selected_hex_to_clipboard_as_c_code(); + })); + + auto& view_menu = menubar->add_menu("View"); + auto& bytes_per_row_menu = view_menu.add_submenu("Bytes per row"); + for (int i = 8; i <= 32; i += 8) { + bytes_per_row_menu.add_action(GUI::Action::create(String::number(i), [this, i](auto&) { + m_editor->set_bytes_per_row(i); + m_editor->update(); + })); + } + + auto& help_menu = menubar->add_menu("Help"); + help_menu.add_action(GUI::CommonActions::make_about_action("Hex Editor", GUI::Icon::default_icon("Hex Editor"), window())); + + GUI::Application::the()->set_menubar(move(menubar)); + + m_editor->set_focus(true); +} + +HexEditorWidget::~HexEditorWidget() +{ +} + +void HexEditorWidget::set_path(const LexicalPath& lexical_path) +{ + m_path = lexical_path.string(); + m_name = lexical_path.title(); + m_extension = lexical_path.extension(); + update_title(); +} + +void HexEditorWidget::update_title() +{ + StringBuilder builder; + builder.append(m_path); + if (m_document_dirty) + builder.append(" (*)"); + builder.append(" - Hex Editor"); + window()->set_title(builder.to_string()); +} + +void HexEditorWidget::open_file(const String& path) +{ + auto file = Core::File::construct(path); + if (!file->open(Core::IODevice::ReadOnly)) { + GUI::MessageBox::show(window(), String::formatted("Opening \"{}\" failed: {}", path, strerror(errno)), "Error", GUI::MessageBox::Type::Error); + return; + } + + m_document_dirty = false; + m_editor->set_buffer(file->read_all()); // FIXME: On really huge files, this is never going to work. Should really create a framework to fetch data from the file on-demand. + set_path(LexicalPath(path)); +} + +bool HexEditorWidget::request_close() +{ + if (!m_document_dirty) + return true; + auto result = GUI::MessageBox::show(window(), "The file has been modified. Quit without saving?", "Quit without saving?", GUI::MessageBox::Type::Warning, GUI::MessageBox::InputType::OKCancel); + return result == GUI::MessageBox::ExecOK; +} diff --git a/Userland/Applications/HexEditor/HexEditorWidget.h b/Userland/Applications/HexEditor/HexEditorWidget.h new file mode 100644 index 0000000000..55152518ab --- /dev/null +++ b/Userland/Applications/HexEditor/HexEditorWidget.h @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "HexEditor.h" +#include <AK/Function.h> +#include <AK/LexicalPath.h> +#include <LibGUI/Application.h> +#include <LibGUI/TextEditor.h> +#include <LibGUI/Widget.h> +#include <LibGUI/Window.h> + +class HexEditor; + +class HexEditorWidget final : public GUI::Widget { + C_OBJECT(HexEditorWidget) +public: + virtual ~HexEditorWidget() override; + void open_file(const String& path); + bool request_close(); + +private: + HexEditorWidget(); + void set_path(const LexicalPath& file); + void update_title(); + + RefPtr<HexEditor> m_editor; + String m_path; + String m_name; + String m_extension; + RefPtr<GUI::Action> m_new_action; + RefPtr<GUI::Action> m_open_action; + RefPtr<GUI::Action> m_save_action; + RefPtr<GUI::Action> m_save_as_action; + RefPtr<GUI::Action> m_goto_decimal_offset_action; + RefPtr<GUI::Action> m_goto_hex_offset_action; + + RefPtr<GUI::StatusBar> m_statusbar; + + bool m_document_dirty { false }; +}; diff --git a/Userland/Applications/HexEditor/main.cpp b/Userland/Applications/HexEditor/main.cpp new file mode 100644 index 0000000000..65e358726e --- /dev/null +++ b/Userland/Applications/HexEditor/main.cpp @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "HexEditorWidget.h" +#include <LibGUI/Icon.h> +#include <LibGfx/Bitmap.h> +#include <stdio.h> + +int main(int argc, char** argv) +{ + if (pledge("stdio shared_buffer accept rpath unix cpath wpath fattr thread", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio shared_buffer accept rpath cpath wpath thread", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app_icon = GUI::Icon::default_icon("app-hexeditor"); + + auto window = GUI::Window::construct(); + window->set_title("Hex Editor"); + window->resize(640, 400); + + auto& hex_editor_widget = window->set_main_widget<HexEditorWidget>(); + + window->on_close_request = [&]() -> GUI::Window::CloseRequestDecision { + if (hex_editor_widget.request_close()) + return GUI::Window::CloseRequestDecision::Close; + return GUI::Window::CloseRequestDecision::StayOpen; + }; + + window->show(); + window->set_icon(app_icon.bitmap_for_size(16)); + + if (argc >= 2) + hex_editor_widget.open_file(argv[1]); + + return app->exec(); +} diff --git a/Userland/Applications/IRCClient/CMakeLists.txt b/Userland/Applications/IRCClient/CMakeLists.txt new file mode 100644 index 0000000000..3926bbd5dc --- /dev/null +++ b/Userland/Applications/IRCClient/CMakeLists.txt @@ -0,0 +1,14 @@ +set(SOURCES + IRCAppWindow.cpp + IRCChannel.cpp + IRCChannelMemberListModel.cpp + IRCClient.cpp + IRCLogBuffer.cpp + IRCQuery.cpp + IRCWindow.cpp + IRCWindowListModel.cpp + main.cpp +) + +serenity_app(IRCClient ICON app-irc-client) +target_link_libraries(IRCClient LibWeb LibGUI) diff --git a/Userland/Applications/IRCClient/IRCAppWindow.cpp b/Userland/Applications/IRCClient/IRCAppWindow.cpp new file mode 100644 index 0000000000..8d4f931025 --- /dev/null +++ b/Userland/Applications/IRCClient/IRCAppWindow.cpp @@ -0,0 +1,375 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "IRCAppWindow.h" +#include "IRCChannel.h" +#include "IRCWindow.h" +#include "IRCWindowListModel.h" +#include <LibGUI/Action.h> +#include <LibGUI/Application.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/InputBox.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/Splitter.h> +#include <LibGUI/StackWidget.h> +#include <LibGUI/TableView.h> +#include <LibGUI/ToolBar.h> +#include <LibGUI/ToolBarContainer.h> + +static IRCAppWindow* s_the; + +IRCAppWindow& IRCAppWindow::the() +{ + return *s_the; +} + +IRCAppWindow::IRCAppWindow(String server, int port) + : m_client(IRCClient::construct(server, port)) +{ + ASSERT(!s_the); + s_the = this; + + set_icon(Gfx::Bitmap::load_from_file("/res/icons/16x16/app-irc-client.png")); + + update_title(); + resize(600, 400); + setup_actions(); + setup_menus(); + setup_widgets(); + + setup_client(); +} + +IRCAppWindow::~IRCAppWindow() +{ +} + +void IRCAppWindow::update_title() +{ + set_title(String::formatted("{}@{}:{} - IRC Client", m_client->nickname(), m_client->hostname(), m_client->port())); +} + +void IRCAppWindow::setup_client() +{ + m_client->aid_create_window = [this](void* owner, IRCWindow::Type type, const String& name) { + return create_window(owner, type, name); + }; + m_client->aid_get_active_window = [this] { + return static_cast<IRCWindow*>(m_container->active_widget()); + }; + m_client->aid_update_window_list = [this] { + m_window_list->model()->update(); + }; + m_client->on_nickname_changed = [this](const String&) { + update_title(); + }; + m_client->on_part_from_channel = [this](auto&) { + update_gui_actions(); + }; + + if (m_client->hostname().is_empty()) { + String value; + if (GUI::InputBox::show(value, this, "Enter server:", "Connect to server") == GUI::InputBox::ExecCancel) + ::exit(0); + + m_client->set_server(value, 6667); + } + update_title(); + bool success = m_client->connect(); + ASSERT(success); +} + +void IRCAppWindow::setup_actions() +{ + m_join_action = GUI::Action::create("Join channel", { Mod_Ctrl, Key_J }, Gfx::Bitmap::load_from_file("/res/icons/16x16/irc-join.png"), [&](auto&) { + String value; + if (GUI::InputBox::show(value, this, "Enter channel name:", "Join channel") == GUI::InputBox::ExecOK && !value.is_empty()) + m_client->handle_join_action(value); + }); + + m_list_channels_action = GUI::Action::create("List channels", Gfx::Bitmap::load_from_file("/res/icons/16x16/irc-list.png"), [&](auto&) { + m_client->handle_list_channels_action(); + }); + + m_part_action = GUI::Action::create("Part from channel", { Mod_Ctrl, Key_P }, Gfx::Bitmap::load_from_file("/res/icons/16x16/irc-part.png"), [this](auto&) { + auto* window = m_client->current_window(); + if (!window || window->type() != IRCWindow::Type::Channel) { + return; + } + m_client->handle_part_action(window->channel().name()); + }); + + m_whois_action = GUI::Action::create("Whois user", Gfx::Bitmap::load_from_file("/res/icons/16x16/irc-whois.png"), [&](auto&) { + String value; + if (GUI::InputBox::show(value, this, "Enter nickname:", "IRC WHOIS lookup") == GUI::InputBox::ExecOK && !value.is_empty()) + m_client->handle_whois_action(value); + }); + + m_open_query_action = GUI::Action::create("Open query", { Mod_Ctrl, Key_O }, Gfx::Bitmap::load_from_file("/res/icons/16x16/irc-open-query.png"), [&](auto&) { + String value; + if (GUI::InputBox::show(value, this, "Enter nickname:", "Open IRC query with...") == GUI::InputBox::ExecOK && !value.is_empty()) + m_client->handle_open_query_action(value); + }); + + m_close_query_action = GUI::Action::create("Close query", { Mod_Ctrl, Key_D }, Gfx::Bitmap::load_from_file("/res/icons/16x16/irc-close-query.png"), [](auto&) { + outln("FIXME: Implement close-query action"); + }); + + m_change_nick_action = GUI::Action::create("Change nickname", Gfx::Bitmap::load_from_file("/res/icons/16x16/irc-nick.png"), [this](auto&) { + String value; + if (GUI::InputBox::show(value, this, "Enter nickname:", "Change nickname") == GUI::InputBox::ExecOK && !value.is_empty()) + m_client->handle_change_nick_action(value); + }); + + m_change_topic_action = GUI::Action::create("Change topic", Gfx::Bitmap::load_from_file("/res/icons/16x16/irc-topic.png"), [this](auto&) { + auto* window = m_client->current_window(); + if (!window || window->type() != IRCWindow::Type::Channel) { + return; + } + String value; + if (GUI::InputBox::show(value, this, "Enter topic:", "Change topic") == GUI::InputBox::ExecOK && !value.is_empty()) + m_client->handle_change_topic_action(window->channel().name(), value); + }); + + m_invite_user_action = GUI::Action::create("Invite user", Gfx::Bitmap::load_from_file("/res/icons/16x16/irc-invite.png"), [this](auto&) { + auto* window = m_client->current_window(); + if (!window || window->type() != IRCWindow::Type::Channel) { + return; + } + String value; + if (GUI::InputBox::show(value, this, "Enter nick:", "Invite user") == GUI::InputBox::ExecOK && !value.is_empty()) + m_client->handle_invite_user_action(window->channel().name(), value); + }); + + m_banlist_action = GUI::Action::create("Ban list", [this](auto&) { + auto* window = m_client->current_window(); + if (!window || window->type() != IRCWindow::Type::Channel) { + return; + } + m_client->handle_banlist_action(window->channel().name()); + }); + + m_voice_user_action = GUI::Action::create("Voice user", [this](auto&) { + auto* window = m_client->current_window(); + if (!window || window->type() != IRCWindow::Type::Channel) { + return; + } + String value; + if (GUI::InputBox::show(value, this, "Enter nick:", "Voice user") == GUI::InputBox::ExecOK && !value.is_empty()) + m_client->handle_voice_user_action(window->channel().name(), value); + }); + + m_devoice_user_action = GUI::Action::create("DeVoice user", [this](auto&) { + auto* window = m_client->current_window(); + if (!window || window->type() != IRCWindow::Type::Channel) { + return; + } + String value; + if (GUI::InputBox::show(value, this, "Enter nick:", "DeVoice user") == GUI::InputBox::ExecOK && !value.is_empty()) + m_client->handle_devoice_user_action(window->channel().name(), value); + }); + + m_hop_user_action = GUI::Action::create("Hop user", [this](auto&) { + auto* window = m_client->current_window(); + if (!window || window->type() != IRCWindow::Type::Channel) { + return; + } + String value; + if (GUI::InputBox::show(value, this, "Enter nick:", "Hop user") == GUI::InputBox::ExecOK && !value.is_empty()) + m_client->handle_hop_user_action(window->channel().name(), value); + }); + + m_dehop_user_action = GUI::Action::create("DeHop user", [this](auto&) { + auto* window = m_client->current_window(); + if (!window || window->type() != IRCWindow::Type::Channel) { + return; + } + String value; + if (GUI::InputBox::show(value, this, "Enter nick:", "DeHop user") == GUI::InputBox::ExecOK && !value.is_empty()) + m_client->handle_dehop_user_action(window->channel().name(), value); + }); + + m_op_user_action = GUI::Action::create("Op user", [this](auto&) { + auto* window = m_client->current_window(); + if (!window || window->type() != IRCWindow::Type::Channel) { + return; + } + String value; + if (GUI::InputBox::show(value, this, "Enter nick:", "Op user") == GUI::InputBox::ExecOK && !value.is_empty()) + m_client->handle_op_user_action(window->channel().name(), value); + }); + + m_deop_user_action = GUI::Action::create("DeOp user", [this](auto&) { + auto* window = m_client->current_window(); + if (!window || window->type() != IRCWindow::Type::Channel) { + return; + } + String value; + if (GUI::InputBox::show(value, this, "Enter nick:", "DeOp user") == GUI::InputBox::ExecOK && !value.is_empty()) + m_client->handle_deop_user_action(window->channel().name(), value); + }); + + m_kick_user_action = GUI::Action::create("Kick user", [this](auto&) { + auto* window = m_client->current_window(); + if (!window || window->type() != IRCWindow::Type::Channel) { + return; + } + String nick_value; + if (GUI::InputBox::show(nick_value, this, "Enter nick:", "Kick user") != GUI::InputBox::ExecOK || nick_value.is_empty()) + return; + String reason_value; + if (GUI::InputBox::show(reason_value, this, "Enter reason:", "Reason") == GUI::InputBox::ExecOK) + m_client->handle_kick_user_action(window->channel().name(), nick_value, reason_value.characters()); + }); + + m_cycle_channel_action = GUI::Action::create("Cycle channel", [this](auto&) { + auto* window = m_client->current_window(); + if (!window || window->type() != IRCWindow::Type::Channel) { + return; + } + m_client->handle_cycle_channel_action(window->channel().name()); + }); +} + +void IRCAppWindow::setup_menus() +{ + auto menubar = GUI::MenuBar::construct(); + auto& app_menu = menubar->add_menu("IRC Client"); + app_menu.add_action(GUI::CommonActions::make_quit_action([](auto&) { + dbgln("Terminal: Quit menu activated!"); + GUI::Application::the()->quit(); + return; + })); + + auto& server_menu = menubar->add_menu("Server"); + server_menu.add_action(*m_change_nick_action); + server_menu.add_separator(); + server_menu.add_action(*m_join_action); + server_menu.add_action(*m_list_channels_action); + server_menu.add_separator(); + server_menu.add_action(*m_whois_action); + server_menu.add_action(*m_open_query_action); + server_menu.add_action(*m_close_query_action); + + auto& channel_menu = menubar->add_menu("Channel"); + channel_menu.add_action(*m_change_topic_action); + channel_menu.add_action(*m_invite_user_action); + channel_menu.add_action(*m_banlist_action); + + auto& channel_control_menu = channel_menu.add_submenu("Control"); + channel_control_menu.add_action(*m_voice_user_action); + channel_control_menu.add_action(*m_devoice_user_action); + channel_control_menu.add_action(*m_hop_user_action); + channel_control_menu.add_action(*m_dehop_user_action); + channel_control_menu.add_action(*m_op_user_action); + channel_control_menu.add_action(*m_deop_user_action); + channel_control_menu.add_separator(); + channel_control_menu.add_action(*m_kick_user_action); + + channel_menu.add_separator(); + channel_menu.add_action(*m_cycle_channel_action); + channel_menu.add_action(*m_part_action); + + auto& help_menu = menubar->add_menu("Help"); + help_menu.add_action(GUI::CommonActions::make_about_action("IRC Client", GUI::Icon::default_icon("app-irc-client"), this)); + + GUI::Application::the()->set_menubar(move(menubar)); +} + +void IRCAppWindow::setup_widgets() +{ + auto& widget = set_main_widget<GUI::Widget>(); + widget.set_fill_with_background_color(true); + widget.set_layout<GUI::VerticalBoxLayout>(); + widget.layout()->set_spacing(0); + + auto& toolbar_container = widget.add<GUI::ToolBarContainer>(); + auto& toolbar = toolbar_container.add<GUI::ToolBar>(); + toolbar.set_has_frame(false); + toolbar.add_action(*m_change_nick_action); + toolbar.add_separator(); + toolbar.add_action(*m_join_action); + toolbar.add_action(*m_part_action); + toolbar.add_separator(); + toolbar.add_action(*m_whois_action); + toolbar.add_action(*m_open_query_action); + toolbar.add_action(*m_close_query_action); + + auto& outer_container = widget.add<GUI::Widget>(); + outer_container.set_layout<GUI::VerticalBoxLayout>(); + outer_container.layout()->set_margins({ 2, 0, 2, 2 }); + + auto& horizontal_container = outer_container.add<GUI::HorizontalSplitter>(); + + m_window_list = horizontal_container.add<GUI::TableView>(); + m_window_list->set_column_headers_visible(false); + m_window_list->set_alternating_row_colors(false); + m_window_list->set_model(m_client->client_window_list_model()); + m_window_list->set_activates_on_selection(true); + m_window_list->set_fixed_width(100); + m_window_list->on_activation = [this](auto& index) { + set_active_window(m_client->window_at(index.row())); + }; + + m_container = horizontal_container.add<GUI::StackWidget>(); + m_container->on_active_widget_change = [this](auto*) { + update_gui_actions(); + }; + + create_window(&m_client, IRCWindow::Server, "Server"); +} + +void IRCAppWindow::set_active_window(IRCWindow& window) +{ + m_container->set_active_widget(&window); + window.clear_unread_count(); + auto index = m_window_list->model()->index(m_client->window_index(window)); + m_window_list->selection().set(index); +} + +void IRCAppWindow::update_gui_actions() +{ + auto* window = static_cast<IRCWindow*>(m_container->active_widget()); + bool is_open_channel = window && window->type() == IRCWindow::Type::Channel && window->channel().is_open(); + m_change_topic_action->set_enabled(is_open_channel); + m_invite_user_action->set_enabled(is_open_channel); + m_banlist_action->set_enabled(is_open_channel); + m_voice_user_action->set_enabled(is_open_channel); + m_devoice_user_action->set_enabled(is_open_channel); + m_hop_user_action->set_enabled(is_open_channel); + m_dehop_user_action->set_enabled(is_open_channel); + m_op_user_action->set_enabled(is_open_channel); + m_deop_user_action->set_enabled(is_open_channel); + m_kick_user_action->set_enabled(is_open_channel); + m_cycle_channel_action->set_enabled(is_open_channel); + m_part_action->set_enabled(is_open_channel); +} + +NonnullRefPtr<IRCWindow> IRCAppWindow::create_window(void* owner, IRCWindow::Type type, const String& name) +{ + return m_container->add<IRCWindow>(m_client, owner, type, name); +} diff --git a/Userland/Applications/IRCClient/IRCAppWindow.h b/Userland/Applications/IRCClient/IRCAppWindow.h new file mode 100644 index 0000000000..10f0f5a4ee --- /dev/null +++ b/Userland/Applications/IRCClient/IRCAppWindow.h @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "IRCClient.h" +#include "IRCWindow.h" +#include <LibGUI/Widget.h> +#include <LibGUI/Window.h> + +class IRCAppWindow : public GUI::Window { + C_OBJECT(IRCAppWindow); + +public: + virtual ~IRCAppWindow() override; + + static IRCAppWindow& the(); + + void set_active_window(IRCWindow&); + +private: + IRCAppWindow(String server, int port); + + void setup_client(); + void setup_actions(); + void setup_menus(); + void setup_widgets(); + void update_title(); + void update_gui_actions(); + + NonnullRefPtr<IRCWindow> create_window(void* owner, IRCWindow::Type, const String& name); + NonnullRefPtr<IRCClient> m_client; + RefPtr<GUI::StackWidget> m_container; + RefPtr<GUI::TableView> m_window_list; + RefPtr<GUI::Action> m_join_action; + RefPtr<GUI::Action> m_list_channels_action; + RefPtr<GUI::Action> m_part_action; + RefPtr<GUI::Action> m_cycle_channel_action; + RefPtr<GUI::Action> m_whois_action; + RefPtr<GUI::Action> m_open_query_action; + RefPtr<GUI::Action> m_close_query_action; + RefPtr<GUI::Action> m_change_nick_action; + RefPtr<GUI::Action> m_change_topic_action; + RefPtr<GUI::Action> m_invite_user_action; + RefPtr<GUI::Action> m_banlist_action; + RefPtr<GUI::Action> m_voice_user_action; + RefPtr<GUI::Action> m_devoice_user_action; + RefPtr<GUI::Action> m_hop_user_action; + RefPtr<GUI::Action> m_dehop_user_action; + RefPtr<GUI::Action> m_op_user_action; + RefPtr<GUI::Action> m_deop_user_action; + RefPtr<GUI::Action> m_kick_user_action; +}; diff --git a/Userland/Applications/IRCClient/IRCChannel.cpp b/Userland/Applications/IRCClient/IRCChannel.cpp new file mode 100644 index 0000000000..81d9173e31 --- /dev/null +++ b/Userland/Applications/IRCClient/IRCChannel.cpp @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "IRCChannel.h" +#include "IRCChannelMemberListModel.h" +#include "IRCClient.h" +#include <stdio.h> + +IRCChannel::IRCChannel(IRCClient& client, const String& name) + : m_client(client) + , m_name(name) + , m_log(IRCLogBuffer::create()) + , m_member_model(IRCChannelMemberListModel::create(*this)) +{ + m_window = m_client.aid_create_window(this, IRCWindow::Channel, m_name); + m_window->set_log_buffer(*m_log); +} + +IRCChannel::~IRCChannel() +{ +} + +NonnullRefPtr<IRCChannel> IRCChannel::create(IRCClient& client, const String& name) +{ + return adopt(*new IRCChannel(client, name)); +} + +void IRCChannel::add_member(const String& name, char prefix) +{ + for (auto& member : m_members) { + if (member.name == name) { + member.prefix = prefix; + return; + } + } + m_members.append({ name, prefix }); + m_member_model->update(); +} + +void IRCChannel::remove_member(const String& name) +{ + m_members.remove_first_matching([&](auto& member) { return name == member.name; }); +} + +void IRCChannel::add_message(char prefix, const String& name, const String& text, Color color) +{ + log().add_message(prefix, name, text, color); + window().did_add_message(name, text); +} + +void IRCChannel::add_message(const String& text, Color color) +{ + log().add_message(text, color); + window().did_add_message(); +} + +void IRCChannel::say(const String& text) +{ + m_client.send_privmsg(m_name, text); + add_message(' ', m_client.nickname(), text); +} + +void IRCChannel::handle_join(const String& nick, const String& hostmask) +{ + if (nick == m_client.nickname()) { + m_open = true; + return; + } + add_member(nick, (char)0); + m_member_model->update(); + if (m_client.show_join_part_messages()) + add_message(String::formatted("*** {} [{}] has joined {}", nick, hostmask, m_name), Color::MidGreen); +} + +void IRCChannel::handle_part(const String& nick, const String& hostmask) +{ + if (nick == m_client.nickname()) { + m_open = false; + m_members.clear(); + m_client.did_part_from_channel({}, *this); + } else { + remove_member(nick); + } + m_member_model->update(); + if (m_client.show_join_part_messages()) + add_message(String::formatted("*** {} [{}] has parted from {}", nick, hostmask, m_name), Color::MidGreen); +} + +void IRCChannel::handle_quit(const String& nick, const String& hostmask, const String& message) +{ + if (nick == m_client.nickname()) { + m_open = false; + m_members.clear(); + m_client.did_part_from_channel({}, *this); + } else { + remove_member(nick); + } + m_member_model->update(); + add_message(String::formatted("*** {} [{}] has quit ({})", nick, hostmask, message), Color::MidGreen); +} + +void IRCChannel::handle_topic(const String& nick, const String& topic) +{ + if (nick.is_null()) + add_message(String::formatted("*** Topic is \"{}\"", topic), Color::MidBlue); + else + add_message(String::formatted("*** {} set topic to \"{}\"", nick, topic), Color::MidBlue); +} + +void IRCChannel::notify_nick_changed(const String& old_nick, const String& new_nick) +{ + for (auto& member : m_members) { + if (member.name == old_nick) { + member.name = new_nick; + m_member_model->update(); + if (m_client.show_nick_change_messages()) + add_message(String::formatted("~ {} changed nickname to {}", old_nick, new_nick), Color::MidMagenta); + return; + } + } +} diff --git a/Userland/Applications/IRCClient/IRCChannel.h b/Userland/Applications/IRCClient/IRCChannel.h new file mode 100644 index 0000000000..1d0eb8657b --- /dev/null +++ b/Userland/Applications/IRCClient/IRCChannel.h @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "IRCLogBuffer.h" +#include <AK/RefCounted.h> +#include <AK/RefPtr.h> +#include <AK/String.h> +#include <AK/Vector.h> + +class IRCClient; +class IRCChannelMemberListModel; +class IRCWindow; + +class IRCChannel : public RefCounted<IRCChannel> { +public: + static NonnullRefPtr<IRCChannel> create(IRCClient&, const String&); + ~IRCChannel(); + + bool is_open() const { return m_open; } + void set_open(bool b) { m_open = b; } + + String name() const { return m_name; } + + void add_member(const String& name, char prefix); + void remove_member(const String& name); + + void add_message(char prefix, const String& name, const String& text, Color = Color::Black); + void add_message(const String& text, Color = Color::Black); + + void say(const String&); + + const IRCLogBuffer& log() const { return *m_log; } + IRCLogBuffer& log() { return *m_log; } + + IRCChannelMemberListModel* member_model() { return m_member_model.ptr(); } + const IRCChannelMemberListModel* member_model() const { return m_member_model.ptr(); } + + int member_count() const { return m_members.size(); } + String member_at(int i) { return m_members[i].name; } + + void handle_join(const String& nick, const String& hostmask); + void handle_part(const String& nick, const String& hostmask); + void handle_quit(const String& nick, const String& hostmask, const String& message); + void handle_topic(const String& nick, const String& topic); + + IRCWindow& window() { return *m_window; } + const IRCWindow& window() const { return *m_window; } + + String topic() const { return m_topic; } + + void notify_nick_changed(const String& old_nick, const String& new_nick); + +private: + IRCChannel(IRCClient&, const String&); + + IRCClient& m_client; + String m_name; + String m_topic; + struct Member { + String name; + char prefix { 0 }; + }; + Vector<Member> m_members; + bool m_open { false }; + + NonnullRefPtr<IRCLogBuffer> m_log; + NonnullRefPtr<IRCChannelMemberListModel> m_member_model; + IRCWindow* m_window { nullptr }; +}; diff --git a/Userland/Applications/IRCClient/IRCChannelMemberListModel.cpp b/Userland/Applications/IRCClient/IRCChannelMemberListModel.cpp new file mode 100644 index 0000000000..22cdcd710f --- /dev/null +++ b/Userland/Applications/IRCClient/IRCChannelMemberListModel.cpp @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "IRCChannelMemberListModel.h" +#include "IRCChannel.h" +#include <stdio.h> +#include <time.h> + +IRCChannelMemberListModel::IRCChannelMemberListModel(IRCChannel& channel) + : m_channel(channel) +{ +} + +IRCChannelMemberListModel::~IRCChannelMemberListModel() +{ +} + +int IRCChannelMemberListModel::row_count(const GUI::ModelIndex&) const +{ + return m_channel.member_count(); +} + +int IRCChannelMemberListModel::column_count(const GUI::ModelIndex&) const +{ + return 1; +} + +String IRCChannelMemberListModel::column_name(int column) const +{ + switch (column) { + case Column::Name: + return "Name"; + } + ASSERT_NOT_REACHED(); +} + +GUI::Variant IRCChannelMemberListModel::data(const GUI::ModelIndex& index, GUI::ModelRole role) const +{ + if (role == GUI::ModelRole::TextAlignment) + return Gfx::TextAlignment::CenterLeft; + if (role == GUI::ModelRole::Display) { + switch (index.column()) { + case Column::Name: + return m_channel.member_at(index.row()); + } + } + return {}; +} + +void IRCChannelMemberListModel::update() +{ + did_update(); +} + +String IRCChannelMemberListModel::nick_at(const GUI::ModelIndex& index) const +{ + return data(index, GUI::ModelRole::Display).to_string(); +} diff --git a/Userland/Applications/IRCClient/IRCChannelMemberListModel.h b/Userland/Applications/IRCClient/IRCChannelMemberListModel.h new file mode 100644 index 0000000000..99e4128fc6 --- /dev/null +++ b/Userland/Applications/IRCClient/IRCChannelMemberListModel.h @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/Function.h> +#include <LibGUI/Model.h> + +class IRCChannel; + +class IRCChannelMemberListModel final : public GUI::Model { +public: + enum Column { + Name + }; + static NonnullRefPtr<IRCChannelMemberListModel> create(IRCChannel& channel) { return adopt(*new IRCChannelMemberListModel(channel)); } + virtual ~IRCChannelMemberListModel() override; + + virtual int row_count(const GUI::ModelIndex&) const override; + virtual int column_count(const GUI::ModelIndex&) const override; + virtual String column_name(int column) const override; + virtual GUI::Variant data(const GUI::ModelIndex&, GUI::ModelRole) const override; + virtual void update() override; + virtual String nick_at(const GUI::ModelIndex& index) const; + +private: + explicit IRCChannelMemberListModel(IRCChannel&); + + IRCChannel& m_channel; +}; diff --git a/Userland/Applications/IRCClient/IRCClient.cpp b/Userland/Applications/IRCClient/IRCClient.cpp new file mode 100644 index 0000000000..c0da630b8f --- /dev/null +++ b/Userland/Applications/IRCClient/IRCClient.cpp @@ -0,0 +1,1188 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "IRCClient.h" +#include "IRCAppWindow.h" +#include "IRCChannel.h" +#include "IRCLogBuffer.h" +#include "IRCQuery.h" +#include "IRCWindow.h" +#include "IRCWindowListModel.h" +#include <AK/QuickSort.h> +#include <AK/StringBuilder.h> +#include <LibCore/DateTime.h> +#include <LibCore/Notifier.h> +#include <pwd.h> +#include <stdio.h> +#include <strings.h> + +#ifndef IRC_DEBUG +# define IRC_DEBUG +#endif + +enum IRCNumeric { + RPL_WELCOME = 1, + RPL_WHOISUSER = 311, + RPL_WHOISSERVER = 312, + RPL_WHOISOPERATOR = 313, + RPL_ENDOFWHO = 315, + RPL_WHOISIDLE = 317, + RPL_ENDOFWHOIS = 318, + RPL_WHOISCHANNELS = 319, + RPL_TOPIC = 332, + RPL_TOPICWHOTIME = 333, + RPL_NAMREPLY = 353, + RPL_ENDOFNAMES = 366, + RPL_BANLIST = 367, + RPL_ENDOFBANLIST = 368, + RPL_ENDOFWHOWAS = 369, + RPL_ENDOFMOTD = 376, + ERR_NOSUCHNICK = 401, + ERR_UNKNOWNCOMMAND = 421, + ERR_NICKNAMEINUSE = 433, +}; + +IRCClient::IRCClient(String server, int port) + : m_nickname("seren1ty") + , m_client_window_list_model(IRCWindowListModel::create(*this)) + , m_log(IRCLogBuffer::create()) + , m_config(Core::ConfigFile::get_for_app("IRCClient")) +{ + struct passwd* user_pw = getpwuid(getuid()); + m_socket = Core::TCPSocket::construct(this); + m_nickname = m_config->read_entry("User", "Nickname", String::formatted("{}_seren1ty", user_pw->pw_name)); + + if (server.is_empty()) { + m_hostname = m_config->read_entry("Connection", "Server", ""); + m_port = m_config->read_num_entry("Connection", "Port", 6667); + } else { + m_hostname = server; + m_port = port ? port : 6667; + } + + m_show_join_part_messages = m_config->read_bool_entry("Messaging", "ShowJoinPartMessages", 1); + m_show_nick_change_messages = m_config->read_bool_entry("Messaging", "ShowNickChangeMessages", 1); + + m_notify_on_message = m_config->read_bool_entry("Notifications", "NotifyOnMessage", 1); + m_notify_on_mention = m_config->read_bool_entry("Notifications", "NotifyOnMention", 1); + + m_ctcp_version_reply = m_config->read_entry("CTCP", "VersionReply", "IRC Client [x86] / Serenity OS"); + m_ctcp_userinfo_reply = m_config->read_entry("CTCP", "UserInfoReply", user_pw->pw_name); + m_ctcp_finger_reply = m_config->read_entry("CTCP", "FingerReply", user_pw->pw_name); +} + +IRCClient::~IRCClient() +{ +} + +void IRCClient::set_server(const String& hostname, int port) +{ + m_hostname = hostname; + m_port = port; + m_config->write_entry("Connection", "Server", hostname); + m_config->write_num_entry("Connection", "Port", port); + m_config->sync(); +} + +void IRCClient::on_socket_connected() +{ + m_notifier = Core::Notifier::construct(m_socket->fd(), Core::Notifier::Read); + m_notifier->on_ready_to_read = [this] { receive_from_server(); }; + + send_user(); + send_nick(); +} + +bool IRCClient::connect() +{ + if (m_socket->is_connected()) + ASSERT_NOT_REACHED(); + + m_socket->on_connected = [this] { on_socket_connected(); }; + + return m_socket->connect(m_hostname, m_port); +} + +void IRCClient::receive_from_server() +{ + while (m_socket->can_read_line()) { + auto line = m_socket->read_line(); + if (line.is_null()) { + if (!m_socket->is_connected()) { + outln("IRCClient: Connection closed!"); + exit(1); + } + ASSERT_NOT_REACHED(); + } + process_line(line); + } +} + +void IRCClient::process_line(const String& line) +{ + Message msg; + Vector<char, 32> prefix; + Vector<char, 32> command; + Vector<char, 256> current_parameter; + enum { + Start, + InPrefix, + InCommand, + InStartOfParameter, + InParameter, + InTrailingParameter, + } state + = Start; + + for (size_t i = 0; i < line.length(); ++i) { + char ch = line[i]; + if (ch == '\r') + continue; + if (ch == '\n') + break; + switch (state) { + case Start: + if (ch == ':') { + state = InPrefix; + continue; + } + state = InCommand; + [[fallthrough]]; + case InCommand: + if (ch == ' ') { + state = InStartOfParameter; + continue; + } + command.append(ch); + continue; + case InPrefix: + if (ch == ' ') { + state = InCommand; + continue; + } + prefix.append(ch); + continue; + case InStartOfParameter: + if (ch == ':') { + state = InTrailingParameter; + continue; + } + state = InParameter; + [[fallthrough]]; + case InParameter: + if (ch == ' ') { + if (!current_parameter.is_empty()) + msg.arguments.append(String(current_parameter.data(), current_parameter.size())); + current_parameter.clear_with_capacity(); + state = InStartOfParameter; + continue; + } + current_parameter.append(ch); + continue; + case InTrailingParameter: + current_parameter.append(ch); + continue; + } + } + if (!current_parameter.is_empty()) + msg.arguments.append(String::copy(current_parameter)); + msg.prefix = String::copy(prefix); + msg.command = String::copy(command); + handle(msg); +} + +void IRCClient::send(const String& text) +{ + if (!m_socket->send(text.bytes())) { + perror("send"); + exit(1); + } +} + +void IRCClient::send_user() +{ + send(String::formatted("USER {} 0 * :{}\r\n", m_nickname, m_nickname)); +} + +void IRCClient::send_nick() +{ + send(String::formatted("NICK {}\r\n", m_nickname)); +} + +void IRCClient::send_pong(const String& server) +{ + send(String::formatted("PONG {}\r\n", server)); + sleep(1); +} + +void IRCClient::join_channel(const String& channel_name) +{ + send(String::formatted("JOIN {}\r\n", channel_name)); +} + +void IRCClient::part_channel(const String& channel_name) +{ + send(String::formatted("PART {}\r\n", channel_name)); +} + +void IRCClient::send_whois(const String& nick) +{ + send(String::formatted("WHOIS {}\r\n", nick)); +} + +void IRCClient::handle(const Message& msg) +{ +#ifdef IRC_DEBUG + outln("IRCClient::execute: prefix='{}', command='{}', arguments={}", + msg.prefix, + msg.command, + msg.arguments.size()); + + size_t index = 0; + for (auto& arg : msg.arguments) + outln(" [{}]: {}", index++, arg); +#endif + + auto numeric = msg.command.to_uint(); + + if (numeric.has_value()) { + switch (numeric.value()) { + case RPL_WELCOME: + return handle_rpl_welcome(msg); + case RPL_WHOISCHANNELS: + return handle_rpl_whoischannels(msg); + case RPL_ENDOFWHO: + return handle_rpl_endofwho(msg); + case RPL_ENDOFWHOIS: + return handle_rpl_endofwhois(msg); + case RPL_ENDOFWHOWAS: + return handle_rpl_endofwhowas(msg); + case RPL_ENDOFMOTD: + return handle_rpl_endofmotd(msg); + case RPL_WHOISOPERATOR: + return handle_rpl_whoisoperator(msg); + case RPL_WHOISSERVER: + return handle_rpl_whoisserver(msg); + case RPL_WHOISUSER: + return handle_rpl_whoisuser(msg); + case RPL_WHOISIDLE: + return handle_rpl_whoisidle(msg); + case RPL_TOPICWHOTIME: + return handle_rpl_topicwhotime(msg); + case RPL_TOPIC: + return handle_rpl_topic(msg); + case RPL_NAMREPLY: + return handle_rpl_namreply(msg); + case RPL_ENDOFNAMES: + return handle_rpl_endofnames(msg); + case RPL_BANLIST: + return handle_rpl_banlist(msg); + case RPL_ENDOFBANLIST: + return handle_rpl_endofbanlist(msg); + case ERR_NOSUCHNICK: + return handle_err_nosuchnick(msg); + case ERR_UNKNOWNCOMMAND: + return handle_err_unknowncommand(msg); + case ERR_NICKNAMEINUSE: + return handle_err_nicknameinuse(msg); + } + } + + if (msg.command == "PING") + return handle_ping(msg); + + if (msg.command == "JOIN") + return handle_join(msg); + + if (msg.command == "PART") + return handle_part(msg); + + if (msg.command == "QUIT") + return handle_quit(msg); + + if (msg.command == "TOPIC") + return handle_topic(msg); + + if (msg.command == "PRIVMSG") + return handle_privmsg_or_notice(msg, PrivmsgOrNotice::Privmsg); + + if (msg.command == "NOTICE") + return handle_privmsg_or_notice(msg, PrivmsgOrNotice::Notice); + + if (msg.command == "NICK") + return handle_nick(msg); + + if (msg.arguments.size() >= 2) + add_server_message(String::formatted("[{}] {}", msg.command, msg.arguments[1])); +} + +void IRCClient::add_server_message(const String& text, Color color) +{ + m_log->add_message(0, "", text, color); + m_server_subwindow->did_add_message(); +} + +void IRCClient::send_topic(const String& channel_name, const String& text) +{ + send(String::formatted("TOPIC {} :{}\r\n", channel_name, text)); +} + +void IRCClient::send_invite(const String& channel_name, const String& nick) +{ + send(String::formatted("INVITE {} {}\r\n", nick, channel_name)); +} + +void IRCClient::send_banlist(const String& channel_name) +{ + send(String::formatted("MODE {} +b\r\n", channel_name)); +} + +void IRCClient::send_voice_user(const String& channel_name, const String& nick) +{ + send(String::formatted("MODE {} +v {}\r\n", channel_name, nick)); +} + +void IRCClient::send_devoice_user(const String& channel_name, const String& nick) +{ + send(String::formatted("MODE {} -v {}\r\n", channel_name, nick)); +} + +void IRCClient::send_hop_user(const String& channel_name, const String& nick) +{ + send(String::formatted("MODE {} +h {}\r\n", channel_name, nick)); +} + +void IRCClient::send_dehop_user(const String& channel_name, const String& nick) +{ + send(String::formatted("MODE {} -h {}\r\n", channel_name, nick)); +} + +void IRCClient::send_op_user(const String& channel_name, const String& nick) +{ + send(String::formatted("MODE {} +o {}\r\n", channel_name, nick)); +} + +void IRCClient::send_deop_user(const String& channel_name, const String& nick) +{ + send(String::formatted("MODE {} -o {}\r\n", channel_name, nick)); +} + +void IRCClient::send_kick(const String& channel_name, const String& nick, const String& comment) +{ + send(String::formatted("KICK {} {} :{}\r\n", channel_name, nick, comment)); +} + +void IRCClient::send_list() +{ + send("LIST\r\n"); +} + +void IRCClient::send_privmsg(const String& target, const String& text) +{ + send(String::formatted("PRIVMSG {} :{}\r\n", target, text)); +} + +void IRCClient::send_notice(const String& target, const String& text) +{ + send(String::formatted("NOTICE {} :{}\r\n", target, text)); +} + +void IRCClient::handle_user_input_in_channel(const String& channel_name, const String& input) +{ + if (input.is_empty()) + return; + if (input[0] == '/') + return handle_user_command(input); + ensure_channel(channel_name).say(input); +} + +void IRCClient::handle_user_input_in_query(const String& query_name, const String& input) +{ + if (input.is_empty()) + return; + if (input[0] == '/') + return handle_user_command(input); + ensure_query(query_name).say(input); +} + +void IRCClient::handle_user_input_in_server(const String& input) +{ + if (input.is_empty()) + return; + if (input[0] == '/') + return handle_user_command(input); +} + +String IRCClient::nick_without_prefix(const String& nick) +{ + assert(!nick.is_empty()); + if (IRCClient::is_nick_prefix(nick[0])) + return nick.substring(1, nick.length() - 1); + return nick; +} + +bool IRCClient::is_nick_prefix(char ch) +{ + switch (ch) { + case '@': + case '+': + case '~': + case '&': + case '%': + return true; + } + return false; +} + +bool IRCClient::is_channel_prefix(char ch) +{ + switch (ch) { + case '&': + case '#': + case '+': + case '!': + return true; + } + return false; +} + +static bool has_ctcp_payload(const StringView& string) +{ + return string.length() >= 2 && string[0] == 0x01 && string[string.length() - 1] == 0x01; +} + +void IRCClient::handle_privmsg_or_notice(const Message& msg, PrivmsgOrNotice type) +{ + if (msg.arguments.size() < 2) + return; + if (msg.prefix.is_empty()) + return; + auto parts = msg.prefix.split('!'); + auto sender_nick = parts[0]; + auto target = msg.arguments[0]; + + bool is_ctcp = has_ctcp_payload(msg.arguments[1]); + +#ifdef IRC_DEBUG + outln("handle_privmsg_or_notice: type='{}'{}, sender_nick='{}', target='{}'", + type == PrivmsgOrNotice::Privmsg ? "privmsg" : "notice", + is_ctcp ? " (ctcp)" : "", + sender_nick, + target); +#endif + + if (sender_nick.is_empty()) + return; + + char sender_prefix = 0; + if (is_nick_prefix(sender_nick[0])) { + sender_prefix = sender_nick[0]; + sender_nick = sender_nick.substring(1, sender_nick.length() - 1); + } + + String message_text = msg.arguments[1]; + auto message_color = Color::Black; + + bool insert_as_raw_message = false; + + if (is_ctcp) { + auto ctcp_payload = msg.arguments[1].substring_view(1, msg.arguments[1].length() - 2); + if (type == PrivmsgOrNotice::Privmsg) + handle_ctcp_request(sender_nick, ctcp_payload); + else + handle_ctcp_response(sender_nick, ctcp_payload); + + if (ctcp_payload.starts_with("ACTION")) { + insert_as_raw_message = true; + message_text = String::formatted("* {}{}", sender_nick, ctcp_payload.substring_view(6, ctcp_payload.length() - 6)); + message_color = Color::Magenta; + } else { + message_text = String::formatted("(CTCP) {}", ctcp_payload); + message_color = Color::Blue; + } + } + + { + auto it = m_channels.find(target); + if (it != m_channels.end()) { + if (insert_as_raw_message) + (*it).value->add_message(message_text, message_color); + else + (*it).value->add_message(sender_prefix, sender_nick, message_text, message_color); + return; + } + } + + // For NOTICE or CTCP messages, only put them in query if one already exists. + // Otherwise, put them in the server window. This seems to match other clients. + IRCQuery* query = nullptr; + if (is_ctcp || type == PrivmsgOrNotice::Notice) { + query = query_with_name(sender_nick); + } else { + query = &ensure_query(sender_nick); + } + if (query) { + if (insert_as_raw_message) + query->add_message(message_text, message_color); + else + query->add_message(sender_prefix, sender_nick, message_text, message_color); + } else { + add_server_message(String::formatted("<{}> {}", sender_nick, message_text), message_color); + } +} + +IRCQuery* IRCClient::query_with_name(const String& name) +{ + return const_cast<IRCQuery*>(m_queries.get(name).value_or(nullptr)); +} + +IRCQuery& IRCClient::ensure_query(const String& name) +{ + auto it = m_queries.find(name); + if (it != m_queries.end()) + return *(*it).value; + auto query = IRCQuery::create(*this, name); + auto& query_reference = *query; + m_queries.set(name, query); + return query_reference; +} + +IRCChannel& IRCClient::ensure_channel(const String& name) +{ + auto it = m_channels.find(name); + if (it != m_channels.end()) + return *(*it).value; + auto channel = IRCChannel::create(*this, name); + auto& channel_reference = *channel; + m_channels.set(name, channel); + return channel_reference; +} + +void IRCClient::handle_ping(const Message& msg) +{ + if (msg.arguments.size() < 1) + return; + m_log->add_message(0, "", "Ping? Pong!"); + send_pong(msg.arguments[0]); +} + +void IRCClient::handle_join(const Message& msg) +{ + if (msg.arguments.size() != 1) + return; + auto prefix_parts = msg.prefix.split('!'); + if (prefix_parts.size() < 1) + return; + auto nick = prefix_parts[0]; + auto& channel_name = msg.arguments[0]; + ensure_channel(channel_name).handle_join(nick, msg.prefix); +} + +void IRCClient::handle_part(const Message& msg) +{ + if (msg.arguments.size() < 1) + return; + auto prefix_parts = msg.prefix.split('!'); + if (prefix_parts.size() < 1) + return; + auto nick = prefix_parts[0]; + auto& channel_name = msg.arguments[0]; + ensure_channel(channel_name).handle_part(nick, msg.prefix); +} + +void IRCClient::handle_quit(const Message& msg) +{ + if (msg.arguments.size() < 1) + return; + auto prefix_parts = msg.prefix.split('!'); + if (prefix_parts.size() < 1) + return; + auto nick = prefix_parts[0]; + auto& message = msg.arguments[0]; + for (auto& it : m_channels) { + it.value->handle_quit(nick, msg.prefix, message); + } +} + +void IRCClient::handle_nick(const Message& msg) +{ + auto prefix_parts = msg.prefix.split('!'); + if (prefix_parts.size() < 1) + return; + auto old_nick = prefix_parts[0]; + if (msg.arguments.size() != 1) + return; + auto& new_nick = msg.arguments[0]; + if (old_nick == m_nickname) + m_nickname = new_nick; + if (m_show_nick_change_messages) + add_server_message(String::formatted("~ {} changed nickname to {}", old_nick, new_nick)); + if (on_nickname_changed) + on_nickname_changed(new_nick); + for (auto& it : m_channels) { + it.value->notify_nick_changed(old_nick, new_nick); + } +} + +void IRCClient::handle_topic(const Message& msg) +{ + if (msg.arguments.size() != 2) + return; + auto prefix_parts = msg.prefix.split('!'); + if (prefix_parts.size() < 1) + return; + auto nick = prefix_parts[0]; + auto& channel_name = msg.arguments[0]; + ensure_channel(channel_name).handle_topic(nick, msg.arguments[1]); +} + +void IRCClient::handle_rpl_welcome(const Message& msg) +{ + if (msg.arguments.size() < 2) + return; + auto& welcome_message = msg.arguments[1]; + add_server_message(welcome_message); + + auto channel_str = m_config->read_entry("Connection", "AutoJoinChannels", ""); + if (channel_str.is_empty()) + return; + dbgln("IRCClient: Channels to autojoin: {}", channel_str); + auto channels = channel_str.split(','); + for (auto& channel : channels) { + join_channel(channel); + dbgln("IRCClient: Auto joining channel: {}", channel); + } +} + +void IRCClient::handle_rpl_topic(const Message& msg) +{ + if (msg.arguments.size() < 3) + return; + auto& channel_name = msg.arguments[1]; + auto& topic = msg.arguments[2]; + ensure_channel(channel_name).handle_topic({}, topic); +} + +void IRCClient::handle_rpl_namreply(const Message& msg) +{ + if (msg.arguments.size() < 4) + return; + auto& channel_name = msg.arguments[2]; + auto& channel = ensure_channel(channel_name); + auto members = msg.arguments[3].split(' '); + + quick_sort(members, [](auto& a, auto& b) { + return strcasecmp(a.characters(), b.characters()) < 0; + }); + + for (auto& member : members) { + if (member.is_empty()) + continue; + char prefix = 0; + if (is_nick_prefix(member[0])) + prefix = member[0]; + channel.add_member(member, prefix); + } +} + +void IRCClient::handle_rpl_endofnames(const Message&) +{ + add_server_message("// End of NAMES"); +} + +void IRCClient::handle_rpl_banlist(const Message& msg) +{ + if (msg.arguments.size() < 5) + return; + auto& channel = msg.arguments[1]; + auto& mask = msg.arguments[2]; + auto& user = msg.arguments[3]; + auto& datestamp = msg.arguments[4]; + add_server_message(String::formatted("* {}: {} on {} by {}", channel, mask, datestamp, user)); +} + +void IRCClient::handle_rpl_endofbanlist(const Message&) +{ + add_server_message("// End of BANLIST"); +} + +void IRCClient::handle_rpl_endofwho(const Message&) +{ + add_server_message("// End of WHO"); +} + +void IRCClient::handle_rpl_endofwhois(const Message&) +{ + add_server_message("// End of WHOIS"); +} + +void IRCClient::handle_rpl_endofwhowas(const Message&) +{ + add_server_message("// End of WHOWAS"); +} + +void IRCClient::handle_rpl_endofmotd(const Message&) +{ + add_server_message("// End of MOTD"); +} + +void IRCClient::handle_rpl_whoisoperator(const Message& msg) +{ + if (msg.arguments.size() < 2) + return; + auto& nick = msg.arguments[1]; + add_server_message(String::formatted("* {} is an IRC operator", nick)); +} + +void IRCClient::handle_rpl_whoisserver(const Message& msg) +{ + if (msg.arguments.size() < 3) + return; + auto& nick = msg.arguments[1]; + auto& server = msg.arguments[2]; + add_server_message(String::formatted("* {} is using server {}", nick, server)); +} + +void IRCClient::handle_rpl_whoisuser(const Message& msg) +{ + if (msg.arguments.size() < 6) + return; + auto& nick = msg.arguments[1]; + auto& username = msg.arguments[2]; + auto& host = msg.arguments[3]; + [[maybe_unused]] auto& asterisk = msg.arguments[4]; + auto& realname = msg.arguments[5]; + add_server_message(String::formatted("* {} is {}@{}, real name: {}", nick, username, host, realname)); +} + +void IRCClient::handle_rpl_whoisidle(const Message& msg) +{ + if (msg.arguments.size() < 3) + return; + auto& nick = msg.arguments[1]; + auto& secs = msg.arguments[2]; + add_server_message(String::formatted("* {} is {} seconds idle", nick, secs)); +} + +void IRCClient::handle_rpl_whoischannels(const Message& msg) +{ + if (msg.arguments.size() < 3) + return; + auto& nick = msg.arguments[1]; + auto& channel_list = msg.arguments[2]; + add_server_message(String::formatted("* {} is in channels {}", nick, channel_list)); +} + +void IRCClient::handle_rpl_topicwhotime(const Message& msg) +{ + if (msg.arguments.size() < 4) + return; + auto& channel_name = msg.arguments[1]; + auto& nick = msg.arguments[2]; + auto setat = msg.arguments[3]; + auto setat_time = setat.to_uint(); + if (setat_time.has_value()) + setat = Core::DateTime::from_timestamp(setat_time.value()).to_string(); + ensure_channel(channel_name).add_message(String::formatted("*** (set by {} at {})", nick, setat), Color::Blue); +} + +void IRCClient::handle_err_nosuchnick(const Message& msg) +{ + if (msg.arguments.size() < 3) + return; + auto& nick = msg.arguments[1]; + auto& message = msg.arguments[2]; + add_server_message(String::formatted("* {} :{}", nick, message)); +} + +void IRCClient::handle_err_unknowncommand(const Message& msg) +{ + if (msg.arguments.size() < 2) + return; + auto& cmd = msg.arguments[1]; + add_server_message(String::formatted("* Unknown command: {}", cmd)); +} + +void IRCClient::handle_err_nicknameinuse(const Message& msg) +{ + if (msg.arguments.size() < 2) + return; + auto& nick = msg.arguments[1]; + add_server_message(String::formatted("* {} :Nickname in use", nick)); +} + +void IRCClient::register_subwindow(IRCWindow& subwindow) +{ + if (subwindow.type() == IRCWindow::Server) { + m_server_subwindow = &subwindow; + subwindow.set_log_buffer(*m_log); + } + m_windows.append(&subwindow); + m_client_window_list_model->update(); +} + +void IRCClient::unregister_subwindow(IRCWindow& subwindow) +{ + if (subwindow.type() == IRCWindow::Server) { + m_server_subwindow = &subwindow; + } + for (size_t i = 0; i < m_windows.size(); ++i) { + if (m_windows.at(i) == &subwindow) { + m_windows.remove(i); + break; + } + } + m_client_window_list_model->update(); +} + +void IRCClient::handle_user_command(const String& input) +{ + auto parts = input.split_view(' '); + if (parts.is_empty()) + return; + auto command = String(parts[0]).to_uppercase(); + if (command == "/RAW") { + if (parts.size() <= 1) + return; + int command_length = command.length() + 1; + StringView raw_message = input.view().substring_view(command_length, input.view().length() - command_length); + send(String::formatted("{}\r\n", raw_message)); + return; + } + if (command == "/NICK") { + if (parts.size() >= 2) + change_nick(parts[1]); + return; + } + if (command == "/JOIN") { + if (parts.size() >= 2) + join_channel(parts[1]); + return; + } + if (command == "/PART") { + if (parts.size() >= 2) { + auto channel = parts[1]; + part_channel(channel); + } else { + auto* window = current_window(); + if (!window || window->type() != IRCWindow::Type::Channel) + return; + auto channel = window->channel().name(); + join_channel(channel); + } + return; + } + if (command == "/CYCLE") { + if (parts.size() >= 2) { + auto channel = parts[1]; + part_channel(channel); + join_channel(channel); + } else { + auto* window = current_window(); + if (!window || window->type() != IRCWindow::Type::Channel) + return; + auto channel = window->channel().name(); + part_channel(channel); + join_channel(channel); + } + return; + } + if (command == "/BANLIST") { + if (parts.size() >= 2) { + auto channel = parts[1]; + send_banlist(channel); + } else { + auto* window = current_window(); + if (!window || window->type() != IRCWindow::Type::Channel) + return; + auto channel = window->channel().name(); + send_banlist(channel); + } + return; + } + if (command == "/ME") { + if (parts.size() < 2) + return; + + auto* window = current_window(); + if (!window) + return; + + auto emote = input.view().substring_view_starting_after_substring(parts[0]); + auto action_string = String::formatted("ACTION{}", emote); + String peer; + if (window->type() == IRCWindow::Type::Channel) { + peer = window->channel().name(); + window->channel().add_message(String::formatted("* {}{}", m_nickname, emote), Gfx::Color::Magenta); + } else if (window->type() == IRCWindow::Type::Query) { + peer = window->query().name(); + window->query().add_message(String::formatted("* {}{}", m_nickname, emote), Gfx::Color::Magenta); + } else { + return; + } + + send_ctcp_request(peer, action_string); + return; + } + if (command == "/TOPIC") { + if (parts.size() < 2) + return; + if (parts[1].is_empty()) + return; + + if (is_channel_prefix(parts[1][0])) { + if (parts.size() < 3) + return; + auto channel = parts[1]; + auto topic = input.view().substring_view_starting_after_substring(channel); + send_topic(channel, topic); + } else { + auto* window = current_window(); + if (!window || window->type() != IRCWindow::Type::Channel) + return; + auto channel = window->channel().name(); + auto topic = input.view().substring_view_starting_after_substring(parts[0]); + send_topic(channel, topic); + } + return; + } + if (command == "/KICK") { + if (parts.size() < 2) + return; + if (parts[1].is_empty()) + return; + + if (is_channel_prefix(parts[1][0])) { + if (parts.size() < 3) + return; + auto channel = parts[1]; + auto nick = parts[2]; + auto reason = input.view().substring_view_starting_after_substring(nick); + send_kick(channel, nick, reason); + } else { + auto* window = current_window(); + if (!window || window->type() != IRCWindow::Type::Channel) + return; + auto channel = window->channel().name(); + auto nick = parts[1]; + auto reason = input.view().substring_view_starting_after_substring(nick); + send_kick(channel, nick, reason); + } + return; + } + if (command == "/LIST") { + send_list(); + return; + } + if (command == "/QUERY") { + if (parts.size() >= 2) { + auto& query = ensure_query(parts[1]); + IRCAppWindow::the().set_active_window(query.window()); + } + return; + } + if (command == "/MSG") { + if (parts.size() < 3) + return; + auto nick = parts[1]; + auto& query = ensure_query(nick); + IRCAppWindow::the().set_active_window(query.window()); + query.say(input.view().substring_view_starting_after_substring(nick)); + return; + } + if (command == "/WHOIS") { + if (parts.size() >= 2) + send_whois(parts[1]); + return; + } +} + +void IRCClient::change_nick(const String& nick) +{ + send(String::formatted("NICK {}\r\n", nick)); +} + +void IRCClient::handle_list_channels_action() +{ + send_list(); +} + +void IRCClient::handle_whois_action(const String& nick) +{ + send_whois(nick); +} + +void IRCClient::handle_ctcp_user_action(const String& nick, const String& message) +{ + send_ctcp_request(nick, message); +} + +void IRCClient::handle_open_query_action(const String& nick) +{ + ensure_query(nick); +} + +void IRCClient::handle_change_nick_action(const String& nick) +{ + change_nick(nick); +} + +void IRCClient::handle_change_topic_action(const String& channel, const String& topic) +{ + send_topic(channel, topic); +} + +void IRCClient::handle_invite_user_action(const String& channel, const String& nick) +{ + send_invite(channel, nick); +} + +void IRCClient::handle_banlist_action(const String& channel) +{ + send_banlist(channel); +} + +void IRCClient::handle_voice_user_action(const String& channel, const String& nick) +{ + send_voice_user(channel, nick); +} + +void IRCClient::handle_devoice_user_action(const String& channel, const String& nick) +{ + send_devoice_user(channel, nick); +} + +void IRCClient::handle_hop_user_action(const String& channel, const String& nick) +{ + send_hop_user(channel, nick); +} + +void IRCClient::handle_dehop_user_action(const String& channel, const String& nick) +{ + send_dehop_user(channel, nick); +} + +void IRCClient::handle_op_user_action(const String& channel, const String& nick) +{ + send_op_user(channel, nick); +} + +void IRCClient::handle_deop_user_action(const String& channel, const String& nick) +{ + send_deop_user(channel, nick); +} + +void IRCClient::handle_kick_user_action(const String& channel, const String& nick, const String& message) +{ + send_kick(channel, nick, message); +} + +void IRCClient::handle_close_query_action(const String& nick) +{ + m_queries.remove(nick); + m_client_window_list_model->update(); +} + +void IRCClient::handle_join_action(const String& channel) +{ + join_channel(channel); +} + +void IRCClient::handle_part_action(const String& channel) +{ + part_channel(channel); +} + +void IRCClient::handle_cycle_channel_action(const String& channel) +{ + part_channel(channel); + join_channel(channel); +} + +void IRCClient::did_part_from_channel(Badge<IRCChannel>, IRCChannel& channel) +{ + if (on_part_from_channel) + on_part_from_channel(channel); +} + +void IRCClient::send_ctcp_response(const StringView& peer, const StringView& payload) +{ + StringBuilder builder; + builder.append(0x01); + builder.append(payload); + builder.append(0x01); + auto message = builder.to_string(); + send_notice(peer, message); +} + +void IRCClient::send_ctcp_request(const StringView& peer, const StringView& payload) +{ + StringBuilder builder; + builder.append(0x01); + builder.append(payload); + builder.append(0x01); + auto message = builder.to_string(); + send_privmsg(peer, message); +} + +void IRCClient::handle_ctcp_request(const StringView& peer, const StringView& payload) +{ + dbgln("handle_ctcp_request: {}", payload); + + if (payload == "VERSION") { + auto version = ctcp_version_reply(); + if (version.is_empty()) + return; + send_ctcp_response(peer, String::formatted("VERSION {}", version)); + return; + } + + if (payload == "USERINFO") { + auto userinfo = ctcp_userinfo_reply(); + if (userinfo.is_empty()) + return; + send_ctcp_response(peer, String::formatted("USERINFO {}", userinfo)); + return; + } + + if (payload == "FINGER") { + auto finger = ctcp_finger_reply(); + if (finger.is_empty()) + return; + send_ctcp_response(peer, String::formatted("FINGER {}", finger)); + return; + } + + if (payload.starts_with("PING")) { + send_ctcp_response(peer, payload); + return; + } +} + +void IRCClient::handle_ctcp_response(const StringView& peer, const StringView& payload) +{ + dbgln("handle_ctcp_response({}): {}", peer, payload); +} diff --git a/Userland/Applications/IRCClient/IRCClient.h b/Userland/Applications/IRCClient/IRCClient.h new file mode 100644 index 0000000000..06f7e59289 --- /dev/null +++ b/Userland/Applications/IRCClient/IRCClient.h @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "IRCLogBuffer.h" +#include "IRCWindow.h" +#include <AK/CircularQueue.h> +#include <AK/Function.h> +#include <AK/HashMap.h> +#include <AK/String.h> +#include <LibCore/ConfigFile.h> +#include <LibCore/TCPSocket.h> + +class IRCChannel; +class IRCQuery; +class IRCWindowListModel; + +class IRCClient final : public Core::Object { + C_OBJECT(IRCClient) + friend class IRCChannel; + friend class IRCQuery; + +public: + virtual ~IRCClient() override; + + void set_server(const String& hostname, int port = 6667); + + bool connect(); + + String hostname() const { return m_hostname; } + int port() const { return m_port; } + + String nickname() const { return m_nickname; } + + String ctcp_version_reply() const { return m_ctcp_version_reply; } + String ctcp_userinfo_reply() const { return m_ctcp_userinfo_reply; } + String ctcp_finger_reply() const { return m_ctcp_finger_reply; } + + bool show_join_part_messages() const { return m_show_join_part_messages; } + bool show_nick_change_messages() const { return m_show_nick_change_messages; } + + bool notify_on_message() const { return m_notify_on_message; } + bool notify_on_mention() const { return m_notify_on_mention; } + + void join_channel(const String&); + void part_channel(const String&); + void change_nick(const String&); + + static bool is_nick_prefix(char); + static bool is_channel_prefix(char); + String nick_without_prefix(const String& nick); + + IRCWindow* current_window() { return aid_get_active_window(); } + const IRCWindow* current_window() const { return aid_get_active_window(); } + + Function<void()> on_disconnect; + Function<void()> on_server_message; + Function<void(const String&)> on_nickname_changed; + Function<void(IRCChannel&)> on_part_from_channel; + + Function<NonnullRefPtr<IRCWindow>(void*, IRCWindow::Type, const String&)> aid_create_window; + Function<IRCWindow*()> aid_get_active_window; + Function<void()> aid_update_window_list; + + void register_subwindow(IRCWindow&); + void unregister_subwindow(IRCWindow&); + + IRCWindowListModel* client_window_list_model() { return m_client_window_list_model.ptr(); } + const IRCWindowListModel* client_window_list_model() const { return m_client_window_list_model.ptr(); } + + int window_count() const { return m_windows.size(); } + const IRCWindow& window_at(int index) const { return *m_windows.at(index); } + IRCWindow& window_at(int index) { return *m_windows.at(index); } + + size_t window_index(const IRCWindow& window) const + { + for (size_t i = 0; i < m_windows.size(); ++i) { + if (m_windows[i] == &window) + return i; + } + ASSERT_NOT_REACHED(); + } + + void did_part_from_channel(Badge<IRCChannel>, IRCChannel&); + + void handle_user_input_in_channel(const String& channel_name, const String&); + void handle_user_input_in_query(const String& query_name, const String&); + void handle_user_input_in_server(const String&); + + void handle_list_channels_action(); + void handle_whois_action(const String& nick); + void handle_ctcp_user_action(const String& nick, const String& message); + void handle_open_query_action(const String&); + void handle_close_query_action(const String&); + void handle_join_action(const String& channel_name); + void handle_part_action(const String& channel_name); + void handle_cycle_channel_action(const String& channel_name); + void handle_change_nick_action(const String& nick); + void handle_change_topic_action(const String& channel_name, const String&); + void handle_invite_user_action(const String& channel_name, const String& nick); + void handle_banlist_action(const String& channel_name); + void handle_voice_user_action(const String& channel_name, const String& nick); + void handle_devoice_user_action(const String& channel_name, const String& nick); + void handle_hop_user_action(const String& channel_name, const String& nick); + void handle_dehop_user_action(const String& channel_name, const String& nick); + void handle_op_user_action(const String& channel_name, const String& nick); + void handle_deop_user_action(const String& channel_name, const String& nick); + void handle_kick_user_action(const String& channel_name, const String& nick, const String&); + + IRCQuery* query_with_name(const String&); + IRCQuery& ensure_query(const String& name); + IRCChannel& ensure_channel(const String& name); + + void add_server_message(const String&, Color = Color::Black); + +private: + IRCClient(String server, int port); + + struct Message { + String prefix; + String command; + Vector<String> arguments; + }; + + enum class PrivmsgOrNotice { + Privmsg, + Notice, + }; + + void receive_from_server(); + void send(const String&); + void send_user(); + void send_nick(); + void send_pong(const String& server); + void send_privmsg(const String& target, const String&); + void send_notice(const String& target, const String&); + void send_topic(const String& channel_name, const String&); + void send_invite(const String& channel_name, const String& nick); + void send_banlist(const String& channel_name); + void send_voice_user(const String& channel_name, const String& nick); + void send_devoice_user(const String& channel_name, const String& nick); + void send_hop_user(const String& channel_name, const String& nick); + void send_dehop_user(const String& channel_name, const String& nick); + void send_op_user(const String& channel_name, const String& nick); + void send_deop_user(const String& channel_name, const String& nick); + void send_kick(const String& channel_name, const String& nick, const String&); + void send_list(); + void send_whois(const String&); + void process_line(const String&); + void handle_join(const Message&); + void handle_part(const Message&); + void handle_quit(const Message&); + void handle_ping(const Message&); + void handle_topic(const Message&); + void handle_rpl_welcome(const Message&); + void handle_rpl_topic(const Message&); + void handle_rpl_whoisuser(const Message&); + void handle_rpl_whoisserver(const Message&); + void handle_rpl_whoisoperator(const Message&); + void handle_rpl_whoisidle(const Message&); + void handle_rpl_endofwho(const Message&); + void handle_rpl_endofwhois(const Message&); + void handle_rpl_endofwhowas(const Message&); + void handle_rpl_endofmotd(const Message&); + void handle_rpl_whoischannels(const Message&); + void handle_rpl_topicwhotime(const Message&); + void handle_rpl_endofnames(const Message&); + void handle_rpl_endofbanlist(const Message&); + void handle_rpl_namreply(const Message&); + void handle_rpl_banlist(const Message&); + void handle_err_nosuchnick(const Message&); + void handle_err_unknowncommand(const Message&); + void handle_err_nicknameinuse(const Message&); + void handle_privmsg_or_notice(const Message&, PrivmsgOrNotice); + void handle_nick(const Message&); + void handle(const Message&); + void handle_user_command(const String&); + void handle_ctcp_request(const StringView& peer, const StringView& payload); + void handle_ctcp_response(const StringView& peer, const StringView& payload); + void send_ctcp_request(const StringView& peer, const StringView& payload); + void send_ctcp_response(const StringView& peer, const StringView& payload); + + void on_socket_connected(); + + String m_hostname; + int m_port { 6667 }; + + RefPtr<Core::TCPSocket> m_socket; + + String m_nickname; + RefPtr<Core::Notifier> m_notifier; + HashMap<String, RefPtr<IRCChannel>, CaseInsensitiveStringTraits> m_channels; + HashMap<String, RefPtr<IRCQuery>, CaseInsensitiveStringTraits> m_queries; + + bool m_show_join_part_messages { 1 }; + bool m_show_nick_change_messages { 1 }; + + bool m_notify_on_message { 1 }; + bool m_notify_on_mention { 1 }; + + String m_ctcp_version_reply; + String m_ctcp_userinfo_reply; + String m_ctcp_finger_reply; + + Vector<IRCWindow*> m_windows; + + IRCWindow* m_server_subwindow { nullptr }; + + NonnullRefPtr<IRCWindowListModel> m_client_window_list_model; + NonnullRefPtr<IRCLogBuffer> m_log; + NonnullRefPtr<Core::ConfigFile> m_config; +}; diff --git a/Userland/Applications/IRCClient/IRCLogBuffer.cpp b/Userland/Applications/IRCClient/IRCLogBuffer.cpp new file mode 100644 index 0000000000..b292e0a4ee --- /dev/null +++ b/Userland/Applications/IRCClient/IRCLogBuffer.cpp @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "IRCLogBuffer.h" +#include <AK/StringBuilder.h> +#include <LibWeb/DOM/DocumentFragment.h> +#include <LibWeb/DOM/DocumentType.h> +#include <LibWeb/DOM/ElementFactory.h> +#include <LibWeb/DOM/Text.h> +#include <LibWeb/HTML/HTMLBodyElement.h> +#include <time.h> + +NonnullRefPtr<IRCLogBuffer> IRCLogBuffer::create() +{ + return adopt(*new IRCLogBuffer); +} + +IRCLogBuffer::IRCLogBuffer() +{ + m_document = Web::DOM::Document::create(); + m_document->append_child(adopt(*new Web::DOM::DocumentType(document()))); + auto html_element = m_document->create_element("html"); + m_document->append_child(html_element); + auto head_element = m_document->create_element("head"); + html_element->append_child(head_element); + auto style_element = m_document->create_element("style"); + style_element->append_child(adopt(*new Web::DOM::Text(document(), "div { font-family: Csilla; font-weight: lighter; }"))); + head_element->append_child(style_element); + auto body_element = m_document->create_element("body"); + html_element->append_child(body_element); + m_container_element = body_element; +} + +IRCLogBuffer::~IRCLogBuffer() +{ +} + +static String timestamp_string() +{ + auto now = time(nullptr); + auto* tm = localtime(&now); + return String::formatted("{:02}:{:02}:{:02} ", tm->tm_hour, tm->tm_min, tm->tm_sec); +} + +void IRCLogBuffer::add_message(char prefix, const String& name, const String& text, Color color) +{ + auto nick_string = String::formatted("<{}{}> ", prefix ? prefix : ' ', name.characters()); + auto html = String::formatted( + "<span>{}</span>" + "<b>{}</b>" + "<span>{}</span>", + timestamp_string(), + escape_html_entities(nick_string), + escape_html_entities(text)); + + auto wrapper = m_document->create_element(Web::HTML::TagNames::div); + wrapper->set_attribute(Web::HTML::AttributeNames::style, String::formatted("color: {}", color.to_string())); + wrapper->set_inner_html(html); + m_container_element->append_child(wrapper); + m_document->force_layout(); +} + +void IRCLogBuffer::add_message(const String& text, Color color) +{ + auto html = String::formatted( + "<span>{}</span>" + "<span>{}</span>", + timestamp_string(), + escape_html_entities(text)); + auto wrapper = m_document->create_element(Web::HTML::TagNames::div); + wrapper->set_attribute(Web::HTML::AttributeNames::style, String::formatted("color: {}", color.to_string())); + wrapper->set_inner_html(html); + m_container_element->append_child(wrapper); + m_document->force_layout(); +} diff --git a/Userland/Applications/IRCClient/IRCLogBuffer.h b/Userland/Applications/IRCClient/IRCLogBuffer.h new file mode 100644 index 0000000000..db4636f0ee --- /dev/null +++ b/Userland/Applications/IRCClient/IRCLogBuffer.h @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/RefCounted.h> +#include <AK/RefPtr.h> +#include <AK/String.h> +#include <LibGfx/Color.h> +#include <LibWeb/DOM/Document.h> + +class IRCLogBuffer : public RefCounted<IRCLogBuffer> { +public: + static NonnullRefPtr<IRCLogBuffer> create(); + ~IRCLogBuffer(); + + struct Message { + time_t timestamp { 0 }; + char prefix { 0 }; + String sender; + String text; + Color color { Color::Black }; + }; + + void add_message(char prefix, const String& name, const String& text, Color = Color::Black); + void add_message(const String& text, Color = Color::Black); + + const Web::DOM::Document& document() const { return *m_document; } + Web::DOM::Document& document() { return *m_document; } + +private: + IRCLogBuffer(); + RefPtr<Web::DOM::Document> m_document; + RefPtr<Web::DOM::Element> m_container_element; +}; diff --git a/Userland/Applications/IRCClient/IRCQuery.cpp b/Userland/Applications/IRCClient/IRCQuery.cpp new file mode 100644 index 0000000000..e2795f05d8 --- /dev/null +++ b/Userland/Applications/IRCClient/IRCQuery.cpp @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "IRCQuery.h" +#include "IRCClient.h" +#include <stdio.h> + +IRCQuery::IRCQuery(IRCClient& client, const String& name) + : m_client(client) + , m_name(name) + , m_log(IRCLogBuffer::create()) +{ + m_window = m_client->aid_create_window(this, IRCWindow::Query, m_name); + m_window->set_log_buffer(*m_log); +} + +IRCQuery::~IRCQuery() +{ +} + +NonnullRefPtr<IRCQuery> IRCQuery::create(IRCClient& client, const String& name) +{ + return adopt(*new IRCQuery(client, name)); +} + +void IRCQuery::add_message(char prefix, const String& name, const String& text, Color color) +{ + log().add_message(prefix, name, text, color); + window().did_add_message(name, text); +} + +void IRCQuery::add_message(const String& text, Color color) +{ + log().add_message(text, color); + window().did_add_message(); +} + +void IRCQuery::say(const String& text) +{ + m_client->send_privmsg(m_name, text); + add_message(' ', m_client->nickname(), text); +} diff --git a/Userland/Applications/IRCClient/IRCQuery.h b/Userland/Applications/IRCClient/IRCQuery.h new file mode 100644 index 0000000000..379d9d6492 --- /dev/null +++ b/Userland/Applications/IRCClient/IRCQuery.h @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "IRCLogBuffer.h" +#include <AK/CircularQueue.h> +#include <AK/RefCounted.h> +#include <AK/RefPtr.h> +#include <AK/String.h> +#include <AK/Vector.h> + +class IRCClient; +class IRCWindow; + +class IRCQuery : public RefCounted<IRCQuery> { +public: + static NonnullRefPtr<IRCQuery> create(IRCClient&, const String& name); + ~IRCQuery(); + + String name() const { return m_name; } + void add_message(char prefix, const String& name, const String& text, Color = Color::Black); + void add_message(const String& text, Color = Color::Black); + + const IRCLogBuffer& log() const { return *m_log; } + IRCLogBuffer& log() { return *m_log; } + + void say(const String&); + + IRCWindow& window() { return *m_window; } + const IRCWindow& window() const { return *m_window; } + +private: + IRCQuery(IRCClient&, const String& name); + + NonnullRefPtr<IRCClient> m_client; + String m_name; + RefPtr<IRCWindow> m_window; + + NonnullRefPtr<IRCLogBuffer> m_log; +}; diff --git a/Userland/Applications/IRCClient/IRCWindow.cpp b/Userland/Applications/IRCClient/IRCWindow.cpp new file mode 100644 index 0000000000..7a84795b8f --- /dev/null +++ b/Userland/Applications/IRCClient/IRCWindow.cpp @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "IRCWindow.h" +#include "IRCChannel.h" +#include "IRCChannelMemberListModel.h" +#include "IRCClient.h" +#include <AK/StringBuilder.h> +#include <LibGUI/Action.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/InputBox.h> +#include <LibGUI/Menu.h> +#include <LibGUI/Notification.h> +#include <LibGUI/Splitter.h> +#include <LibGUI/TableView.h> +#include <LibGUI/TextBox.h> +#include <LibGUI/TextEditor.h> +#include <LibGUI/Window.h> +#include <LibWeb/InProcessWebView.h> + +IRCWindow::IRCWindow(IRCClient& client, void* owner, Type type, const String& name) + : m_client(client) + , m_owner(owner) + , m_type(type) + , m_name(name) +{ + set_layout<GUI::VerticalBoxLayout>(); + + // Make a container for the log buffer view + (optional) member list. + auto& container = add<GUI::HorizontalSplitter>(); + + m_page_view = container.add<Web::InProcessWebView>(); + + if (m_type == Channel) { + auto& member_view = container.add<GUI::TableView>(); + member_view.set_column_headers_visible(false); + member_view.set_fixed_width(100); + member_view.set_alternating_row_colors(false); + member_view.set_model(channel().member_model()); + member_view.set_activates_on_selection(true); + member_view.on_activation = [&](auto& index) { + if (!index.is_valid()) + return; + auto nick = channel().member_model()->nick_at(member_view.selection().first()); + if (nick.is_empty()) + return; + m_client->handle_open_query_action(m_client->nick_without_prefix(nick.characters())); + }; + member_view.on_context_menu_request = [&](const GUI::ModelIndex& index, const GUI::ContextMenuEvent& event) { + if (!index.is_valid()) + return; + + m_context_menu = GUI::Menu::construct(); + + m_context_menu->add_action(GUI::Action::create("Open query", Gfx::Bitmap::load_from_file("/res/icons/16x16/irc-open-query.png"), [&](const GUI::Action&) { + auto nick = channel().member_model()->nick_at(member_view.selection().first()); + if (nick.is_empty()) + return; + m_client->handle_open_query_action(m_client->nick_without_prefix(nick.characters())); + })); + + m_context_menu->add_action(GUI::Action::create("Whois", Gfx::Bitmap::load_from_file("/res/icons/16x16/irc-whois.png"), [&](const GUI::Action&) { + auto nick = channel().member_model()->nick_at(member_view.selection().first()); + if (nick.is_empty()) + return; + m_client->handle_whois_action(m_client->nick_without_prefix(nick.characters())); + })); + + auto& context_control_menu = m_context_menu->add_submenu("Control"); + + context_control_menu.add_action(GUI::Action::create("Voice", [&](const GUI::Action&) { + auto nick = channel().member_model()->nick_at(member_view.selection().first()); + if (nick.is_empty()) + return; + m_client->handle_voice_user_action(m_name.characters(), m_client->nick_without_prefix(nick.characters())); + })); + + context_control_menu.add_action(GUI::Action::create("DeVoice", [&](const GUI::Action&) { + auto nick = channel().member_model()->nick_at(member_view.selection().first()); + if (nick.is_empty()) + return; + m_client->handle_devoice_user_action(m_name.characters(), m_client->nick_without_prefix(nick.characters())); + })); + + context_control_menu.add_action(GUI::Action::create("Hop", [&](const GUI::Action&) { + auto nick = channel().member_model()->nick_at(member_view.selection().first()); + if (nick.is_empty()) + return; + m_client->handle_hop_user_action(m_name.characters(), m_client->nick_without_prefix(nick.characters())); + })); + + context_control_menu.add_action(GUI::Action::create("DeHop", [&](const GUI::Action&) { + auto nick = channel().member_model()->nick_at(member_view.selection().first()); + if (nick.is_empty()) + return; + m_client->handle_dehop_user_action(m_name.characters(), m_client->nick_without_prefix(nick.characters())); + })); + + context_control_menu.add_action(GUI::Action::create("Op", [&](const GUI::Action&) { + auto nick = channel().member_model()->nick_at(member_view.selection().first()); + if (nick.is_empty()) + return; + m_client->handle_op_user_action(m_name.characters(), m_client->nick_without_prefix(nick.characters())); + })); + + context_control_menu.add_action(GUI::Action::create("DeOp", [&](const GUI::Action&) { + auto nick = channel().member_model()->nick_at(member_view.selection().first()); + if (nick.is_empty()) + return; + m_client->handle_deop_user_action(m_name.characters(), m_client->nick_without_prefix(nick.characters())); + })); + + context_control_menu.add_separator(); + + context_control_menu.add_action(GUI::Action::create("Kick", [&](const GUI::Action&) { + auto nick = channel().member_model()->nick_at(member_view.selection().first()); + if (nick.is_empty()) + return; + if (IRCClient::is_nick_prefix(nick[0])) + nick = nick.substring(1, nick.length() - 1); + String value; + if (GUI::InputBox::show(value, window(), "Enter reason:", "Reason") == GUI::InputBox::ExecOK) + m_client->handle_kick_user_action(m_name.characters(), m_client->nick_without_prefix(nick.characters()), value); + })); + + auto& context_ctcp_menu = m_context_menu->add_submenu("CTCP"); + + context_ctcp_menu.add_action(GUI::Action::create("User info", [&](const GUI::Action&) { + auto nick = channel().member_model()->nick_at(member_view.selection().first()); + if (nick.is_empty()) + return; + m_client->handle_ctcp_user_action(m_client->nick_without_prefix(nick.characters()), "USERINFO"); + })); + + context_ctcp_menu.add_action(GUI::Action::create("Finger", [&](const GUI::Action&) { + auto nick = channel().member_model()->nick_at(member_view.selection().first()); + if (nick.is_empty()) + return; + m_client->handle_ctcp_user_action(m_client->nick_without_prefix(nick.characters()), "FINGER"); + })); + + context_ctcp_menu.add_action(GUI::Action::create("Time", [&](const GUI::Action&) { + auto nick = channel().member_model()->nick_at(member_view.selection().first()); + if (nick.is_empty()) + return; + m_client->handle_ctcp_user_action(m_client->nick_without_prefix(nick.characters()), "TIME"); + })); + + context_ctcp_menu.add_action(GUI::Action::create("Version", [&](const GUI::Action&) { + auto nick = channel().member_model()->nick_at(member_view.selection().first()); + if (nick.is_empty()) + return; + m_client->handle_ctcp_user_action(m_client->nick_without_prefix(nick.characters()), "VERSION"); + })); + + context_ctcp_menu.add_action(GUI::Action::create("Client info", [&](const GUI::Action&) { + auto nick = channel().member_model()->nick_at(member_view.selection().first()); + if (nick.is_empty()) + return; + m_client->handle_ctcp_user_action(m_client->nick_without_prefix(nick.characters()), "CLIENTINFO"); + })); + + m_context_menu->popup(event.screen_position()); + }; + } + + m_text_box = add<GUI::TextBox>(); + m_text_box->set_fixed_height(19); + m_text_box->on_return_pressed = [this] { + if (m_type == Channel) + m_client->handle_user_input_in_channel(m_name, m_text_box->text()); + else if (m_type == Query) + m_client->handle_user_input_in_query(m_name, m_text_box->text()); + else if (m_type == Server) + m_client->handle_user_input_in_server(m_text_box->text()); + m_text_box->add_current_text_to_history(); + m_text_box->clear(); + }; + m_text_box->set_history_enabled(true); + m_text_box->set_placeholder("Message"); + + m_client->register_subwindow(*this); +} + +IRCWindow::~IRCWindow() +{ + m_client->unregister_subwindow(*this); +} + +void IRCWindow::set_log_buffer(const IRCLogBuffer& log_buffer) +{ + m_log_buffer = &log_buffer; + m_page_view->set_document(const_cast<Web::DOM::Document*>(&log_buffer.document())); +} + +bool IRCWindow::is_active() const +{ + return m_client->current_window() == this; +} + +void IRCWindow::post_notification_if_needed(const String& name, const String& message) +{ + if (name.is_null() || message.is_null()) + return; + if (is_active() && window()->is_active()) + return; + + auto notification = GUI::Notification::construct(); + + if (type() == Type::Channel) { + if (!m_client->notify_on_mention()) + return; + if (!message.contains(m_client->nickname())) + return; + + StringBuilder builder; + builder.append(name); + builder.append(" in "); + builder.append(m_name); + notification->set_title(builder.to_string()); + } else { + if (!m_client->notify_on_message()) + return; + notification->set_title(name); + } + + notification->set_icon(Gfx::Bitmap::load_from_file("/res/icons/32x32/app-irc-client.png")); + notification->set_text(message); + notification->show(); +} + +void IRCWindow::did_add_message(const String& name, const String& message) +{ + post_notification_if_needed(name, message); + + if (!is_active()) { + ++m_unread_count; + m_client->aid_update_window_list(); + return; + } + m_page_view->scroll_to_bottom(); +} + +void IRCWindow::clear_unread_count() +{ + if (!m_unread_count) + return; + m_unread_count = 0; + m_client->aid_update_window_list(); +} + +int IRCWindow::unread_count() const +{ + return m_unread_count; +} diff --git a/Userland/Applications/IRCClient/IRCWindow.h b/Userland/Applications/IRCClient/IRCWindow.h new file mode 100644 index 0000000000..a26e00d65e --- /dev/null +++ b/Userland/Applications/IRCClient/IRCWindow.h @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Widget.h> +#include <LibWeb/Forward.h> + +class IRCChannel; +class IRCClient; +class IRCQuery; +class IRCLogBuffer; + +class IRCWindow : public GUI::Widget { + C_OBJECT(IRCWindow) +public: + enum Type { + Server, + Channel, + Query, + }; + + virtual ~IRCWindow() override; + + String name() const { return m_name; } + void set_name(const String& name) { m_name = name; } + + Type type() const { return m_type; } + + void set_log_buffer(const IRCLogBuffer&); + + bool is_active() const; + + int unread_count() const; + void clear_unread_count(); + + void did_add_message(const String& name = {}, const String& message = {}); + + IRCChannel& channel() { return *(IRCChannel*)m_owner; } + const IRCChannel& channel() const { return *(const IRCChannel*)m_owner; } + + IRCQuery& query() { return *(IRCQuery*)m_owner; } + const IRCQuery& query() const { return *(const IRCQuery*)m_owner; } + +private: + IRCWindow(IRCClient&, void* owner, Type, const String& name); + + void post_notification_if_needed(const String& name, const String& message); + + NonnullRefPtr<IRCClient> m_client; + void* m_owner { nullptr }; + Type m_type; + String m_name; + RefPtr<Web::InProcessWebView> m_page_view; + RefPtr<GUI::TextBox> m_text_box; + RefPtr<IRCLogBuffer> m_log_buffer; + RefPtr<GUI::Menu> m_context_menu; + int m_unread_count { 0 }; +}; diff --git a/Userland/Applications/IRCClient/IRCWindowListModel.cpp b/Userland/Applications/IRCClient/IRCWindowListModel.cpp new file mode 100644 index 0000000000..7ee68c429e --- /dev/null +++ b/Userland/Applications/IRCClient/IRCWindowListModel.cpp @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "IRCWindowListModel.h" +#include "IRCChannel.h" +#include "IRCClient.h" + +IRCWindowListModel::IRCWindowListModel(IRCClient& client) + : m_client(client) +{ +} + +IRCWindowListModel::~IRCWindowListModel() +{ +} + +int IRCWindowListModel::row_count(const GUI::ModelIndex&) const +{ + return m_client->window_count(); +} + +int IRCWindowListModel::column_count(const GUI::ModelIndex&) const +{ + return 1; +} + +String IRCWindowListModel::column_name(int column) const +{ + switch (column) { + case Column::Name: + return "Name"; + } + ASSERT_NOT_REACHED(); +} + +GUI::Variant IRCWindowListModel::data(const GUI::ModelIndex& index, GUI::ModelRole role) const +{ + if (role == GUI::ModelRole::TextAlignment) + return Gfx::TextAlignment::CenterLeft; + if (role == GUI::ModelRole::Display) { + switch (index.column()) { + case Column::Name: { + auto& window = m_client->window_at(index.row()); + if (window.unread_count()) + return String::formatted("{} ({})", window.name(), window.unread_count()); + return window.name(); + } + } + } + if (role == GUI::ModelRole::ForegroundColor) { + switch (index.column()) { + case Column::Name: { + auto& window = m_client->window_at(index.row()); + if (window.unread_count()) + return Color(Color::Red); + if (!window.channel().is_open()) + return Color(Color::WarmGray); + return Color(Color::Black); + } + } + } + return {}; +} + +void IRCWindowListModel::update() +{ + did_update(); +} diff --git a/Userland/Applications/IRCClient/IRCWindowListModel.h b/Userland/Applications/IRCClient/IRCWindowListModel.h new file mode 100644 index 0000000000..88e5463b82 --- /dev/null +++ b/Userland/Applications/IRCClient/IRCWindowListModel.h @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/Function.h> +#include <LibGUI/Model.h> + +class IRCClient; +class IRCWindow; + +class IRCWindowListModel final : public GUI::Model { +public: + enum Column { + Name, + }; + + static NonnullRefPtr<IRCWindowListModel> create(IRCClient& client) { return adopt(*new IRCWindowListModel(client)); } + virtual ~IRCWindowListModel() override; + + virtual int row_count(const GUI::ModelIndex&) const override; + virtual int column_count(const GUI::ModelIndex&) const override; + virtual String column_name(int column) const override; + virtual GUI::Variant data(const GUI::ModelIndex&, GUI::ModelRole) const override; + virtual void update() override; + +private: + explicit IRCWindowListModel(IRCClient&); + + NonnullRefPtr<IRCClient> m_client; +}; diff --git a/Userland/Applications/IRCClient/main.cpp b/Userland/Applications/IRCClient/main.cpp new file mode 100644 index 0000000000..a0adf92c0d --- /dev/null +++ b/Userland/Applications/IRCClient/main.cpp @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "IRCAppWindow.h" +#include "IRCClient.h" +#include <LibCore/StandardPaths.h> +#include <LibGUI/Application.h> +#include <LibGUI/MessageBox.h> +#include <stdio.h> + +int main(int argc, char** argv) +{ + if (pledge("stdio inet dns unix shared_buffer cpath rpath fattr wpath cpath", nullptr) < 0) { + perror("pledge"); + return 1; + } + + if (getuid() == 0) { + warnln("Refusing to run as root"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio inet dns unix shared_buffer rpath wpath cpath", nullptr) < 0) { + perror("pledge"); + return 1; + } + + if (unveil("/tmp/portal/lookup", "rw") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/tmp/portal/notify", "rw") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/etc/passwd", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil(Core::StandardPaths::home_directory().characters(), "rwc") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/res", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil(nullptr, nullptr) < 0) { + perror("unveil"); + return 1; + } + + URL url = ""; + if (app->args().size() >= 1) { + url = URL::create_with_url_or_path(app->args()[0]); + + if (url.protocol().to_lowercase() == "ircs") { + warnln("Secure IRC over SSL/TLS (ircs) is not supported"); + return 1; + } + + if (url.protocol().to_lowercase() != "irc") { + warnln("Unsupported protocol"); + return 1; + } + + if (url.host().is_empty()) { + warnln("Invalid URL"); + return 1; + } + + if (!url.port() || url.port() == 80) + url.set_port(6667); + } + + auto app_window = IRCAppWindow::construct(url.host(), url.port()); + app_window->show(); + return app->exec(); +} diff --git a/Userland/Applications/KeyboardMapper/CMakeLists.txt b/Userland/Applications/KeyboardMapper/CMakeLists.txt new file mode 100644 index 0000000000..fe24376eb1 --- /dev/null +++ b/Userland/Applications/KeyboardMapper/CMakeLists.txt @@ -0,0 +1,8 @@ +set(SOURCES + KeyboardMapperWidget.cpp + KeyButton.cpp + main.cpp +) + +serenity_bin(KeyboardMapper) +target_link_libraries(KeyboardMapper LibGUI LibKeyboard) diff --git a/Userland/Applications/KeyboardMapper/KeyButton.cpp b/Userland/Applications/KeyboardMapper/KeyButton.cpp new file mode 100644 index 0000000000..58a687faca --- /dev/null +++ b/Userland/Applications/KeyboardMapper/KeyButton.cpp @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2020, Hüseyin Aslıtürk <asliturk@hotmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "KeyButton.h" +#include <LibGUI/Button.h> +#include <LibGUI/Painter.h> +#include <LibGUI/Window.h> +#include <LibGfx/Font.h> +#include <LibGfx/Palette.h> + +KeyButton::~KeyButton() +{ +} + +void KeyButton::paint_event(GUI::PaintEvent& event) +{ + GUI::Painter painter(*this); + painter.add_clip_rect(event.rect()); + + auto cont_rect = rect(); + auto& font = this->font(); + + Color color; + if (m_pressed) { + color = Color::Cyan; + } else if (!is_enabled()) { + color = Color::LightGray; + } else { + color = Color::White; + } + + painter.fill_rect(cont_rect, Color::Black); + painter.fill_rect({ cont_rect.x() + 1, cont_rect.y() + 1, cont_rect.width() - 2, cont_rect.height() - 2 }, Color::from_rgb(0x999999)); + painter.fill_rect({ cont_rect.x() + 6, cont_rect.y() + 3, cont_rect.width() - 12, cont_rect.height() - 12 }, Color::from_rgb(0x8C7272)); + painter.fill_rect({ cont_rect.x() + 7, cont_rect.y() + 4, cont_rect.width() - 14, cont_rect.height() - 14 }, color); + + if (!text().is_empty()) { + Gfx::IntRect text_rect { 0, 0, font.width(text()), font.glyph_height() }; + text_rect.align_within({ cont_rect.x() + 7, cont_rect.y() + 4, cont_rect.width() - 14, cont_rect.height() - 14 }, Gfx::TextAlignment::Center); + + painter.draw_text(text_rect, text(), font, Gfx::TextAlignment::Center, palette().button_text(), Gfx::TextElision::Right); + if (is_focused()) + painter.draw_rect(text_rect.inflated(6, 4), palette().focus_outline()); + } +} + +void KeyButton::click(unsigned) +{ + if (on_click) + on_click(); +} + +void KeyButton::mousemove_event(GUI::MouseEvent& event) +{ + if (!is_enabled()) + return; + + Gfx::IntRect c = { rect().x() + 7, rect().y() + 4, rect().width() - 14, rect().height() - 14 }; + + if (c.contains(event.position())) { + window()->set_cursor(Gfx::StandardCursor::Hand); + return; + } + window()->set_cursor(Gfx::StandardCursor::Arrow); + + AbstractButton::mousemove_event(event); +} + +void KeyButton::leave_event(Core::Event& event) +{ + window()->set_cursor(Gfx::StandardCursor::Arrow); + AbstractButton::leave_event(event); +} diff --git a/Userland/Applications/KeyboardMapper/KeyButton.h b/Userland/Applications/KeyboardMapper/KeyButton.h new file mode 100644 index 0000000000..afb57033b3 --- /dev/null +++ b/Userland/Applications/KeyboardMapper/KeyButton.h @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2020, Hüseyin Aslıtürk <asliturk@hotmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/AbstractButton.h> + +class KeyButton : public GUI::AbstractButton { + C_OBJECT(KeyButton) + +public: + virtual ~KeyButton() override; + + void set_pressed(bool value) { m_pressed = value; } + + Function<void()> on_click; + +protected: + virtual void click(unsigned modifiers = 0) override; + virtual void leave_event(Core::Event&) override; + virtual void mousemove_event(GUI::MouseEvent&) override; + virtual void paint_event(GUI::PaintEvent&) override; + +private: + bool m_pressed { false }; +}; diff --git a/Userland/Applications/KeyboardMapper/KeyPositions.h b/Userland/Applications/KeyboardMapper/KeyPositions.h new file mode 100644 index 0000000000..3cd9dbaaf7 --- /dev/null +++ b/Userland/Applications/KeyboardMapper/KeyPositions.h @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2020, Hüseyin Aslıtürk <asliturk@hotmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/String.h> + +struct KeyPosition { + u32 scancode; + int x; + int y; + int width; + int height; + bool enabled; + int map_index; + AK::String name; +}; + +#define KEY_COUNT 63 + +struct KeyPosition keys[KEY_COUNT] = { + // clang-format off + [ 0] = { 0, 0, 0, 0, 0, false, 0, ""}, + + [ 1] = { 0x29, 0, 0, 50, 50, true, 41, "`"}, + [ 2] = { 0x02, 51, 0, 50, 50, true, 2, "1"}, + [ 3] = { 0x03, 102, 0, 50, 50, true, 3, "2"}, + [ 4] = { 0x04, 153, 0, 50, 50, true, 4, "3"}, + [ 5] = { 0x05, 204, 0, 50, 50, true, 5, "4"}, + [ 6] = { 0x06, 255, 0, 50, 50, true, 6, "5"}, + [ 7] = { 0x07, 306, 0, 50, 50, true, 7, "6"}, + [ 8] = { 0x08, 357, 0, 50, 50, true, 8, "7"}, + [ 9] = { 0x09, 408, 0, 50, 50, true, 9, "8"}, + [10] = { 0x0A, 459, 0, 50, 50, true, 10, "9"}, + [11] = { 0x0B, 510, 0, 50, 50, true, 11, "0"}, + [12] = { 0x0C, 561, 0, 50, 50, true, 12, "-"}, + [13] = { 0x0D, 612, 0, 50, 50, true, 13, "="}, + [14] = { 0x0E, 663, 0, 100, 50, false, 0, "back space"}, + + [15] = { 0x0F, 0, 52, 76, 50, false, 0, "tab"}, + [16] = { 0x10, 77, 52, 50, 50, true, 16, "q"}, + [17] = { 0x11, 128, 52, 50, 50, true, 17, "w"}, + [18] = { 0x12, 179, 52, 50, 50, true, 18, "e"}, + [19] = { 0x13, 230, 52, 50, 50, true, 19, "r"}, + [20] = { 0x14, 281, 52, 50, 50, true, 20, "t"}, + [21] = { 0x15, 332, 52, 50, 50, true, 21, "y"}, + [22] = { 0x16, 383, 52, 50, 50, true, 22, "u"}, + [23] = { 0x17, 434, 52, 50, 50, true, 23, "ı"}, + [24] = { 0x18, 485, 52, 50, 50, true, 24, "o"}, + [25] = { 0x19, 536, 52, 50, 50, true, 25, "p"}, + [26] = { 0x1A, 587, 52, 50, 50, true, 26, "["}, + [27] = { 0x1B, 638, 52, 50, 50, true, 27, "]"}, + [28] = { 0x1C, 689, 52, 74, 50, false, 0, "enter"}, + + [29] = { 0x3A, 0, 104, 101, 50, false, 0, "caps lock"}, + [30] = { 0x1E, 103, 104, 50, 50, true, 30, "a"}, + [31] = { 0x1F, 154, 104, 50, 50, true, 31, "s"}, + [32] = { 0x20, 205, 104, 50, 50, true, 32, "d"}, + [33] = { 0x21, 256, 104, 50, 50, true, 33, "f"}, + [34] = { 0x22, 307, 104, 50, 50, true, 34, "g"}, + [35] = { 0x23, 358, 104, 50, 50, true, 35, "h"}, + [36] = { 0x24, 409, 104, 50, 50, true, 36, "j"}, + [37] = { 0x25, 460, 104, 50, 50, true, 37, "k"}, + [38] = { 0x26, 511, 104, 50, 50, true, 38, "l"}, + [39] = { 0x27, 562, 104, 50, 50, true, 39, ";"}, + [40] = { 0x28, 614, 104, 50, 50, true, 40, "\""}, + [41] = { 0x2B, 665, 104, 50, 50, true, 43, "\\"}, + + [42] = { 0x2A, 0, 156, 76, 50, false, 0, "left shift"}, + [43] = { 0x56, 77, 156, 50, 50, true, 86, "\\"}, + [44] = { 0x2C, 128, 156, 50, 50, true, 44, "z"}, + [45] = { 0x2D, 179, 156, 50, 50, true, 45, "x"}, + [46] = { 0x2E, 230, 156, 50, 50, true, 46, "c"}, + [47] = { 0x2F, 281, 156, 50, 50, true, 47, "v"}, + [48] = { 0x30, 332, 156, 50, 50, true, 48, "b"}, + [49] = { 0x31, 383, 156, 50, 50, true, 49, "n"}, + [50] = { 0x32, 434, 156, 50, 50, true, 50, "m"}, + [51] = { 0x33, 485, 156, 50, 50, true, 51, ","}, + [52] = { 0x34, 536, 156, 50, 50, true, 52, "."}, + [53] = { 0x35, 587, 156, 50, 50, true, 53, "/"}, + [54] = { 0x36, 638, 156, 125, 50, false, 0, "right shift"}, + + [55] = { 0x1D, 0, 208, 76, 50, false, 0, "left ctrl"}, + [56] = {0xE05B, 77, 208, 50, 50, false, 0, "left\nsuper"}, + [57] = { 0x38, 128, 208, 50, 50, false, 0, "alt"}, + [58] = { 0x39, 179, 208, 356, 50, false, 0, "space"}, + [59] = {0xE038, 536, 208, 50, 50, false, 0, "alt gr"}, + [60] = {0xE05C, 587, 208, 50, 50, false, 0, "right\nsuper"}, + [61] = {0xE05D, 638, 208, 50, 50, false, 0, "menu"}, + [62] = {0xE01D, 689, 208, 74, 50, false, 0, "right ctrl"} + // clang-format on +}; diff --git a/Userland/Applications/KeyboardMapper/KeyboardMapperWidget.cpp b/Userland/Applications/KeyboardMapper/KeyboardMapperWidget.cpp new file mode 100644 index 0000000000..24df141a01 --- /dev/null +++ b/Userland/Applications/KeyboardMapper/KeyboardMapperWidget.cpp @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2020, Hüseyin Aslıtürk <asliturk@hotmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "KeyboardMapperWidget.h" +#include "KeyPositions.h" +#include <LibCore/File.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/InputBox.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/RadioButton.h> +#include <LibKeyboard/CharacterMapFile.h> +#include <fcntl.h> +#include <stdio.h> +#include <string.h> + +KeyboardMapperWidget::KeyboardMapperWidget() +{ + create_frame(); +} + +KeyboardMapperWidget::~KeyboardMapperWidget() +{ +} + +void KeyboardMapperWidget::create_frame() +{ + set_fill_with_background_color(true); + set_layout<GUI::VerticalBoxLayout>(); + layout()->set_margins({ 4, 4, 4, 4 }); + + auto& main_widget = add<GUI::Widget>(); + main_widget.set_relative_rect(0, 0, 200, 200); + + m_keys.resize(KEY_COUNT); + + for (unsigned i = 0; i < KEY_COUNT; i++) { + Gfx::IntRect rect = { keys[i].x, keys[i].y, keys[i].width, keys[i].height }; + + auto& tmp_button = main_widget.add<KeyButton>(); + tmp_button.set_relative_rect(rect); + tmp_button.set_text(keys[i].name); + tmp_button.set_enabled(keys[i].enabled); + + tmp_button.on_click = [this, &tmp_button]() { + String value; + if (GUI::InputBox::show(value, window(), "New Character:", "Select Character") == GUI::InputBox::ExecOK) { + int i = m_keys.find_first_index(&tmp_button).value_or(0); + ASSERT(i > 0); + + auto index = keys[i].map_index; + ASSERT(index > 0); + + tmp_button.set_text(value); + u32* map; + + if (m_current_map_name == "map") { + map = m_character_map.map; + } else if (m_current_map_name == "shift_map") { + map = m_character_map.shift_map; + } else if (m_current_map_name == "alt_map") { + map = m_character_map.alt_map; + } else if (m_current_map_name == "altgr_map") { + map = m_character_map.altgr_map; + } else if (m_current_map_name == "shift_altgr_map") { + map = m_character_map.shift_altgr_map; + } else { + ASSERT_NOT_REACHED(); + } + + if (value.length() == 0) + map[index] = '\0'; // Empty string + else + map[index] = value[0]; + + m_modified = true; + update_window_title(); + } + }; + + m_keys.insert(i, &tmp_button); + } + + // Action Buttons + auto& bottom_widget = add<GUI::Widget>(); + bottom_widget.set_layout<GUI::HorizontalBoxLayout>(); + bottom_widget.set_fixed_height(40); + + // Map Selection + m_map_group = bottom_widget.add<GUI::Widget>(); + m_map_group->set_layout<GUI::HorizontalBoxLayout>(); + m_map_group->set_fixed_width(250); + + auto& radio_map = m_map_group->add<GUI::RadioButton>("Default"); + radio_map.set_name("map"); + radio_map.on_checked = [&](bool) { + set_current_map("map"); + }; + auto& radio_shift = m_map_group->add<GUI::RadioButton>("Shift"); + radio_shift.set_name("shift_map"); + radio_shift.on_checked = [this](bool) { + set_current_map("shift_map"); + }; + auto& radio_altgr = m_map_group->add<GUI::RadioButton>("AltGr"); + radio_altgr.set_name("altgr_map"); + radio_altgr.on_checked = [this](bool) { + set_current_map("altgr_map"); + }; + auto& radio_alt = m_map_group->add<GUI::RadioButton>("Alt"); + radio_alt.set_name("alt_map"); + radio_alt.on_checked = [this](bool) { + set_current_map("alt_map"); + }; + auto& radio_shift_altgr = m_map_group->add<GUI::RadioButton>("Shift+AltGr"); + radio_shift_altgr.set_name("shift_altgr_map"); + radio_shift_altgr.on_checked = [this](bool) { + set_current_map("shift_altgr_map"); + }; + + bottom_widget.layout()->add_spacer(); + + auto& ok_button = bottom_widget.add<GUI::Button>(); + ok_button.set_text("Save"); + ok_button.set_fixed_width(80); + ok_button.on_click = [this](auto) { + save(); + }; +} + +void KeyboardMapperWidget::load_from_file(String file_name) +{ + auto result = Keyboard::CharacterMapFile::load_from_file(file_name); + if (!result.has_value()) { + ASSERT_NOT_REACHED(); + } + + m_file_name = file_name; + m_character_map = result.value(); + set_current_map("map"); + + for (Widget* widget : m_map_group->child_widgets()) { + auto radio_button = (GUI::RadioButton*)widget; + radio_button->set_checked(radio_button->name() == "map"); + } + + update_window_title(); +} + +void KeyboardMapperWidget::save() +{ + save_to_file(m_file_name); +} + +void KeyboardMapperWidget::save_to_file(const StringView& file_name) +{ + JsonObject map_json; + + auto add_array = [&](String name, u32* values) { + JsonArray items; + for (int i = 0; i < 90; i++) { + AK::StringBuilder sb; + sb.append(values[i]); + + JsonValue val(sb.to_string()); + items.append(move(val)); + } + map_json.set(name, move(items)); + }; + + add_array("map", m_character_map.map); + add_array("shift_map", m_character_map.shift_map); + add_array("alt_map", m_character_map.alt_map); + add_array("altgr_map", m_character_map.altgr_map); + add_array("shift_altgr_map", m_character_map.shift_altgr_map); + + // Write to file. + String file_content = map_json.to_string(); + + auto file = Core::File::construct(file_name); + file->open(Core::IODevice::WriteOnly); + if (!file->is_open()) { + StringBuilder sb; + sb.append("Failed to open "); + sb.append(file_name); + sb.append(" for write. Error: "); + sb.append(file->error_string()); + + GUI::MessageBox::show(window(), sb.to_string(), "Error", GUI::MessageBox::Type::Error); + return; + } + + bool result = file->write(file_content); + if (!result) { + int error_number = errno; + StringBuilder sb; + sb.append("Unable to save file. Error: "); + sb.append(strerror(error_number)); + + GUI::MessageBox::show(window(), sb.to_string(), "Error", GUI::MessageBox::Type::Error); + return; + } + + m_modified = false; + m_file_name = file_name; + update_window_title(); +} + +void KeyboardMapperWidget::keydown_event(GUI::KeyEvent& event) +{ + for (int i = 0; i < KEY_COUNT; i++) { + auto& tmp_button = m_keys.at(i); + tmp_button->set_pressed(keys[i].scancode == event.scancode()); + tmp_button->update(); + } +} + +void KeyboardMapperWidget::keyup_event(GUI::KeyEvent& event) +{ + for (int i = 0; i < KEY_COUNT; i++) { + if (keys[i].scancode == event.scancode()) { + auto& tmp_button = m_keys.at(i); + tmp_button->set_pressed(false); + tmp_button->update(); + break; + } + } +} + +void KeyboardMapperWidget::set_current_map(const String current_map) +{ + m_current_map_name = current_map; + u32* map; + + if (m_current_map_name == "map") { + map = m_character_map.map; + } else if (m_current_map_name == "shift_map") { + map = m_character_map.shift_map; + } else if (m_current_map_name == "alt_map") { + map = m_character_map.alt_map; + } else if (m_current_map_name == "altgr_map") { + map = m_character_map.altgr_map; + } else if (m_current_map_name == "shift_altgr_map") { + map = m_character_map.shift_altgr_map; + } else { + ASSERT_NOT_REACHED(); + } + + for (unsigned k = 0; k < KEY_COUNT; k++) { + auto index = keys[k].map_index; + if (index == 0) + continue; + + AK::StringBuilder sb; + sb.append_code_point(map[index]); + + m_keys.at(k)->set_text(sb.to_string()); + } + + this->update(); +} + +void KeyboardMapperWidget::update_window_title() +{ + StringBuilder sb; + sb.append(m_file_name); + if (m_modified) + sb.append(" (*)"); + sb.append(" - KeyboardMapper"); + + window()->set_title(sb.to_string()); +} diff --git a/Userland/Applications/KeyboardMapper/KeyboardMapperWidget.h b/Userland/Applications/KeyboardMapper/KeyboardMapperWidget.h new file mode 100644 index 0000000000..e0df34ed78 --- /dev/null +++ b/Userland/Applications/KeyboardMapper/KeyboardMapperWidget.h @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2020, Hüseyin Aslıtürk <asliturk@hotmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "KeyButton.h" +#include <LibGUI/Button.h> +#include <LibKeyboard/CharacterMapData.h> + +class KeyboardMapperWidget : public GUI::Widget { + C_OBJECT(KeyboardMapperWidget) + +public: + KeyboardMapperWidget(); + virtual ~KeyboardMapperWidget() override; + + void create_frame(); + void load_from_file(const String); + void save(); + void save_to_file(const StringView&); + +protected: + virtual void keydown_event(GUI::KeyEvent&) override; + virtual void keyup_event(GUI::KeyEvent&) override; + + void set_current_map(const String); + void update_window_title(); + +private: + Vector<KeyButton*> m_keys; + RefPtr<GUI::Widget> m_map_group; + + String m_file_name; + Keyboard::CharacterMapData m_character_map; + String m_current_map_name; + bool m_modified { false }; +}; diff --git a/Userland/Applications/KeyboardMapper/main.cpp b/Userland/Applications/KeyboardMapper/main.cpp new file mode 100644 index 0000000000..0edeef72fe --- /dev/null +++ b/Userland/Applications/KeyboardMapper/main.cpp @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2020, Hüseyin Aslıtürk <asliturk@hotmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "KeyboardMapperWidget.h" +#include <LibCore/ArgsParser.h> +#include <LibGUI/Action.h> +#include <LibGUI/Application.h> +#include <LibGUI/FilePicker.h> +#include <LibGUI/Icon.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> + +int main(int argc, char** argv) +{ + const char* path = nullptr; + Core::ArgsParser args_parser; + args_parser.add_positional_argument(path, "Keyboard character mapping file.", "file", Core::ArgsParser::Required::No); + args_parser.parse(argc, argv); + + if (pledge("stdio thread rpath accept cpath wpath shared_buffer unix fattr", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio thread rpath accept cpath wpath shared_buffer", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app_icon = GUI::Icon::default_icon("app-keyboard-mapper"); + + auto window = GUI::Window::construct(); + window->set_title("Keyboard Mapper"); + window->set_icon(app_icon.bitmap_for_size(16)); + window->set_main_widget<KeyboardMapperWidget>(); + window->resize(775, 315); + window->set_resizable(false); + window->show(); + + auto keyboard_mapper_widget = (KeyboardMapperWidget*)window->main_widget(); + if (path != nullptr) { + keyboard_mapper_widget->load_from_file(path); + } else { + keyboard_mapper_widget->load_from_file("/res/keymaps/en.json"); + } + + // Actions + auto open_action = GUI::CommonActions::make_open_action( + [&](auto&) { + Optional<String> path = GUI::FilePicker::get_open_filepath(window, "Open"); + if (path.has_value()) { + keyboard_mapper_widget->load_from_file(path.value()); + } + }); + + auto save_action = GUI::CommonActions::make_save_action( + [&](auto&) { + keyboard_mapper_widget->save(); + }); + + auto save_as_action = GUI::CommonActions::make_save_as_action([&](auto&) { + String m_name = "Unnamed"; + Optional<String> save_path = GUI::FilePicker::get_save_filepath(window, m_name, "json"); + if (!save_path.has_value()) + return; + + keyboard_mapper_widget->save_to_file(save_path.value()); + }); + + auto quit_action = GUI::CommonActions::make_quit_action( + [&](auto&) { + app->quit(); + }); + + // Menu + auto menubar = GUI::MenuBar::construct(); + + auto& app_menu = menubar->add_menu("Keyboard Mapper"); + app_menu.add_action(open_action); + app_menu.add_action(save_action); + app_menu.add_action(save_as_action); + app_menu.add_separator(); + app_menu.add_action(quit_action); + + auto& help_menu = menubar->add_menu("Help"); + help_menu.add_action(GUI::CommonActions::make_about_action("Keyboard Mapper", app_icon, window)); + + app->set_menubar(move(menubar)); + + return app->exec(); +} diff --git a/Userland/Applications/KeyboardSettings/CMakeLists.txt b/Userland/Applications/KeyboardSettings/CMakeLists.txt new file mode 100644 index 0000000000..62212cda36 --- /dev/null +++ b/Userland/Applications/KeyboardSettings/CMakeLists.txt @@ -0,0 +1,6 @@ +set(SOURCES + main.cpp +) + +serenity_app(KeyboardSettings ICON app-keyboard-settings) +target_link_libraries(KeyboardSettings LibGUI LibKeyboard) diff --git a/Userland/Applications/KeyboardSettings/CharacterMapFileListModel.h b/Userland/Applications/KeyboardSettings/CharacterMapFileListModel.h new file mode 100644 index 0000000000..4fcd1d8123 --- /dev/null +++ b/Userland/Applications/KeyboardSettings/CharacterMapFileListModel.h @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2020, Hüseyin Aslıtürk <asliturk@hotmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/Vector.h> +#include <LibGUI/Model.h> + +class CharacterMapFileListModel final : public GUI::Model { +public: + static NonnullRefPtr<CharacterMapFileListModel> create(Vector<String>& file_names) + { + return adopt(*new CharacterMapFileListModel(file_names)); + } + + virtual ~CharacterMapFileListModel() override { } + + virtual int row_count(const GUI::ModelIndex&) const override + { + return m_file_names.size(); + } + + virtual int column_count(const GUI::ModelIndex&) const override + { + return 1; + } + + virtual GUI::Variant data(const GUI::ModelIndex& index, GUI::ModelRole role) const override + { + ASSERT(index.is_valid()); + ASSERT(index.column() == 0); + + if (role == GUI::ModelRole::Display) + return m_file_names.at(index.row()); + + return {}; + } + + virtual void update() override + { + did_update(); + } + +private: + explicit CharacterMapFileListModel(Vector<String>& file_names) + : m_file_names(file_names) + { + } + + Vector<String>& m_file_names; +}; diff --git a/Userland/Applications/KeyboardSettings/main.cpp b/Userland/Applications/KeyboardSettings/main.cpp new file mode 100644 index 0000000000..8b515d815e --- /dev/null +++ b/Userland/Applications/KeyboardSettings/main.cpp @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2020, Hüseyin Aslıtürk <asliturk@hotmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "CharacterMapFileListModel.h" +#include <AK/JsonObject.h> +#include <AK/QuickSort.h> +#include <LibCore/ArgsParser.h> +#include <LibCore/DirIterator.h> +#include <LibCore/File.h> +#include <LibGUI/Action.h> +#include <LibGUI/Application.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/ComboBox.h> +#include <LibGUI/Label.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/WindowServerConnection.h> +#include <LibKeyboard/CharacterMap.h> +#include <spawn.h> + +int main(int argc, char** argv) +{ + if (pledge("stdio rpath accept cpath wpath shared_buffer unix fattr proc exec", nullptr) < 0) { + perror("pledge"); + return 1; + } + + // If there is no command line parameter go for GUI. + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio rpath accept shared_buffer proc exec", nullptr) < 0) { + perror("pledge"); + return 1; + } + + if (unveil("/res", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/bin/keymap", "x") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/proc/keymap", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil(nullptr, nullptr)) { + perror("unveil"); + return 1; + } + + auto app_icon = GUI::Icon::default_icon("app-keyboard-settings"); + + auto proc_keymap = Core::File::construct("/proc/keymap"); + if (!proc_keymap->open(Core::IODevice::OpenMode::ReadOnly)) + ASSERT_NOT_REACHED(); + + auto json = JsonValue::from_string(proc_keymap->read_all()); + ASSERT(json.has_value()); + JsonObject keymap_object = json.value().as_object(); + ASSERT(keymap_object.has("keymap")); + String current_keymap = keymap_object.get("keymap").to_string(); + dbgln("KeyboardSettings thinks the current keymap is: {}", current_keymap); + + Vector<String> character_map_files; + Core::DirIterator iterator("/res/keymaps/", Core::DirIterator::Flags::SkipDots); + if (iterator.has_error()) { + GUI::MessageBox::show(nullptr, String::formatted("Error on reading mapping file list: {}", iterator.error_string()), "Keyboard settings", GUI::MessageBox::Type::Error); + return -1; + } + + while (iterator.has_next()) { + auto name = iterator.next_path(); + name.replace(".json", ""); + character_map_files.append(name); + } + quick_sort(character_map_files); + + size_t initial_keymap_index = SIZE_MAX; + for (size_t i = 0; i < character_map_files.size(); ++i) { + if (character_map_files[i].equals_ignoring_case(current_keymap)) + initial_keymap_index = i; + } + ASSERT(initial_keymap_index < character_map_files.size()); + + auto window = GUI::Window::construct(); + window->set_title("Keyboard Settings"); + window->resize(300, 70); + window->set_icon(app_icon.bitmap_for_size(16)); + + auto& root_widget = window->set_main_widget<GUI::Widget>(); + root_widget.set_layout<GUI::VerticalBoxLayout>(); + root_widget.set_fill_with_background_color(true); + root_widget.layout()->set_spacing(0); + root_widget.layout()->set_margins({ 4, 4, 4, 4 }); + + auto& character_map_file_selection_container = root_widget.add<GUI::Widget>(); + character_map_file_selection_container.set_layout<GUI::HorizontalBoxLayout>(); + character_map_file_selection_container.set_fixed_height(22); + + auto& character_map_file_label = character_map_file_selection_container.add<GUI::Label>(); + character_map_file_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + character_map_file_label.set_fixed_width(130); + character_map_file_label.set_text("Character Mapping File:"); + + auto& character_map_file_combo = character_map_file_selection_container.add<GUI::ComboBox>(); + character_map_file_combo.set_only_allow_values_from_model(true); + character_map_file_combo.set_model(*CharacterMapFileListModel::create(character_map_files)); + character_map_file_combo.set_selected_index(initial_keymap_index); + + root_widget.layout()->add_spacer(); + + auto apply_settings = [&](bool quit) { + String character_map_file = character_map_file_combo.text(); + if (character_map_file.is_empty()) { + GUI::MessageBox::show(window, "Please select character mapping file.", "Keyboard settings", GUI::MessageBox::Type::Error); + return; + } + pid_t child_pid; + const char* argv[] = { "/bin/keymap", character_map_file.characters(), nullptr }; + if ((errno = posix_spawn(&child_pid, "/bin/keymap", nullptr, nullptr, const_cast<char**>(argv), environ))) { + perror("posix_spawn"); + exit(1); + } + if (quit) + app->quit(); + }; + + auto& bottom_widget = root_widget.add<GUI::Widget>(); + bottom_widget.set_layout<GUI::HorizontalBoxLayout>(); + bottom_widget.layout()->add_spacer(); + bottom_widget.set_fixed_height(22); + + auto& apply_button = bottom_widget.add<GUI::Button>(); + apply_button.set_text("Apply"); + apply_button.set_fixed_width(60); + apply_button.on_click = [&](auto) { + apply_settings(false); + }; + + auto& ok_button = bottom_widget.add<GUI::Button>(); + ok_button.set_text("OK"); + ok_button.set_fixed_width(60); + ok_button.on_click = [&](auto) { + apply_settings(true); + }; + + auto& cancel_button = bottom_widget.add<GUI::Button>(); + cancel_button.set_text("Cancel"); + cancel_button.set_fixed_width(60); + cancel_button.on_click = [&](auto) { + app->quit(); + }; + + auto quit_action = GUI::CommonActions::make_quit_action( + [&](auto&) { + app->quit(); + }); + + auto menubar = GUI::MenuBar::construct(); + + auto& app_menu = menubar->add_menu("Keyboard Settings"); + app_menu.add_action(quit_action); + + auto& help_menu = menubar->add_menu("Help"); + help_menu.add_action(GUI::CommonActions::make_about_action("Keyboard Settings", app_icon, window)); + + app->set_menubar(move(menubar)); + + window->show(); + + return app->exec(); +} diff --git a/Userland/Applications/MouseSettings/CMakeLists.txt b/Userland/Applications/MouseSettings/CMakeLists.txt new file mode 100644 index 0000000000..f52df8d76c --- /dev/null +++ b/Userland/Applications/MouseSettings/CMakeLists.txt @@ -0,0 +1,6 @@ +set(SOURCES + main.cpp +) + +serenity_app(MouseSettings ICON app-mouse) +target_link_libraries(MouseSettings LibGUI) diff --git a/Userland/Applications/MouseSettings/main.cpp b/Userland/Applications/MouseSettings/main.cpp new file mode 100644 index 0000000000..74b07d0b0a --- /dev/null +++ b/Userland/Applications/MouseSettings/main.cpp @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2020, Idan Horowitz <idan.horowitz@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include <LibGUI/Action.h> +#include <LibGUI/Application.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/GroupBox.h> +#include <LibGUI/Icon.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/Slider.h> +#include <LibGUI/SpinBox.h> +#include <LibGUI/Widget.h> +#include <LibGUI/Window.h> +#include <LibGUI/WindowServerConnection.h> +#include <LibGfx/SystemTheme.h> +#include <WindowServer/Screen.h> + +int main(int argc, char** argv) +{ + if (pledge("stdio cpath rpath shared_buffer unix fattr", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio cpath rpath shared_buffer", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app_icon = GUI::Icon::default_icon("app-mouse"); + auto window = GUI::Window::construct(); + window->set_title("Mouse Settings"); + window->resize(200, 130); + window->set_resizable(false); + window->set_icon(app_icon.bitmap_for_size(16)); + + auto& settings = window->set_main_widget<GUI::Widget>(); + settings.set_fill_with_background_color(true); + settings.set_background_role(ColorRole::Button); + settings.set_layout<GUI::VerticalBoxLayout>(); + settings.layout()->set_margins({ 4, 4, 4, 4 }); + + auto& speed_container = settings.add<GUI::GroupBox>("Mouse speed"); + speed_container.set_layout<GUI::VerticalBoxLayout>(); + speed_container.layout()->set_margins({ 6, 16, 6, 6 }); + speed_container.set_fixed_height(50); + + auto& speed_slider = speed_container.add<GUI::HorizontalSlider>(); + const auto scalar = 1000.0; + speed_slider.set_range(WindowServer::mouse_accel_min * scalar, WindowServer::mouse_accel_max * scalar); // These values are scaled down (by a factor of 1000) to get fractional values + int current_value = GUI::WindowServerConnection::the().send_sync<Messages::WindowServer::GetMouseAcceleration>()->factor() * scalar; + speed_slider.set_value(current_value); + + auto& scroll_container = settings.add<GUI::GroupBox>("Scroll length"); + scroll_container.set_layout<GUI::VerticalBoxLayout>(); + scroll_container.layout()->set_margins({ 6, 16, 6, 6 }); + scroll_container.set_fixed_height(46); + + auto& scroll_spinbox = scroll_container.add<GUI::SpinBox>(); + scroll_spinbox.set_min(WindowServer::scroll_step_size_min); + scroll_spinbox.set_value(GUI::WindowServerConnection::the().send_sync<Messages::WindowServer::GetScrollStepSize>()->step_size()); + + auto update_window_server = [&]() { + float factor = speed_slider.value() / scalar; + GUI::WindowServerConnection::the().send_sync<Messages::WindowServer::SetMouseAcceleration>(factor); + GUI::WindowServerConnection::the().send_sync<Messages::WindowServer::SetScrollStepSize>(scroll_spinbox.value()); + }; + + auto& prompt_buttons = settings.add<GUI::Widget>(); + prompt_buttons.set_layout<GUI::HorizontalBoxLayout>(); + prompt_buttons.set_fixed_height(22); + + auto& ok_button = prompt_buttons.add<GUI::Button>(); + ok_button.set_text("OK"); + prompt_buttons.set_fixed_height(22); + ok_button.on_click = [&](auto) { + update_window_server(); + app->quit(); + }; + auto& apply_button = prompt_buttons.add<GUI::Button>(); + apply_button.set_text("Apply"); + prompt_buttons.set_fixed_height(22); + apply_button.on_click = [&](auto) { + update_window_server(); + }; + auto& reset_button = prompt_buttons.add<GUI::Button>(); + reset_button.set_text("Reset"); + prompt_buttons.set_fixed_height(22); + reset_button.on_click = [&](auto) { + speed_slider.set_value(scalar); + scroll_spinbox.set_value(4); + update_window_server(); + }; + + auto menubar = GUI::MenuBar::construct(); + auto& app_menu = menubar->add_menu("Mouse Settings"); + app_menu.add_action(GUI::CommonActions::make_quit_action([&](auto&) { + app->quit(); + })); + auto& help_menu = menubar->add_menu("Help"); + help_menu.add_action(GUI::CommonActions::make_about_action("Mouse Settings", app_icon, window)); + app->set_menubar(move(menubar)); + + window->show(); + return app->exec(); +} diff --git a/Userland/Applications/Piano/CMakeLists.txt b/Userland/Applications/Piano/CMakeLists.txt new file mode 100644 index 0000000000..382e023c3b --- /dev/null +++ b/Userland/Applications/Piano/CMakeLists.txt @@ -0,0 +1,14 @@ +set(SOURCES + Track.cpp + TrackManager.cpp + KeysWidget.cpp + KnobsWidget.cpp + main.cpp + MainWidget.cpp + RollWidget.cpp + SamplerWidget.cpp + WaveWidget.cpp +) + +serenity_app(Piano ICON app-piano) +target_link_libraries(Piano LibAudio LibGUI) diff --git a/Userland/Applications/Piano/KeysWidget.cpp b/Userland/Applications/Piano/KeysWidget.cpp new file mode 100644 index 0000000000..65fcdc39b5 --- /dev/null +++ b/Userland/Applications/Piano/KeysWidget.cpp @@ -0,0 +1,328 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * Copyright (c) 2019-2020, William McPherson <willmcpherson2@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "KeysWidget.h" +#include "TrackManager.h" +#include <LibGUI/Painter.h> + +KeysWidget::KeysWidget(TrackManager& track_manager) + : m_track_manager(track_manager) +{ + set_fill_with_background_color(true); +} + +KeysWidget::~KeysWidget() +{ +} + +int KeysWidget::mouse_note() const +{ + if (m_mouse_down && m_mouse_note + m_track_manager.octave_base() < note_count) + return m_mouse_note; // Can be -1. + else + return -1; +} + +void KeysWidget::set_key(int key, Switch switch_key) +{ + if (key == -1 || key + m_track_manager.octave_base() >= note_count) + return; + + if (switch_key == On) { + ++m_key_on[key]; + } else { + if (m_key_on[key] >= 1) + --m_key_on[key]; + } + ASSERT(m_key_on[key] <= 2); + + m_track_manager.set_note_current_octave(key, switch_key); +} + +bool KeysWidget::note_is_set(int note) const +{ + if (note < m_track_manager.octave_base()) + return false; + + if (note >= m_track_manager.octave_base() + note_count) + return false; + + return m_key_on[note - m_track_manager.octave_base()] != 0; +} + +int KeysWidget::key_code_to_key(int key_code) const +{ + switch (key_code) { + case Key_A: + return 0; + case Key_W: + return 1; + case Key_S: + return 2; + case Key_E: + return 3; + case Key_D: + return 4; + case Key_F: + return 5; + case Key_T: + return 6; + case Key_G: + return 7; + case Key_Y: + return 8; + case Key_H: + return 9; + case Key_U: + return 10; + case Key_J: + return 11; + case Key_K: + return 12; + case Key_O: + return 13; + case Key_L: + return 14; + case Key_P: + return 15; + case Key_Semicolon: + return 16; + case Key_Apostrophe: + return 17; + case Key_RightBracket: + return 18; + case Key_Return: + return 19; + default: + return -1; + } +} + +constexpr int white_key_width = 24; +constexpr int black_key_width = 16; +constexpr int black_key_x_offset = black_key_width / 2; +constexpr int black_key_height = 60; + +constexpr char white_key_labels[] = { + 'A', + 'S', + 'D', + 'F', + 'G', + 'H', + 'J', + 'K', + 'L', + ';', + '\'', + 'r', +}; +constexpr int white_key_labels_count = sizeof(white_key_labels) / sizeof(char); + +constexpr char black_key_labels[] = { + 'W', + 'E', + 'T', + 'Y', + 'U', + 'O', + 'P', + ']', +}; +constexpr int black_key_labels_count = sizeof(black_key_labels) / sizeof(char); + +constexpr int black_key_offsets[] = { + white_key_width, + white_key_width * 2, + white_key_width, + white_key_width, + white_key_width * 2, +}; + +constexpr int white_key_note_accumulator[] = { + 2, + 2, + 1, + 2, + 2, + 2, + 1, +}; + +constexpr int black_key_note_accumulator[] = { + 2, + 3, + 2, + 2, + 3, +}; + +void KeysWidget::paint_event(GUI::PaintEvent& event) +{ + GUI::Painter painter(*this); + painter.translate(frame_thickness(), frame_thickness()); + + int note = 0; + int x = 0; + int i = 0; + for (;;) { + Gfx::IntRect rect(x, 0, white_key_width, frame_inner_rect().height()); + painter.fill_rect(rect, m_key_on[note] ? note_pressed_color : Color::White); + painter.draw_rect(rect, Color::Black); + if (i < white_key_labels_count) { + rect.set_height(rect.height() * 1.5); + painter.draw_text(rect, StringView(&white_key_labels[i], 1), Gfx::TextAlignment::Center, Color::Black); + } + + note += white_key_note_accumulator[i % white_keys_per_octave]; + x += white_key_width; + ++i; + + if (note + m_track_manager.octave_base() >= note_count) + break; + if (x >= frame_inner_rect().width()) + break; + } + + note = 1; + x = white_key_width - black_key_x_offset; + i = 0; + for (;;) { + Gfx::IntRect rect(x, 0, black_key_width, black_key_height); + painter.fill_rect(rect, m_key_on[note] ? note_pressed_color : Color::Black); + painter.draw_rect(rect, Color::Black); + if (i < black_key_labels_count) { + rect.set_height(rect.height() * 1.5); + painter.draw_text(rect, StringView(&black_key_labels[i], 1), Gfx::TextAlignment::Center, Color::White); + } + + note += black_key_note_accumulator[i % black_keys_per_octave]; + x += black_key_offsets[i % black_keys_per_octave]; + ++i; + + if (note + m_track_manager.octave_base() >= note_count) + break; + if (x >= frame_inner_rect().width()) + break; + } + + GUI::Frame::paint_event(event); +} + +constexpr int notes_per_white_key[] = { + 1, + 3, + 5, + 6, + 8, + 10, + 12, +}; + +// Keep in mind that in any of these functions a note value can be out of +// bounds. Bounds checking is done in set_key(). + +static inline int note_from_white_keys(int white_keys) +{ + int octaves = white_keys / white_keys_per_octave; + int remainder = white_keys % white_keys_per_octave; + int notes_from_octaves = octaves * notes_per_octave; + int notes_from_remainder = notes_per_white_key[remainder]; + int note = (notes_from_octaves + notes_from_remainder) - 1; + return note; +} + +int KeysWidget::note_for_event_position(const Gfx::IntPoint& a_point) const +{ + if (!frame_inner_rect().contains(a_point)) + return -1; + + auto point = a_point; + point.move_by(-frame_thickness(), -frame_thickness()); + + int white_keys = point.x() / white_key_width; + int note = note_from_white_keys(white_keys); + + bool black_key_on_left = note != 0 && key_pattern[(note - 1) % notes_per_octave] == Black; + if (black_key_on_left) { + int black_key_x = (white_keys * white_key_width) - black_key_x_offset; + Gfx::IntRect black_key(black_key_x, 0, black_key_width, black_key_height); + if (black_key.contains(point)) + return note - 1; + } + + bool black_key_on_right = key_pattern[(note + 1) % notes_per_octave] == Black; + if (black_key_on_right) { + int black_key_x = ((white_keys + 1) * white_key_width) - black_key_x_offset; + Gfx::IntRect black_key(black_key_x, 0, black_key_width, black_key_height); + if (black_key.contains(point)) + return note + 1; + } + + return note; +} + +void KeysWidget::mousedown_event(GUI::MouseEvent& event) +{ + if (event.button() != GUI::MouseButton::Left) + return; + + m_mouse_down = true; + + m_mouse_note = note_for_event_position(event.position()); + + set_key(m_mouse_note, On); + update(); +} + +void KeysWidget::mouseup_event(GUI::MouseEvent& event) +{ + if (event.button() != GUI::MouseButton::Left) + return; + + m_mouse_down = false; + + set_key(m_mouse_note, Off); + update(); +} + +void KeysWidget::mousemove_event(GUI::MouseEvent& event) +{ + if (!m_mouse_down) + return; + + int new_mouse_note = note_for_event_position(event.position()); + + if (m_mouse_note == new_mouse_note) + return; + + set_key(m_mouse_note, Off); + set_key(new_mouse_note, On); + update(); + + m_mouse_note = new_mouse_note; +} diff --git a/Userland/Applications/Piano/KeysWidget.h b/Userland/Applications/Piano/KeysWidget.h new file mode 100644 index 0000000000..d0b5a833ce --- /dev/null +++ b/Userland/Applications/Piano/KeysWidget.h @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * Copyright (c) 2019-2020, William McPherson <willmcpherson2@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Music.h" +#include <LibGUI/Frame.h> + +class TrackManager; + +class KeysWidget final : public GUI::Frame { + C_OBJECT(KeysWidget) +public: + virtual ~KeysWidget() override; + + int key_code_to_key(int key_code) const; + int mouse_note() const; + + void set_key(int key, Switch); + bool note_is_set(int note) const; + +private: + explicit KeysWidget(TrackManager&); + + virtual void paint_event(GUI::PaintEvent&) override; + virtual void mousedown_event(GUI::MouseEvent&) override; + virtual void mouseup_event(GUI::MouseEvent&) override; + virtual void mousemove_event(GUI::MouseEvent&) override; + + int note_for_event_position(const Gfx::IntPoint&) const; + + TrackManager& m_track_manager; + + u8 m_key_on[note_count] { 0 }; + + bool m_mouse_down { false }; + int m_mouse_note { -1 }; +}; diff --git a/Userland/Applications/Piano/KnobsWidget.cpp b/Userland/Applications/Piano/KnobsWidget.cpp new file mode 100644 index 0000000000..4b9e4360c2 --- /dev/null +++ b/Userland/Applications/Piano/KnobsWidget.cpp @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * Copyright (c) 2019-2020, William McPherson <willmcpherson2@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "KnobsWidget.h" +#include "MainWidget.h" +#include "TrackManager.h" +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Label.h> +#include <LibGUI/Slider.h> + +constexpr int max_attack = 1000; +constexpr int max_decay = 1000; +constexpr int max_sustain = 1000; +constexpr int max_release = 1000; +constexpr int max_delay = 8; + +KnobsWidget::KnobsWidget(TrackManager& track_manager, MainWidget& main_widget) + : m_track_manager(track_manager) + , m_main_widget(main_widget) +{ + set_layout<GUI::VerticalBoxLayout>(); + set_fill_with_background_color(true); + + m_labels_container = add<GUI::Widget>(); + m_labels_container->set_layout<GUI::HorizontalBoxLayout>(); + m_labels_container->set_fixed_height(20); + + m_octave_label = m_labels_container->add<GUI::Label>("Octave"); + m_wave_label = m_labels_container->add<GUI::Label>("Wave"); + m_attack_label = m_labels_container->add<GUI::Label>("Attack"); + m_decay_label = m_labels_container->add<GUI::Label>("Decay"); + m_sustain_label = m_labels_container->add<GUI::Label>("Sustain"); + m_release_label = m_labels_container->add<GUI::Label>("Release"); + m_delay_label = m_labels_container->add<GUI::Label>("Delay"); + + m_values_container = add<GUI::Widget>(); + m_values_container->set_layout<GUI::HorizontalBoxLayout>(); + m_values_container->set_fixed_height(10); + + m_octave_value = m_values_container->add<GUI::Label>(String::number(m_track_manager.octave())); + m_wave_value = m_values_container->add<GUI::Label>(wave_strings[m_track_manager.current_track().wave()]); + m_attack_value = m_values_container->add<GUI::Label>(String::number(m_track_manager.current_track().attack())); + m_decay_value = m_values_container->add<GUI::Label>(String::number(m_track_manager.current_track().decay())); + m_sustain_value = m_values_container->add<GUI::Label>(String::number(m_track_manager.current_track().sustain())); + m_release_value = m_values_container->add<GUI::Label>(String::number(m_track_manager.current_track().release())); + m_delay_value = m_values_container->add<GUI::Label>(String::number(m_track_manager.current_track().delay())); + + m_knobs_container = add<GUI::Widget>(); + m_knobs_container->set_layout<GUI::HorizontalBoxLayout>(); + + // FIXME: Implement vertical flipping in GUI::Slider, not here. + + m_octave_knob = m_knobs_container->add<GUI::VerticalSlider>(); + m_octave_knob->set_tooltip("Z: octave down, X: octave up"); + m_octave_knob->set_range(octave_min - 1, octave_max - 1); + m_octave_knob->set_value((octave_max - 1) - (m_track_manager.octave() - 1)); + m_octave_knob->on_change = [this](int value) { + int new_octave = octave_max - value; + if (m_change_underlying) + m_main_widget.set_octave_and_ensure_note_change(new_octave); + ASSERT(new_octave == m_track_manager.octave()); + m_octave_value->set_text(String::number(new_octave)); + }; + + m_wave_knob = m_knobs_container->add<GUI::VerticalSlider>(); + m_wave_knob->set_tooltip("C: cycle through waveforms"); + m_wave_knob->set_range(0, last_wave); + m_wave_knob->set_value(last_wave - m_track_manager.current_track().wave()); + m_wave_knob->on_change = [this](int value) { + int new_wave = last_wave - value; + if (m_change_underlying) + m_track_manager.current_track().set_wave(new_wave); + ASSERT(new_wave == m_track_manager.current_track().wave()); + m_wave_value->set_text(wave_strings[new_wave]); + }; + + m_attack_knob = m_knobs_container->add<GUI::VerticalSlider>(); + m_attack_knob->set_range(0, max_attack); + m_attack_knob->set_value(max_attack - m_track_manager.current_track().attack()); + m_attack_knob->set_step(100); + m_attack_knob->on_change = [this](int value) { + int new_attack = max_attack - value; + if (m_change_underlying) + m_track_manager.current_track().set_attack(new_attack); + ASSERT(new_attack == m_track_manager.current_track().attack()); + m_attack_value->set_text(String::number(new_attack)); + }; + + m_decay_knob = m_knobs_container->add<GUI::VerticalSlider>(); + m_decay_knob->set_range(0, max_decay); + m_decay_knob->set_value(max_decay - m_track_manager.current_track().decay()); + m_decay_knob->set_step(100); + m_decay_knob->on_change = [this](int value) { + int new_decay = max_decay - value; + if (m_change_underlying) + m_track_manager.current_track().set_decay(new_decay); + ASSERT(new_decay == m_track_manager.current_track().decay()); + m_decay_value->set_text(String::number(new_decay)); + }; + + m_sustain_knob = m_knobs_container->add<GUI::VerticalSlider>(); + m_sustain_knob->set_range(0, max_sustain); + m_sustain_knob->set_value(max_sustain - m_track_manager.current_track().sustain()); + m_sustain_knob->set_step(100); + m_sustain_knob->on_change = [this](int value) { + int new_sustain = max_sustain - value; + if (m_change_underlying) + m_track_manager.current_track().set_sustain(new_sustain); + ASSERT(new_sustain == m_track_manager.current_track().sustain()); + m_sustain_value->set_text(String::number(new_sustain)); + }; + + m_release_knob = m_knobs_container->add<GUI::VerticalSlider>(); + m_release_knob->set_range(0, max_release); + m_release_knob->set_value(max_release - m_track_manager.current_track().release()); + m_release_knob->set_step(100); + m_release_knob->on_change = [this](int value) { + int new_release = max_release - value; + if (m_change_underlying) + m_track_manager.current_track().set_release(new_release); + ASSERT(new_release == m_track_manager.current_track().release()); + m_release_value->set_text(String::number(new_release)); + }; + + m_delay_knob = m_knobs_container->add<GUI::VerticalSlider>(); + m_delay_knob->set_range(0, max_delay); + m_delay_knob->set_value(max_delay - m_track_manager.current_track().delay()); + m_delay_knob->on_change = [this](int value) { + int new_delay = max_delay - value; + if (m_change_underlying) + m_track_manager.current_track().set_delay(new_delay); + ASSERT(new_delay == m_track_manager.current_track().delay()); + m_delay_value->set_text(String::number(new_delay)); + }; +} + +KnobsWidget::~KnobsWidget() +{ +} + +void KnobsWidget::update_knobs() +{ + m_wave_knob->set_value(last_wave - m_track_manager.current_track().wave()); + + // FIXME: This is needed because when the slider is changed normally, we + // need to change the underlying value, but if the keyboard was used, we + // need to change the slider without changing the underlying value. + m_change_underlying = false; + + m_octave_knob->set_value(octave_max - m_track_manager.octave()); + m_wave_knob->set_value(last_wave - m_track_manager.current_track().wave()); + m_attack_knob->set_value(max_attack - m_track_manager.current_track().attack()); + m_decay_knob->set_value(max_decay - m_track_manager.current_track().decay()); + m_sustain_knob->set_value(max_sustain - m_track_manager.current_track().sustain()); + m_release_knob->set_value(max_release - m_track_manager.current_track().release()); + m_delay_knob->set_value(max_delay - m_track_manager.current_track().delay()); + + m_change_underlying = true; +} diff --git a/Userland/Applications/Piano/KnobsWidget.h b/Userland/Applications/Piano/KnobsWidget.h new file mode 100644 index 0000000000..d5abea5be0 --- /dev/null +++ b/Userland/Applications/Piano/KnobsWidget.h @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * Copyright (c) 2019-2020, William McPherson <willmcpherson2@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Frame.h> + +class TrackManager; +class MainWidget; + +class KnobsWidget final : public GUI::Frame { + C_OBJECT(KnobsWidget) +public: + virtual ~KnobsWidget() override; + + void update_knobs(); + +private: + KnobsWidget(TrackManager&, MainWidget&); + + TrackManager& m_track_manager; + MainWidget& m_main_widget; + + RefPtr<GUI::Widget> m_labels_container; + RefPtr<GUI::Label> m_octave_label; + RefPtr<GUI::Label> m_wave_label; + RefPtr<GUI::Label> m_attack_label; + RefPtr<GUI::Label> m_decay_label; + RefPtr<GUI::Label> m_sustain_label; + RefPtr<GUI::Label> m_release_label; + RefPtr<GUI::Label> m_delay_label; + + RefPtr<GUI::Widget> m_values_container; + RefPtr<GUI::Label> m_octave_value; + RefPtr<GUI::Label> m_wave_value; + RefPtr<GUI::Label> m_attack_value; + RefPtr<GUI::Label> m_decay_value; + RefPtr<GUI::Label> m_sustain_value; + RefPtr<GUI::Label> m_release_value; + RefPtr<GUI::Label> m_delay_value; + + RefPtr<GUI::Widget> m_knobs_container; + RefPtr<GUI::Slider> m_octave_knob; + RefPtr<GUI::Slider> m_wave_knob; + RefPtr<GUI::Slider> m_attack_knob; + RefPtr<GUI::Slider> m_decay_knob; + RefPtr<GUI::Slider> m_sustain_knob; + RefPtr<GUI::Slider> m_release_knob; + RefPtr<GUI::Slider> m_delay_knob; + + bool m_change_underlying { true }; +}; diff --git a/Userland/Applications/Piano/MainWidget.cpp b/Userland/Applications/Piano/MainWidget.cpp new file mode 100644 index 0000000000..ef40a4fd1a --- /dev/null +++ b/Userland/Applications/Piano/MainWidget.cpp @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * Copyright (c) 2019-2020, William McPherson <willmcpherson2@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "MainWidget.h" +#include "KeysWidget.h" +#include "KnobsWidget.h" +#include "RollWidget.h" +#include "SamplerWidget.h" +#include "TrackManager.h" +#include "WaveWidget.h" +#include <LibGUI/Action.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Menu.h> +#include <LibGUI/TabWidget.h> + +MainWidget::MainWidget(TrackManager& track_manager) + : m_track_manager(track_manager) +{ + set_layout<GUI::VerticalBoxLayout>(); + layout()->set_spacing(2); + layout()->set_margins({ 2, 2, 2, 2 }); + set_fill_with_background_color(true); + + m_wave_widget = add<WaveWidget>(track_manager); + m_wave_widget->set_fixed_height(100); + + m_tab_widget = add<GUI::TabWidget>(); + m_roll_widget = m_tab_widget->add_tab<RollWidget>("Piano Roll", track_manager); + + m_roll_widget->set_fixed_height(300); + + m_tab_widget->add_tab<SamplerWidget>("Sampler", track_manager); + + m_keys_and_knobs_container = add<GUI::Widget>(); + m_keys_and_knobs_container->set_layout<GUI::HorizontalBoxLayout>(); + m_keys_and_knobs_container->layout()->set_spacing(2); + m_keys_and_knobs_container->set_fixed_height(100); + m_keys_and_knobs_container->set_fill_with_background_color(true); + + m_keys_widget = m_keys_and_knobs_container->add<KeysWidget>(track_manager); + + m_knobs_widget = m_keys_and_knobs_container->add<KnobsWidget>(track_manager, *this); + m_knobs_widget->set_fixed_width(350); + + m_roll_widget->set_keys_widget(m_keys_widget); +} + +MainWidget::~MainWidget() +{ +} + +void MainWidget::add_actions(GUI::Menu& menu) +{ + menu.add_action(GUI::Action::create("Add track", { Mod_Ctrl, Key_T }, [&](auto&) { + m_track_manager.add_track(); + })); + + menu.add_action(GUI::Action::create("Next track", { Mod_Ctrl, Key_N }, [&](auto&) { + turn_off_pressed_keys(); + m_track_manager.next_track(); + turn_on_pressed_keys(); + + m_knobs_widget->update_knobs(); + })); +} + +// FIXME: There are some unnecessary calls to update() throughout this program, +// which are an easy target for optimization. + +void MainWidget::custom_event(Core::CustomEvent&) +{ + m_wave_widget->update(); + m_roll_widget->update(); +} + +void MainWidget::keydown_event(GUI::KeyEvent& event) +{ + // This is to stop held-down keys from creating multiple events. + if (m_keys_pressed[event.key()]) + return; + else + m_keys_pressed[event.key()] = true; + + note_key_action(event.key(), On); + special_key_action(event.key()); + m_keys_widget->update(); +} + +void MainWidget::keyup_event(GUI::KeyEvent& event) +{ + m_keys_pressed[event.key()] = false; + + note_key_action(event.key(), Off); + m_keys_widget->update(); +} + +void MainWidget::note_key_action(int key_code, Switch switch_note) +{ + int key = m_keys_widget->key_code_to_key(key_code); + m_keys_widget->set_key(key, switch_note); +} + +void MainWidget::special_key_action(int key_code) +{ + switch (key_code) { + case Key_Z: + set_octave_and_ensure_note_change(Down); + break; + case Key_X: + set_octave_and_ensure_note_change(Up); + break; + case Key_C: + m_track_manager.current_track().set_wave(Up); + m_knobs_widget->update_knobs(); + break; + } +} + +void MainWidget::turn_off_pressed_keys() +{ + m_keys_widget->set_key(m_keys_widget->mouse_note(), Off); + for (int i = 0; i < key_code_count; ++i) { + if (m_keys_pressed[i]) + note_key_action(i, Off); + } +} + +void MainWidget::turn_on_pressed_keys() +{ + m_keys_widget->set_key(m_keys_widget->mouse_note(), On); + for (int i = 0; i < key_code_count; ++i) { + if (m_keys_pressed[i]) + note_key_action(i, On); + } +} + +void MainWidget::set_octave_and_ensure_note_change(int octave) +{ + turn_off_pressed_keys(); + m_track_manager.set_octave(octave); + turn_on_pressed_keys(); + + m_knobs_widget->update_knobs(); + m_keys_widget->update(); +} + +void MainWidget::set_octave_and_ensure_note_change(Direction direction) +{ + turn_off_pressed_keys(); + m_track_manager.set_octave(direction); + turn_on_pressed_keys(); + + m_knobs_widget->update_knobs(); + m_keys_widget->update(); +} diff --git a/Userland/Applications/Piano/MainWidget.h b/Userland/Applications/Piano/MainWidget.h new file mode 100644 index 0000000000..a2a6a35a0c --- /dev/null +++ b/Userland/Applications/Piano/MainWidget.h @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * Copyright (c) 2019-2020, William McPherson <willmcpherson2@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Music.h" +#include <LibGUI/Widget.h> + +class TrackManager; +class WaveWidget; +class RollWidget; +class SamplerWidget; +class KeysWidget; +class KnobsWidget; + +class MainWidget final : public GUI::Widget { + C_OBJECT(MainWidget) +public: + virtual ~MainWidget() override; + + void add_actions(GUI::Menu&); + + void set_octave_and_ensure_note_change(Direction); + void set_octave_and_ensure_note_change(int); + +private: + explicit MainWidget(TrackManager&); + + virtual void keydown_event(GUI::KeyEvent&) override; + virtual void keyup_event(GUI::KeyEvent&) override; + virtual void custom_event(Core::CustomEvent&) override; + + void note_key_action(int key_code, Switch); + void special_key_action(int key_code); + + void turn_off_pressed_keys(); + void turn_on_pressed_keys(); + + TrackManager& m_track_manager; + + RefPtr<WaveWidget> m_wave_widget; + RefPtr<RollWidget> m_roll_widget; + RefPtr<SamplerWidget> m_sampler_widget; + RefPtr<GUI::TabWidget> m_tab_widget; + RefPtr<GUI::Widget> m_keys_and_knobs_container; + RefPtr<KeysWidget> m_keys_widget; + RefPtr<KnobsWidget> m_knobs_widget; + + bool m_keys_pressed[key_code_count] { false }; +}; diff --git a/Userland/Applications/Piano/Music.h b/Userland/Applications/Piano/Music.h new file mode 100644 index 0000000000..3a150944ec --- /dev/null +++ b/Userland/Applications/Piano/Music.h @@ -0,0 +1,329 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * Copyright (c) 2019-2020, William McPherson <willmcpherson2@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/Types.h> +#include <LibGfx/Color.h> + +namespace Music { + +// CD quality +// - Stereo +// - 16 bit +// - 44,100 samples/sec +// - 1,411.2 kbps + +struct Sample { + i16 left; + i16 right; +}; + +constexpr int sample_count = 1024; + +constexpr int buffer_size = sample_count * sizeof(Sample); + +constexpr double sample_rate = 44100; + +constexpr double volume = 1800; + +enum Switch { + Off, + On, +}; + +struct RollNote { + u32 length() const { return (off_sample - on_sample) + 1; } + + u32 on_sample; + u32 off_sample; +}; + +enum Direction { + Down, + Up, +}; + +enum Wave { + Sine, + Triangle, + Square, + Saw, + Noise, + RecordedSample, +}; + +constexpr const char* wave_strings[] = { + "Sine", + "Triangle", + "Square", + "Saw", + "Noise", + "Sample", +}; + +constexpr int first_wave = Sine; +constexpr int last_wave = RecordedSample; + +enum Envelope { + Done, + Attack, + Decay, + Release, +}; + +enum KeyColor { + White, + Black, +}; + +constexpr KeyColor key_pattern[] = { + White, + Black, + White, + Black, + White, + White, + Black, + White, + Black, + White, + Black, + White, +}; + +const Color note_pressed_color(64, 64, 255); +const Color column_playing_color(128, 128, 255); + +const Color left_wave_colors[] = { + // Sine + { + 255, + 192, + 0, + }, + // Triangle + { + 35, + 171, + 35, + }, + // Square + { + 128, + 160, + 255, + }, + // Saw + { + 240, + 100, + 128, + }, + // Noise + { + 197, + 214, + 225, + }, + // RecordedSample + { + 227, + 39, + 39, + }, +}; + +const Color right_wave_colors[] = { + // Sine + { + 255, + 223, + 0, + }, + // Triangle + { + 35, + 171, + 90, + }, + // Square + { + 139, + 128, + 255, + }, + // Saw + { + 240, + 100, + 220, + }, + // Noise + { + 197, + 223, + 225, + }, + // RecordedSample + { + 227, + 105, + 39, + }, +}; + +constexpr int notes_per_octave = 12; +constexpr int white_keys_per_octave = 7; +constexpr int black_keys_per_octave = 5; +constexpr int octave_min = 1; +constexpr int octave_max = 7; + +constexpr double beats_per_minute = 60; +constexpr int beats_per_bar = 4; +constexpr int notes_per_beat = 4; +constexpr int roll_length = (sample_rate / (beats_per_minute / 60)) * beats_per_bar; + +constexpr const char* note_names[] = { + "C", + "C#", + "D", + "D#", + "E", + "F", + "F#", + "G", + "G#", + "A", + "A#", + "B", +}; + +// Equal temperament, A = 440Hz +// We calculate note frequencies relative to A4: +// 440.0 * pow(pow(2.0, 1.0 / 12.0), N) +// Where N is the note distance from A. +constexpr double note_frequencies[] = { + // Octave 1 + 32.703195662574764, + 34.647828872108946, + 36.708095989675876, + 38.890872965260044, + 41.203444614108669, + 43.653528929125407, + 46.249302838954222, + 48.99942949771858, + 51.913087197493056, + 54.999999999999915, + 58.270470189761156, + 61.735412657015416, + // Octave 2 + 65.406391325149571, + 69.295657744217934, + 73.416191979351794, + 77.781745930520117, + 82.406889228217381, + 87.307057858250872, + 92.4986056779085, + 97.998858995437217, + 103.82617439498618, + 109.99999999999989, + 116.54094037952237, + 123.4708253140309, + // Octave 3 + 130.8127826502992, + 138.59131548843592, + 146.83238395870364, + 155.56349186104035, + 164.81377845643485, + 174.61411571650183, + 184.99721135581709, + 195.99771799087452, + 207.65234878997245, + 219.99999999999989, + 233.08188075904488, + 246.94165062806198, + // Octave 4 + 261.62556530059851, + 277.18263097687202, + 293.66476791740746, + 311.12698372208081, + 329.62755691286986, + 349.22823143300383, + 369.99442271163434, + 391.99543598174927, + 415.30469757994513, + 440, + 466.16376151808993, + 493.88330125612413, + // Octave 5 + 523.25113060119736, + 554.36526195374427, + 587.32953583481526, + 622.25396744416196, + 659.25511382574007, + 698.456462866008, + 739.98884542326903, + 783.99087196349899, + 830.60939515989071, + 880.00000000000034, + 932.32752303618031, + 987.76660251224882, + // Octave 6 + 1046.5022612023952, + 1108.7305239074892, + 1174.659071669631, + 1244.5079348883246, + 1318.5102276514808, + 1396.9129257320169, + 1479.977690846539, + 1567.9817439269987, + 1661.2187903197821, + 1760.000000000002, + 1864.6550460723618, + 1975.5332050244986, + // Octave 7 + 2093.0045224047913, + 2217.4610478149793, + 2349.3181433392633, + 2489.0158697766506, + 2637.020455302963, + 2793.8258514640347, + 2959.9553816930793, + 3135.9634878539991, + 3322.437580639566, + 3520.0000000000055, + 3729.3100921447249, + 3951.0664100489994, +}; +constexpr int note_count = sizeof(note_frequencies) / sizeof(double); + +constexpr double middle_c = note_frequencies[36]; + +} + +using namespace Music; diff --git a/Userland/Applications/Piano/RollWidget.cpp b/Userland/Applications/Piano/RollWidget.cpp new file mode 100644 index 0000000000..2677fed384 --- /dev/null +++ b/Userland/Applications/Piano/RollWidget.cpp @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * Copyright (c) 2019-2020, William McPherson <willmcpherson2@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "RollWidget.h" +#include "TrackManager.h" +#include <LibGUI/Painter.h> +#include <LibGUI/ScrollBar.h> +#include <LibGfx/Font.h> +#include <LibGfx/FontDatabase.h> +#include <math.h> + +constexpr int note_height = 20; +constexpr int max_note_width = note_height * 2; +constexpr int roll_height = note_count * note_height; +constexpr int horizontal_scroll_sensitivity = 20; +constexpr int max_zoom = 1 << 8; + +RollWidget::RollWidget(TrackManager& track_manager) + : m_track_manager(track_manager) +{ + set_should_hide_unnecessary_scrollbars(true); + set_content_size({ 0, roll_height }); + vertical_scrollbar().set_value(roll_height / 2); +} + +RollWidget::~RollWidget() +{ +} + +void RollWidget::paint_event(GUI::PaintEvent& event) +{ + m_roll_width = widget_inner_rect().width() * m_zoom_level; + set_content_size({ m_roll_width, roll_height }); + + // Divide the roll by the maximum note width. If we get fewer notes than + // our time signature requires, round up. Otherwise, round down to the + // nearest x*(2^y), where x is the base number of notes of our time + // signature. In other words, find a number that is a double of our time + // signature. For 4/4 that would be 16, 32, 64, 128 ... + m_num_notes = m_roll_width / max_note_width; + int time_signature_notes = beats_per_bar * notes_per_beat; + if (m_num_notes < time_signature_notes) + m_num_notes = time_signature_notes; + else + m_num_notes = time_signature_notes * pow(2, static_cast<int>(log2(m_num_notes / time_signature_notes))); + m_note_width = static_cast<double>(m_roll_width) / m_num_notes; + + // This calculates the minimum number of rows needed. We account for a + // partial row at the top and/or bottom. + int y_offset = vertical_scrollbar().value(); + int note_offset = y_offset / note_height; + int note_offset_remainder = y_offset % note_height; + int paint_area = widget_inner_rect().height() + note_offset_remainder; + if (paint_area % note_height != 0) + paint_area += note_height; + int notes_to_paint = paint_area / note_height; + int key_pattern_index = (notes_per_octave - 1) - (note_offset % notes_per_octave); + + int x_offset = horizontal_scrollbar().value(); + int horizontal_note_offset_remainder = fmod(x_offset, m_note_width); + int horizontal_paint_area = widget_inner_rect().width() + horizontal_note_offset_remainder; + if (fmod(horizontal_paint_area, m_note_width) != 0) + horizontal_paint_area += m_note_width; + int horizontal_notes_to_paint = horizontal_paint_area / m_note_width; + + GUI::Painter painter(*this); + painter.translate(frame_thickness(), frame_thickness()); + painter.add_clip_rect(event.rect()); + painter.translate(-horizontal_note_offset_remainder, -note_offset_remainder); + + for (int y = 0; y < notes_to_paint; ++y) { + int y_pos = y * note_height; + + int note = (note_count - note_offset - 1) - y; + for (int x = 0; x < horizontal_notes_to_paint; ++x) { + // This is needed to avoid rounding errors. You can't just use + // m_note_width as the width. + int x_pos = x * m_note_width; + int next_x_pos = (x + 1) * m_note_width; + int distance_to_next_x = next_x_pos - x_pos; + Gfx::IntRect rect(x_pos, y_pos, distance_to_next_x, note_height); + + if (key_pattern[key_pattern_index] == Black) + painter.fill_rect(rect, Color::LightGray); + else + painter.fill_rect(rect, Color::White); + + if (keys_widget() && keys_widget()->note_is_set(note)) + painter.fill_rect(rect, note_pressed_color.with_alpha(128)); + + painter.draw_line(rect.top_right(), rect.bottom_right(), Color::Black); + painter.draw_line(rect.bottom_left(), rect.bottom_right(), Color::Black); + } + + if (--key_pattern_index == -1) + key_pattern_index = notes_per_octave - 1; + } + + painter.translate(-x_offset, -y_offset); + painter.translate(horizontal_note_offset_remainder, note_offset_remainder); + + for (int note = note_count - (note_offset + notes_to_paint); note <= (note_count - 1) - note_offset; ++note) { + int y = ((note_count - 1) - note) * note_height; + for (auto roll_note : m_track_manager.current_track().roll_notes(note)) { + int x = m_roll_width * (static_cast<double>(roll_note.on_sample) / roll_length); + int width = m_roll_width * (static_cast<double>(roll_note.length()) / roll_length); + if (x + width < x_offset || x > x_offset + widget_inner_rect().width()) + continue; + if (width < 2) + width = 2; + + int height = note_height; + + Gfx::IntRect rect(x, y, width, height); + painter.fill_rect(rect, note_pressed_color); + painter.draw_rect(rect, Color::Black); + } + Gfx::IntRect note_name_rect(3, y, 1, note_height); + const char* note_name = note_names[note % notes_per_octave]; + + painter.draw_text(note_name_rect, note_name, Gfx::TextAlignment::CenterLeft); + note_name_rect.move_by(Gfx::FontDatabase::default_font().width(note_name) + 2, 0); + if (note % notes_per_octave == 0) + painter.draw_text(note_name_rect, String::formatted("{}", note / notes_per_octave + 1), Gfx::TextAlignment::CenterLeft); + } + + int x = m_roll_width * (static_cast<double>(m_track_manager.time()) / roll_length); + if (x > x_offset && x <= x_offset + widget_inner_rect().width()) + painter.draw_line({ x, 0 }, { x, roll_height }, Gfx::Color::Black); + + GUI::Frame::paint_event(event); +} + +void RollWidget::mousedown_event(GUI::MouseEvent& event) +{ + if (!widget_inner_rect().contains(event.x(), event.y())) + return; + + m_note_drag_start = event.position(); + + int y = (m_note_drag_start.value().y() + vertical_scrollbar().value()) - frame_thickness(); + y /= note_height; + m_drag_note = (note_count - 1) - y; + + mousemove_event(event); +} + +void RollWidget::mousemove_event(GUI::MouseEvent& event) +{ + if (!m_note_drag_start.has_value()) + return; + + if (m_note_drag_location.has_value()) { + // Clear previous note + m_track_manager.current_track().set_roll_note(m_drag_note, m_note_drag_location.value().on_sample, m_note_drag_location.value().off_sample); + } + + auto get_note_x = [&](int x0) { + // There's a case where we can't just use x / m_note_width. For example, if + // your m_note_width is 3.1 you will have a rect starting at 3. When that + // leftmost pixel of the rect is clicked you will do 3 / 3.1 which is 0 + // and not 1. We can avoid that case by shifting x by 1 if m_note_width is + // fractional, being careful not to shift out of bounds. + int x = (x0 + horizontal_scrollbar().value()) - frame_thickness(); + bool note_width_is_fractional = m_note_width - static_cast<int>(m_note_width) != 0; + bool x_is_not_last = x != widget_inner_rect().width() - 1; + if (note_width_is_fractional && x_is_not_last) + ++x; + x /= m_note_width; + return x; + }; + + int x0 = get_note_x(m_note_drag_start.value().x()); + int x1 = get_note_x(event.x()); + + u32 on_sample = roll_length * (static_cast<double>(min(x0, x1)) / m_num_notes); + u32 off_sample = (roll_length * (static_cast<double>(max(x0, x1) + 1) / m_num_notes)) - 1; + m_track_manager.current_track().set_roll_note(m_drag_note, on_sample, off_sample); + m_note_drag_location = RollNote({ on_sample, off_sample }); + + update(); +} + +void RollWidget::mouseup_event([[maybe_unused]] GUI::MouseEvent& event) +{ + m_note_drag_start = {}; + m_note_drag_location = {}; +} + +// FIXME: Implement zoom and horizontal scroll events in LibGUI, not here. +void RollWidget::mousewheel_event(GUI::MouseEvent& event) +{ + if (event.modifiers() & KeyModifier::Mod_Shift) { + horizontal_scrollbar().set_value(horizontal_scrollbar().value() + (event.wheel_delta() * horizontal_scroll_sensitivity)); + return; + } + + if (!(event.modifiers() & KeyModifier::Mod_Ctrl)) { + GUI::ScrollableWidget::mousewheel_event(event); + return; + } + + double multiplier = event.wheel_delta() >= 0 ? 0.5 : 2; + + if (m_zoom_level * multiplier > max_zoom) + return; + + if (m_zoom_level * multiplier < 1) { + if (m_zoom_level == 1) + return; + m_zoom_level = 1; + } else { + m_zoom_level *= multiplier; + } + + int absolute_x_of_pixel_at_cursor = horizontal_scrollbar().value() + event.position().x(); + int absolute_x_of_pixel_at_cursor_after_resize = absolute_x_of_pixel_at_cursor * multiplier; + int new_scrollbar = absolute_x_of_pixel_at_cursor_after_resize - event.position().x(); + + m_roll_width = widget_inner_rect().width() * m_zoom_level; + set_content_size({ m_roll_width, roll_height }); + + horizontal_scrollbar().set_value(new_scrollbar); +} diff --git a/Userland/Applications/Piano/RollWidget.h b/Userland/Applications/Piano/RollWidget.h new file mode 100644 index 0000000000..bbfcc8bb62 --- /dev/null +++ b/Userland/Applications/Piano/RollWidget.h @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * Copyright (c) 2019-2020, William McPherson <willmcpherson2@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "KeysWidget.h" +#include "Music.h" +#include <LibGUI/ScrollableWidget.h> + +class TrackManager; + +class RollWidget final : public GUI::ScrollableWidget { + C_OBJECT(RollWidget) +public: + virtual ~RollWidget() override; + + const KeysWidget* keys_widget() const { return m_keys_widget; } + void set_keys_widget(const KeysWidget* widget) { m_keys_widget = widget; } + +private: + explicit RollWidget(TrackManager&); + + virtual void paint_event(GUI::PaintEvent&) override; + virtual void mousedown_event(GUI::MouseEvent& event) override; + virtual void mousemove_event(GUI::MouseEvent& event) override; + virtual void mouseup_event(GUI::MouseEvent& event) override; + virtual void mousewheel_event(GUI::MouseEvent&) override; + + TrackManager& m_track_manager; + const KeysWidget* m_keys_widget; + + int m_roll_width { 0 }; + int m_num_notes { 0 }; + double m_note_width { 0.0 }; + int m_zoom_level { 1 }; + + Optional<Gfx::IntPoint> m_note_drag_start; + Optional<RollNote> m_note_drag_location; + int m_drag_note; +}; diff --git a/Userland/Applications/Piano/SamplerWidget.cpp b/Userland/Applications/Piano/SamplerWidget.cpp new file mode 100644 index 0000000000..72704f28c7 --- /dev/null +++ b/Userland/Applications/Piano/SamplerWidget.cpp @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2020, William McPherson <willmcpherson2@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "SamplerWidget.h" +#include "TrackManager.h" +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/FilePicker.h> +#include <LibGUI/Label.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/Painter.h> + +WaveEditor::WaveEditor(TrackManager& track_manager) + : m_track_manager(track_manager) +{ +} + +WaveEditor::~WaveEditor() +{ +} + +int WaveEditor::sample_to_y(double percentage) const +{ + double portion_of_half_height = percentage * ((frame_inner_rect().height() - 1) / 2.0); + double y = (frame_inner_rect().height() / 2.0) + portion_of_half_height; + return y; +} + +void WaveEditor::paint_event(GUI::PaintEvent& event) +{ + GUI::Frame::paint_event(event); + + GUI::Painter painter(*this); + painter.fill_rect(frame_inner_rect(), Color::Black); + + auto recorded_sample = m_track_manager.current_track().recorded_sample(); + if (recorded_sample.is_empty()) + return; + + double width_scale = static_cast<double>(frame_inner_rect().width()) / recorded_sample.size(); + + painter.translate(frame_thickness(), frame_thickness()); + + int prev_x = 0; + int left_prev_y = sample_to_y(recorded_sample[0].left); + int right_prev_y = sample_to_y(recorded_sample[0].right); + painter.set_pixel({ prev_x, left_prev_y }, left_wave_colors[RecordedSample]); + painter.set_pixel({ prev_x, right_prev_y }, right_wave_colors[RecordedSample]); + + for (size_t x = 1; x < recorded_sample.size(); ++x) { + int left_y = sample_to_y(recorded_sample[x].left); + int right_y = sample_to_y(recorded_sample[x].right); + + Gfx::IntPoint left_point1(prev_x * width_scale, left_prev_y); + Gfx::IntPoint left_point2(x * width_scale, left_y); + painter.draw_line(left_point1, left_point2, left_wave_colors[RecordedSample]); + + Gfx::IntPoint right_point1(prev_x * width_scale, right_prev_y); + Gfx::IntPoint right_point2(x * width_scale, right_y); + painter.draw_line(right_point1, right_point2, right_wave_colors[RecordedSample]); + + prev_x = x; + left_prev_y = left_y; + right_prev_y = right_y; + } +} + +SamplerWidget::SamplerWidget(TrackManager& track_manager) + : m_track_manager(track_manager) +{ + set_layout<GUI::VerticalBoxLayout>(); + layout()->set_margins({ 10, 10, 10, 10 }); + layout()->set_spacing(10); + set_fill_with_background_color(true); + + m_open_button_and_recorded_sample_name_container = add<GUI::Widget>(); + m_open_button_and_recorded_sample_name_container->set_layout<GUI::HorizontalBoxLayout>(); + m_open_button_and_recorded_sample_name_container->layout()->set_spacing(10); + m_open_button_and_recorded_sample_name_container->set_fixed_height(24); + + m_open_button = m_open_button_and_recorded_sample_name_container->add<GUI::Button>(); + m_open_button->set_fixed_size(24, 24); + m_open_button->set_focus_policy(GUI::FocusPolicy::TabFocus); + m_open_button->set_icon(Gfx::Bitmap::load_from_file("/res/icons/16x16/open.png")); + m_open_button->on_click = [this](auto) { + Optional<String> open_path = GUI::FilePicker::get_open_filepath(window()); + if (!open_path.has_value()) + return; + String error_string = m_track_manager.current_track().set_recorded_sample(open_path.value()); + if (!error_string.is_empty()) { + GUI::MessageBox::show(window(), String::formatted("Failed to load WAV file: {}", error_string.characters()), "Error", GUI::MessageBox::Type::Error); + return; + } + m_recorded_sample_name->set_text(open_path.value()); + m_wave_editor->update(); + }; + + m_recorded_sample_name = m_open_button_and_recorded_sample_name_container->add<GUI::Label>("No sample loaded"); + m_recorded_sample_name->set_text_alignment(Gfx::TextAlignment::CenterLeft); + + m_wave_editor = add<WaveEditor>(m_track_manager); + m_wave_editor->set_fixed_height(100); +} + +SamplerWidget::~SamplerWidget() +{ +} diff --git a/Userland/Applications/Piano/SamplerWidget.h b/Userland/Applications/Piano/SamplerWidget.h new file mode 100644 index 0000000000..b99dbcbe02 --- /dev/null +++ b/Userland/Applications/Piano/SamplerWidget.h @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2020, William McPherson <willmcpherson2@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Frame.h> + +class TrackManager; + +class WaveEditor final : public GUI::Frame { + C_OBJECT(WaveEditor) +public: + virtual ~WaveEditor() override; + +private: + explicit WaveEditor(TrackManager&); + + virtual void paint_event(GUI::PaintEvent&) override; + + int sample_to_y(double percentage) const; + + TrackManager& m_track_manager; +}; + +class SamplerWidget final : public GUI::Frame { + C_OBJECT(SamplerWidget) +public: + virtual ~SamplerWidget() override; + +private: + explicit SamplerWidget(TrackManager&); + + TrackManager& m_track_manager; + + RefPtr<GUI::Widget> m_open_button_and_recorded_sample_name_container; + RefPtr<GUI::Button> m_open_button; + RefPtr<GUI::Label> m_recorded_sample_name; + RefPtr<WaveEditor> m_wave_editor; +}; diff --git a/Userland/Applications/Piano/Track.cpp b/Userland/Applications/Piano/Track.cpp new file mode 100644 index 0000000000..61d4351d06 --- /dev/null +++ b/Userland/Applications/Piano/Track.cpp @@ -0,0 +1,369 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * Copyright (c) 2019-2020, William McPherson <willmcpherson2@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "Track.h" +#include <AK/NumericLimits.h> +#include <LibAudio/Loader.h> +#include <math.h> + +Track::Track(const u32& time) + : m_time(time) +{ + set_sustain_impl(1000); + set_attack(5); + set_decay(1000); + set_release(5); +} + +Track::~Track() +{ +} + +void Track::fill_sample(Sample& sample) +{ + Audio::Sample new_sample; + + for (size_t note = 0; note < note_count; ++note) { + if (!m_roll_iters[note].is_end()) { + if (m_roll_iters[note]->on_sample == m_time) { + set_note(note, On); + } else if (m_roll_iters[note]->off_sample == m_time) { + set_note(note, Off); + ++m_roll_iters[note]; + if (m_roll_iters[note].is_end()) + m_roll_iters[note] = m_roll_notes[note].begin(); + } + } + + switch (m_envelope[note]) { + case Done: + continue; + case Attack: + m_power[note] += m_attack_step; + if (m_power[note] >= 1) { + m_power[note] = 1; + m_envelope[note] = Decay; + } + break; + case Decay: + m_power[note] -= m_decay_step; + if (m_power[note] < m_sustain_level) + m_power[note] = m_sustain_level; + break; + case Release: + m_power[note] -= m_release_step[note]; + if (m_power[note] <= 0) { + m_power[note] = 0; + m_envelope[note] = Done; + continue; + } + break; + default: + ASSERT_NOT_REACHED(); + } + + Audio::Sample note_sample; + switch (m_wave) { + case Wave::Sine: + note_sample = sine(note); + break; + case Wave::Saw: + note_sample = saw(note); + break; + case Wave::Square: + note_sample = square(note); + break; + case Wave::Triangle: + note_sample = triangle(note); + break; + case Wave::Noise: + note_sample = noise(); + break; + case Wave::RecordedSample: + note_sample = recorded_sample(note); + break; + default: + ASSERT_NOT_REACHED(); + } + new_sample.left += note_sample.left * m_power[note] * volume; + new_sample.right += note_sample.right * m_power[note] * volume; + } + + if (m_delay) { + new_sample.left += m_delay_buffer[m_delay_index].left * 0.333333; + new_sample.right += m_delay_buffer[m_delay_index].right * 0.333333; + m_delay_buffer[m_delay_index].left = new_sample.left; + m_delay_buffer[m_delay_index].right = new_sample.right; + if (++m_delay_index >= m_delay_samples) + m_delay_index = 0; + } + + sample.left += new_sample.left; + sample.right += new_sample.right; +} + +void Track::reset() +{ + memset(m_delay_buffer.data(), 0, m_delay_buffer.size() * sizeof(Sample)); + m_delay_index = 0; + + memset(m_note_on, 0, sizeof(m_note_on)); + memset(m_power, 0, sizeof(m_power)); + memset(m_envelope, 0, sizeof(m_envelope)); +} + +String Track::set_recorded_sample(const StringView& path) +{ + NonnullRefPtr<Audio::Loader> loader = Audio::Loader::create(path); + if (loader->has_error()) + return String(loader->error_string()); + auto buffer = loader->get_more_samples(60 * sample_rate * sizeof(Sample)); // 1 minute maximum + + if (!m_recorded_sample.is_empty()) + m_recorded_sample.clear(); + m_recorded_sample.resize(buffer->sample_count()); + + double peak = 0; + for (int i = 0; i < buffer->sample_count(); ++i) { + double left_abs = fabs(buffer->samples()[i].left); + double right_abs = fabs(buffer->samples()[i].right); + if (left_abs > peak) + peak = left_abs; + if (right_abs > peak) + peak = right_abs; + } + + if (peak) { + for (int i = 0; i < buffer->sample_count(); ++i) { + m_recorded_sample[i].left = buffer->samples()[i].left / peak; + m_recorded_sample[i].right = buffer->samples()[i].right / peak; + } + } + + return String::empty(); +} + +// All of the information for these waves is on Wikipedia. + +Audio::Sample Track::sine(size_t note) +{ + double pos = note_frequencies[note] / sample_rate; + double sin_step = pos * 2 * M_PI; + double w = sin(m_pos[note]); + m_pos[note] += sin_step; + return w; +} + +Audio::Sample Track::saw(size_t note) +{ + double saw_step = note_frequencies[note] / sample_rate; + double t = m_pos[note]; + double w = (0.5 - (t - floor(t))) * 2; + m_pos[note] += saw_step; + return w; +} + +Audio::Sample Track::square(size_t note) +{ + double pos = note_frequencies[note] / sample_rate; + double square_step = pos * 2 * M_PI; + double w = sin(m_pos[note]) >= 0 ? 1 : -1; + m_pos[note] += square_step; + return w; +} + +Audio::Sample Track::triangle(size_t note) +{ + double triangle_step = note_frequencies[note] / sample_rate; + double t = m_pos[note]; + double w = fabs(fmod((4 * t) + 1, 4) - 2) - 1; + m_pos[note] += triangle_step; + return w; +} + +Audio::Sample Track::noise() const +{ + double random_percentage = static_cast<double>(rand()) / RAND_MAX; + double w = (random_percentage * 2) - 1; + return w; +} + +Audio::Sample Track::recorded_sample(size_t note) +{ + int t = m_pos[note]; + if (t >= static_cast<int>(m_recorded_sample.size())) + return 0; + double w_left = m_recorded_sample[t].left; + double w_right = m_recorded_sample[t].right; + if (t + 1 < static_cast<int>(m_recorded_sample.size())) { + double t_fraction = m_pos[note] - t; + w_left += (m_recorded_sample[t + 1].left - m_recorded_sample[t].left) * t_fraction; + w_right += (m_recorded_sample[t + 1].right - m_recorded_sample[t].right) * t_fraction; + } + double recorded_sample_step = note_frequencies[note] / middle_c; + m_pos[note] += recorded_sample_step; + return { w_left, w_right }; +} + +static inline double calculate_step(double distance, int milliseconds) +{ + if (milliseconds == 0) + return distance; + + constexpr double samples_per_millisecond = sample_rate / 1000.0; + double samples = milliseconds * samples_per_millisecond; + double step = distance / samples; + return step; +} + +void Track::set_note(int note, Switch switch_note) +{ + ASSERT(note >= 0 && note < note_count); + + if (switch_note == On) { + if (m_note_on[note] == 0) { + m_pos[note] = 0; + m_envelope[note] = Attack; + } + ++m_note_on[note]; + } else { + if (m_note_on[note] >= 1) { + if (m_note_on[note] == 1) { + m_release_step[note] = calculate_step(m_power[note], m_release); + m_envelope[note] = Release; + } + --m_note_on[note]; + } + } + + ASSERT(m_note_on[note] != NumericLimits<u8>::max()); + ASSERT(m_power[note] >= 0); +} + +void Track::sync_roll(int note) +{ + auto it = m_roll_notes[note].find_if([&](auto& roll_note) { return roll_note.off_sample > m_time; }); + if (it.is_end()) + m_roll_iters[note] = m_roll_notes[note].begin(); + else + m_roll_iters[note] = it; +} + +void Track::set_roll_note(int note, u32 on_sample, u32 off_sample) +{ + RollNote new_roll_note = { on_sample, off_sample }; + + ASSERT(note >= 0 && note < note_count); + ASSERT(new_roll_note.off_sample < roll_length); + ASSERT(new_roll_note.length() >= 2); + + for (auto it = m_roll_notes[note].begin(); !it.is_end();) { + if (it->on_sample > new_roll_note.off_sample) { + m_roll_notes[note].insert_before(it, new_roll_note); + sync_roll(note); + return; + } + if (it->on_sample <= new_roll_note.on_sample && it->off_sample >= new_roll_note.on_sample) { + if (m_time >= it->on_sample && m_time <= it->off_sample) + set_note(note, Off); + m_roll_notes[note].remove(it); + sync_roll(note); + return; + } + if ((new_roll_note.on_sample == 0 || it->on_sample >= new_roll_note.on_sample - 1) && it->on_sample <= new_roll_note.off_sample) { + if (m_time >= new_roll_note.off_sample && m_time <= it->off_sample) + set_note(note, Off); + m_roll_notes[note].remove(it); + it = m_roll_notes[note].begin(); + continue; + } + ++it; + } + + m_roll_notes[note].append(new_roll_note); + sync_roll(note); +} + +void Track::set_wave(int wave) +{ + ASSERT(wave >= first_wave && wave <= last_wave); + m_wave = wave; +} + +void Track::set_wave(Direction direction) +{ + if (direction == Up) { + if (++m_wave > last_wave) + m_wave = first_wave; + } else { + if (--m_wave < first_wave) + m_wave = last_wave; + } +} + +void Track::set_attack(int attack) +{ + ASSERT(attack >= 0); + m_attack = attack; + m_attack_step = calculate_step(1, m_attack); +} + +void Track::set_decay(int decay) +{ + ASSERT(decay >= 0); + m_decay = decay; + m_decay_step = calculate_step(1 - m_sustain_level, m_decay); +} + +void Track::set_sustain_impl(int sustain) +{ + ASSERT(sustain >= 0); + m_sustain = sustain; + m_sustain_level = sustain / 1000.0; +} + +void Track::set_sustain(int sustain) +{ + set_sustain_impl(sustain); + set_decay(m_decay); +} + +void Track::set_release(int release) +{ + ASSERT(release >= 0); + m_release = release; +} + +void Track::set_delay(int delay) +{ + ASSERT(delay >= 0); + m_delay = delay; + m_delay_samples = m_delay == 0 ? 0 : (sample_rate / (beats_per_minute / 60)) / m_delay; + m_delay_buffer.resize(m_delay_samples); + memset(m_delay_buffer.data(), 0, m_delay_buffer.size() * sizeof(Sample)); + m_delay_index = 0; +} diff --git a/Userland/Applications/Piano/Track.h b/Userland/Applications/Piano/Track.h new file mode 100644 index 0000000000..0acca429b0 --- /dev/null +++ b/Userland/Applications/Piano/Track.h @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * Copyright (c) 2019-2020, William McPherson <willmcpherson2@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Music.h" +#include <AK/Noncopyable.h> +#include <AK/SinglyLinkedList.h> +#include <LibAudio/Buffer.h> + +typedef AK::SinglyLinkedListIterator<SinglyLinkedList<RollNote>, RollNote> RollIter; + +class Track { + AK_MAKE_NONCOPYABLE(Track); + AK_MAKE_NONMOVABLE(Track); + +public: + explicit Track(const u32& time); + ~Track(); + + const Vector<Audio::Sample>& recorded_sample() const { return m_recorded_sample; } + const SinglyLinkedList<RollNote>& roll_notes(int note) const { return m_roll_notes[note]; } + int wave() const { return m_wave; } + int attack() const { return m_attack; } + int decay() const { return m_decay; } + int sustain() const { return m_sustain; } + int release() const { return m_release; } + int delay() const { return m_delay; } + + void fill_sample(Sample& sample); + void reset(); + String set_recorded_sample(const StringView& path); + void set_note(int note, Switch); + void set_roll_note(int note, u32 on_sample, u32 off_sample); + void set_wave(int wave); + void set_wave(Direction); + void set_attack(int attack); + void set_decay(int decay); + void set_sustain(int sustain); + void set_release(int release); + void set_delay(int delay); + +private: + Audio::Sample sine(size_t note); + Audio::Sample saw(size_t note); + Audio::Sample square(size_t note); + Audio::Sample triangle(size_t note); + Audio::Sample noise() const; + Audio::Sample recorded_sample(size_t note); + + void sync_roll(int note); + void set_sustain_impl(int sustain); + + Vector<Sample> m_delay_buffer; + + Vector<Audio::Sample> m_recorded_sample; + + u8 m_note_on[note_count] { 0 }; + double m_power[note_count] { 0 }; + double m_pos[note_count]; // Initialized lazily. + Envelope m_envelope[note_count] { Done }; + + int m_wave { first_wave }; + int m_attack; + double m_attack_step; + int m_decay; + double m_decay_step; + int m_sustain; + double m_sustain_level; + int m_release; + double m_release_step[note_count]; + int m_delay { 0 }; + size_t m_delay_samples { 0 }; + size_t m_delay_index { 0 }; + + const u32& m_time; + + SinglyLinkedList<RollNote> m_roll_notes[note_count]; + RollIter m_roll_iters[note_count]; +}; diff --git a/Userland/Applications/Piano/TrackManager.cpp b/Userland/Applications/Piano/TrackManager.cpp new file mode 100644 index 0000000000..5aa1856a11 --- /dev/null +++ b/Userland/Applications/Piano/TrackManager.cpp @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * Copyright (c) 2019-2020, William McPherson <willmcpherson2@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "TrackManager.h" + +TrackManager::TrackManager() +{ + add_track(); +} + +TrackManager::~TrackManager() +{ +} + +void TrackManager::fill_buffer(Span<Sample> buffer) +{ + memset(buffer.data(), 0, buffer_size); + + for (size_t i = 0; i < buffer.size(); ++i) { + for (auto& track : m_tracks) + track->fill_sample(buffer[i]); + + if (++m_time >= roll_length) { + m_time = 0; + if (!m_should_loop) + break; + } + } + + memcpy(m_current_back_buffer.data(), buffer.data(), buffer_size); + swap(m_current_front_buffer, m_current_back_buffer); +} + +void TrackManager::reset() +{ + memset(m_front_buffer.data(), 0, buffer_size); + memset(m_back_buffer.data(), 0, buffer_size); + + m_current_front_buffer = m_front_buffer.span(); + m_current_back_buffer = m_back_buffer.span(); + + m_time = 0; + + for (auto& track : m_tracks) + track->reset(); +} + +void TrackManager::set_note_current_octave(int note, Switch switch_note) +{ + current_track().set_note(note + octave_base(), switch_note); +} + +void TrackManager::set_octave(Direction direction) +{ + if (direction == Up) { + if (m_octave < octave_max) + ++m_octave; + } else { + if (m_octave > octave_min) + --m_octave; + } +} + +void TrackManager::set_octave(int octave) +{ + if (octave <= octave_max && octave >= octave_min) { + m_octave = octave; + } +} + +void TrackManager::add_track() +{ + m_tracks.append(make<Track>(m_time)); +} + +void TrackManager::next_track() +{ + if (++m_current_track >= m_tracks.size()) + m_current_track = 0; +} diff --git a/Userland/Applications/Piano/TrackManager.h b/Userland/Applications/Piano/TrackManager.h new file mode 100644 index 0000000000..70cb661bc4 --- /dev/null +++ b/Userland/Applications/Piano/TrackManager.h @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * Copyright (c) 2019-2020, William McPherson <willmcpherson2@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Music.h" +#include "Track.h" +#include <AK/Array.h> +#include <AK/Noncopyable.h> +#include <AK/NonnullOwnPtr.h> +#include <AK/Vector.h> + +class TrackManager { + AK_MAKE_NONCOPYABLE(TrackManager); + AK_MAKE_NONMOVABLE(TrackManager); + +public: + TrackManager(); + ~TrackManager(); + + Track& current_track() { return *m_tracks[m_current_track]; } + Span<const Sample> buffer() const { return m_current_front_buffer; } + int octave() const { return m_octave; } + int octave_base() const { return (m_octave - octave_min) * 12; } + int time() const { return m_time; } + + void fill_buffer(Span<Sample>); + void reset(); + void set_should_loop(bool b) { m_should_loop = b; } + void set_note_current_octave(int note, Switch); + void set_octave(Direction); + void set_octave(int octave); + void add_track(); + void next_track(); + +private: + Vector<NonnullOwnPtr<Track>> m_tracks; + size_t m_current_track { 0 }; + + Array<Sample, sample_count> m_front_buffer; + Array<Sample, sample_count> m_back_buffer; + Span<Sample> m_current_front_buffer { m_front_buffer.span() }; + Span<Sample> m_current_back_buffer { m_back_buffer.span() }; + + int m_octave { 4 }; + + u32 m_time { 0 }; + + bool m_should_loop { true }; +}; diff --git a/Userland/Applications/Piano/WaveWidget.cpp b/Userland/Applications/Piano/WaveWidget.cpp new file mode 100644 index 0000000000..9c40b46bde --- /dev/null +++ b/Userland/Applications/Piano/WaveWidget.cpp @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * Copyright (c) 2019-2020, William McPherson <willmcpherson2@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "WaveWidget.h" +#include "TrackManager.h" +#include <AK/NumericLimits.h> +#include <LibGUI/Painter.h> + +WaveWidget::WaveWidget(TrackManager& track_manager) + : m_track_manager(track_manager) +{ +} + +WaveWidget::~WaveWidget() +{ +} + +int WaveWidget::sample_to_y(int sample) const +{ + constexpr int nice_scale_factor = 4; + sample *= nice_scale_factor; + constexpr double sample_max = NumericLimits<i16>::max(); + double percentage = sample / sample_max; + double portion_of_half_height = percentage * ((frame_inner_rect().height() - 1) / 2.0); + double y = (frame_inner_rect().height() / 2.0) + portion_of_half_height; + return y; +} + +void WaveWidget::paint_event(GUI::PaintEvent& event) +{ + GUI::Painter painter(*this); + painter.fill_rect(frame_inner_rect(), Color::Black); + painter.translate(frame_thickness(), frame_thickness()); + + Color left_wave_color = left_wave_colors[m_track_manager.current_track().wave()]; + Color right_wave_color = right_wave_colors[m_track_manager.current_track().wave()]; + auto buffer = m_track_manager.buffer(); + double width_scale = static_cast<double>(frame_inner_rect().width()) / buffer.size(); + + int prev_x = 0; + int prev_y_left = sample_to_y(buffer[0].left); + int prev_y_right = sample_to_y(buffer[0].right); + painter.set_pixel({ prev_x, prev_y_left }, left_wave_color); + painter.set_pixel({ prev_x, prev_y_right }, right_wave_color); + + for (size_t x = 1; x < buffer.size(); ++x) { + int y_left = sample_to_y(buffer[x].left); + int y_right = sample_to_y(buffer[x].right); + + Gfx::IntPoint point1_left(prev_x * width_scale, prev_y_left); + Gfx::IntPoint point2_left(x * width_scale, y_left); + painter.draw_line(point1_left, point2_left, left_wave_color); + + Gfx::IntPoint point1_right(prev_x * width_scale, prev_y_right); + Gfx::IntPoint point2_right(x * width_scale, y_right); + painter.draw_line(point1_right, point2_right, right_wave_color); + + prev_x = x; + prev_y_left = y_left; + prev_y_right = y_right; + } + + GUI::Frame::paint_event(event); +} diff --git a/Userland/Applications/Piano/WaveWidget.h b/Userland/Applications/Piano/WaveWidget.h new file mode 100644 index 0000000000..e8b9cd4d5f --- /dev/null +++ b/Userland/Applications/Piano/WaveWidget.h @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * Copyright (c) 2019-2020, William McPherson <willmcpherson2@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Frame.h> + +class TrackManager; + +class WaveWidget final : public GUI::Frame { + C_OBJECT(WaveWidget) +public: + virtual ~WaveWidget() override; + +private: + explicit WaveWidget(TrackManager&); + + virtual void paint_event(GUI::PaintEvent&) override; + + int sample_to_y(int sample) const; + + TrackManager& m_track_manager; +}; diff --git a/Userland/Applications/Piano/main.cpp b/Userland/Applications/Piano/main.cpp new file mode 100644 index 0000000000..dc75e2944d --- /dev/null +++ b/Userland/Applications/Piano/main.cpp @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * Copyright (c) 2019-2020, William McPherson <willmcpherson2@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "MainWidget.h" +#include "TrackManager.h" +#include <AK/Array.h> +#include <LibAudio/ClientConnection.h> +#include <LibAudio/WavWriter.h> +#include <LibCore/EventLoop.h> +#include <LibCore/File.h> +#include <LibGUI/Action.h> +#include <LibGUI/Application.h> +#include <LibGUI/FilePicker.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/Window.h> +#include <LibGfx/Bitmap.h> +#include <LibThread/Thread.h> + +int main(int argc, char** argv) +{ + if (pledge("stdio thread rpath accept cpath wpath shared_buffer unix fattr", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio thread rpath accept cpath wpath shared_buffer unix", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto audio_client = Audio::ClientConnection::construct(); + audio_client->handshake(); + + TrackManager track_manager; + + auto app_icon = GUI::Icon::default_icon("app-piano"); + auto window = GUI::Window::construct(); + auto& main_widget = window->set_main_widget<MainWidget>(track_manager); + window->set_title("Piano"); + window->resize(840, 600); + window->set_icon(app_icon.bitmap_for_size(16)); + window->show(); + + Audio::WavWriter wav_writer; + Optional<String> save_path; + bool need_to_write_wav = false; + + auto audio_thread = LibThread::Thread::construct([&] { + auto audio = Core::File::construct("/dev/audio"); + if (!audio->open(Core::IODevice::WriteOnly)) { + dbgln("Can't open audio device: {}", audio->error_string()); + return 1; + } + + Array<Sample, sample_count> buffer; + while (!Core::EventLoop::current().was_exit_requested()) { + track_manager.fill_buffer(buffer); + audio->write(reinterpret_cast<u8*>(buffer.data()), buffer_size); + Core::EventLoop::current().post_event(main_widget, make<Core::CustomEvent>(0)); + Core::EventLoop::wake(); + + if (need_to_write_wav) { + need_to_write_wav = false; + track_manager.reset(); + track_manager.set_should_loop(false); + do { + track_manager.fill_buffer(buffer); + wav_writer.write_samples(reinterpret_cast<u8*>(buffer.data()), buffer_size); + } while (track_manager.time()); + track_manager.reset(); + track_manager.set_should_loop(true); + wav_writer.finalize(); + } + } + return 0; + }); + audio_thread->start(); + + auto menubar = GUI::MenuBar::construct(); + + auto& app_menu = menubar->add_menu("Piano"); + app_menu.add_action(GUI::Action::create("Export", { Mod_Ctrl, Key_E }, [&](const GUI::Action&) { + save_path = GUI::FilePicker::get_save_filepath(window, "Untitled", "wav"); + if (!save_path.has_value()) + return; + wav_writer.set_file(save_path.value()); + if (wav_writer.has_error()) { + GUI::MessageBox::show(window, String::formatted("Failed to export WAV file: {}", wav_writer.error_string()), "Error", GUI::MessageBox::Type::Error); + wav_writer.clear_error(); + return; + } + need_to_write_wav = true; + })); + app_menu.add_separator(); + app_menu.add_action(GUI::CommonActions::make_quit_action([](auto&) { + GUI::Application::the()->quit(); + return; + })); + + auto& edit_menu = menubar->add_menu("Edit"); + main_widget.add_actions(edit_menu); + + auto& help_menu = menubar->add_menu("Help"); + help_menu.add_action(GUI::CommonActions::make_about_action("Piano", app_icon, window)); + + app->set_menubar(move(menubar)); + + return app->exec(); +} diff --git a/Userland/Applications/PixelPaint/BrushTool.cpp b/Userland/Applications/PixelPaint/BrushTool.cpp new file mode 100644 index 0000000000..200e270ea7 --- /dev/null +++ b/Userland/Applications/PixelPaint/BrushTool.cpp @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2020, Ben Jilks <benjyjilks@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "BrushTool.h" +#include "ImageEditor.h" +#include "Layer.h" +#include <LibGUI/Action.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Label.h> +#include <LibGUI/Painter.h> +#include <LibGUI/Slider.h> +#include <LibGfx/Color.h> +#include <LibGfx/Rect.h> +#include <utility> + +namespace PixelPaint { + +BrushTool::BrushTool() +{ +} + +BrushTool::~BrushTool() +{ +} + +void BrushTool::on_mousedown(Layer&, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + if (event.button() != GUI::MouseButton::Left && event.button() != GUI::MouseButton::Right) + return; + + m_last_position = event.position(); +} + +void BrushTool::on_mousemove(Layer& layer, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + if (!(event.buttons() & GUI::MouseButton::Left || event.buttons() & GUI::MouseButton::Right)) + return; + + draw_line(layer.bitmap(), m_editor->color_for(event), m_last_position, event.position()); + layer.did_modify_bitmap(*m_editor->image()); + m_last_position = event.position(); + m_was_drawing = true; +} + +void BrushTool::on_mouseup(Layer&, GUI::MouseEvent&, GUI::MouseEvent&) +{ + if (m_was_drawing) { + m_editor->did_complete_action(); + m_was_drawing = false; + } +} + +void BrushTool::draw_point(Gfx::Bitmap& bitmap, const Gfx::Color& color, const Gfx::IntPoint& point) +{ + for (int y = point.y() - m_size; y < point.y() + m_size; y++) { + for (int x = point.x() - m_size; x < point.x() + m_size; x++) { + auto distance = point.distance_from({ x, y }); + if (x < 0 || x >= bitmap.width() || y < 0 || y >= bitmap.height()) + continue; + if (distance >= m_size) + continue; + + auto falloff = (1.0 - (distance / (float)m_size)) * (1.0f / (100 - m_hardness)); + auto pixel_color = color; + pixel_color.set_alpha(falloff * 255); + bitmap.set_pixel(x, y, bitmap.get_pixel(x, y).blend(pixel_color)); + } + } +} + +void BrushTool::draw_line(Gfx::Bitmap& bitmap, const Gfx::Color& color, const Gfx::IntPoint& start, const Gfx::IntPoint& end) +{ + int length_x = end.x() - start.x(); + int length_y = end.y() - start.y(); + float y_step = length_y == 0 ? 0 : (float)(length_y) / (float)(length_x); + if (y_step > abs(length_y)) + y_step = abs(length_y); + if (y_step < -abs(length_y)) + y_step = -abs(length_y); + if (y_step == 0 && start.x() == end.x()) + return; + + int start_x = start.x(); + int end_x = end.x(); + int start_y = start.y(); + int end_y = end.y(); + if (start_x > end_x) { + swap(start_x, end_x); + swap(start_y, end_y); + } + + float y = start_y; + for (int x = start_x; x <= end_x; x++) { + int start_step_y = y; + int end_step_y = y + y_step; + if (start_step_y > end_step_y) + swap(start_step_y, end_step_y); + for (int i = start_step_y; i <= end_step_y; i++) + draw_point(bitmap, color, { x, i }); + y += y_step; + } +} + +GUI::Widget* BrushTool::get_properties_widget() +{ + if (!m_properties_widget) { + m_properties_widget = GUI::Widget::construct(); + m_properties_widget->set_layout<GUI::VerticalBoxLayout>(); + + auto& size_container = m_properties_widget->add<GUI::Widget>(); + size_container.set_fixed_height(20); + size_container.set_layout<GUI::HorizontalBoxLayout>(); + + auto& size_label = size_container.add<GUI::Label>("Size:"); + size_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + size_label.set_fixed_size(80, 20); + + auto& size_slider = size_container.add<GUI::HorizontalSlider>(); + size_slider.set_fixed_height(20); + size_slider.set_range(1, 100); + size_slider.set_value(m_size); + size_slider.on_change = [this](int value) { + m_size = value; + }; + + auto& hardness_container = m_properties_widget->add<GUI::Widget>(); + hardness_container.set_fixed_height(20); + hardness_container.set_layout<GUI::HorizontalBoxLayout>(); + + auto& hardness_label = hardness_container.add<GUI::Label>("Hardness:"); + hardness_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + hardness_label.set_fixed_size(80, 20); + + auto& hardness_slider = hardness_container.add<GUI::HorizontalSlider>(); + hardness_slider.set_fixed_height(20); + hardness_slider.set_range(1, 99); + hardness_slider.set_value(m_hardness); + hardness_slider.on_change = [this](int value) { + m_hardness = value; + }; + } + + return m_properties_widget.ptr(); +} + +} diff --git a/Userland/Applications/PixelPaint/BrushTool.h b/Userland/Applications/PixelPaint/BrushTool.h new file mode 100644 index 0000000000..939ccdeef4 --- /dev/null +++ b/Userland/Applications/PixelPaint/BrushTool.h @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2020, Ben Jilks <benjyjilks@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Tool.h" + +namespace PixelPaint { + +class BrushTool final : public Tool { +public: + BrushTool(); + virtual ~BrushTool() override; + + virtual void on_mousedown(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_mousemove(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_mouseup(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual GUI::Widget* get_properties_widget() override; + +private: + RefPtr<GUI::Widget> m_properties_widget; + int m_size { 20 }; + int m_hardness { 80 }; + bool m_was_drawing { false }; + Gfx::IntPoint m_last_position; + + void draw_line(Gfx::Bitmap& bitmap, const Gfx::Color& color, const Gfx::IntPoint& start, const Gfx::IntPoint& end); + void draw_point(Gfx::Bitmap& bitmap, const Gfx::Color& color, const Gfx::IntPoint& point); +}; + +} diff --git a/Userland/Applications/PixelPaint/BucketTool.cpp b/Userland/Applications/PixelPaint/BucketTool.cpp new file mode 100644 index 0000000000..6cffddac20 --- /dev/null +++ b/Userland/Applications/PixelPaint/BucketTool.cpp @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "BucketTool.h" +#include "ImageEditor.h" +#include "Layer.h" +#include <AK/Queue.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Label.h> +#include <LibGUI/Painter.h> +#include <LibGUI/Slider.h> +#include <LibGfx/Bitmap.h> +#include <LibGfx/Rect.h> + +namespace PixelPaint { + +BucketTool::BucketTool() +{ +} + +BucketTool::~BucketTool() +{ +} + +static float color_distance_squared(const Gfx::Color& lhs, const Gfx::Color& rhs) +{ + int a = rhs.red() - lhs.red(); + int b = rhs.green() - lhs.green(); + int c = rhs.blue() - lhs.blue(); + return (a * a + b * b + c * c) / (255.0f * 255.0f); +} + +static void flood_fill(Gfx::Bitmap& bitmap, const Gfx::IntPoint& start_position, Color target_color, Color fill_color, int threshold) +{ + ASSERT(bitmap.bpp() == 32); + + if (target_color == fill_color) + return; + + if (!bitmap.rect().contains(start_position)) + return; + + float threshold_normalized_squared = (threshold / 100.0f) * (threshold / 100.0f); + + Queue<Gfx::IntPoint> queue; + queue.enqueue(start_position); + while (!queue.is_empty()) { + auto position = queue.dequeue(); + + auto pixel_color = bitmap.get_pixel<Gfx::StorageFormat::RGBA32>(position.x(), position.y()); + if (color_distance_squared(pixel_color, target_color) > threshold_normalized_squared) + continue; + + bitmap.set_pixel<Gfx::StorageFormat::RGBA32>(position.x(), position.y(), fill_color); + + if (position.x() != 0) + queue.enqueue(position.translated(-1, 0)); + + if (position.x() != bitmap.width() - 1) + queue.enqueue(position.translated(1, 0)); + + if (position.y() != 0) + queue.enqueue(position.translated(0, -1)); + + if (position.y() != bitmap.height() - 1) + queue.enqueue(position.translated(0, 1)); + } +} + +void BucketTool::on_mousedown(Layer& layer, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + if (!layer.rect().contains(event.position())) + return; + + GUI::Painter painter(layer.bitmap()); + auto target_color = layer.bitmap().get_pixel(event.x(), event.y()); + + flood_fill(layer.bitmap(), event.position(), target_color, m_editor->color_for(event), m_threshold); + + layer.did_modify_bitmap(*m_editor->image()); + m_editor->did_complete_action(); +} + +GUI::Widget* BucketTool::get_properties_widget() +{ + if (!m_properties_widget) { + m_properties_widget = GUI::Widget::construct(); + m_properties_widget->set_layout<GUI::VerticalBoxLayout>(); + + auto& threshold_container = m_properties_widget->add<GUI::Widget>(); + threshold_container.set_fixed_height(20); + threshold_container.set_layout<GUI::HorizontalBoxLayout>(); + + auto& threshold_label = threshold_container.add<GUI::Label>("Threshold:"); + threshold_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + threshold_label.set_fixed_size(80, 20); + + auto& threshold_slider = threshold_container.add<GUI::HorizontalSlider>(); + threshold_slider.set_fixed_height(20); + threshold_slider.set_range(0, 100); + threshold_slider.set_value(m_threshold); + threshold_slider.on_change = [this](int value) { + m_threshold = value; + }; + } + + return m_properties_widget.ptr(); +} + +} diff --git a/Userland/Applications/PixelPaint/BucketTool.h b/Userland/Applications/PixelPaint/BucketTool.h new file mode 100644 index 0000000000..b4bf700815 --- /dev/null +++ b/Userland/Applications/PixelPaint/BucketTool.h @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Tool.h" + +namespace PixelPaint { + +class BucketTool final : public Tool { +public: + BucketTool(); + virtual ~BucketTool() override; + + virtual void on_mousedown(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual GUI::Widget* get_properties_widget() override; + +private: + RefPtr<GUI::Widget> m_properties_widget; + int m_threshold { 0 }; +}; + +} diff --git a/Userland/Applications/PixelPaint/CMakeLists.txt b/Userland/Applications/PixelPaint/CMakeLists.txt new file mode 100644 index 0000000000..713845c80e --- /dev/null +++ b/Userland/Applications/PixelPaint/CMakeLists.txt @@ -0,0 +1,27 @@ +set(SOURCES + BrushTool.cpp + BucketTool.cpp + CreateNewImageDialog.cpp + CreateNewLayerDialog.cpp + EllipseTool.cpp + EraseTool.cpp + Image.cpp + ImageEditor.cpp + Layer.cpp + LayerListWidget.cpp + LayerPropertiesWidget.cpp + LineTool.cpp + main.cpp + MoveTool.cpp + PaletteWidget.cpp + PenTool.cpp + PickerTool.cpp + RectangleTool.cpp + SprayTool.cpp + ToolboxWidget.cpp + ToolPropertiesWidget.cpp + Tool.cpp +) + +serenity_app(PixelPaint ICON app-pixel-paint) +target_link_libraries(PixelPaint LibGUI LibGfx) diff --git a/Userland/Applications/PixelPaint/CreateNewImageDialog.cpp b/Userland/Applications/PixelPaint/CreateNewImageDialog.cpp new file mode 100644 index 0000000000..0696b69ba8 --- /dev/null +++ b/Userland/Applications/PixelPaint/CreateNewImageDialog.cpp @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2020, Ben Jilks <benjyjilks@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "CreateNewImageDialog.h" +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/Label.h> +#include <LibGUI/SpinBox.h> +#include <LibGUI/TextBox.h> + +namespace PixelPaint { + +CreateNewImageDialog::CreateNewImageDialog(GUI::Window* parent_window) + : Dialog(parent_window) +{ + set_title("Create new image"); + resize(200, 200); + + auto& main_widget = set_main_widget<GUI::Widget>(); + main_widget.set_fill_with_background_color(true); + + auto& layout = main_widget.set_layout<GUI::VerticalBoxLayout>(); + layout.set_margins({ 4, 4, 4, 4 }); + + auto& name_label = main_widget.add<GUI::Label>("Name:"); + name_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + + m_name_textbox = main_widget.add<GUI::TextBox>(); + m_name_textbox->on_change = [this] { + m_image_name = m_name_textbox->text(); + }; + + auto& width_label = main_widget.add<GUI::Label>("Width:"); + width_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + + auto& width_spinbox = main_widget.add<GUI::SpinBox>(); + + auto& height_label = main_widget.add<GUI::Label>("Height:"); + height_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + + auto& height_spinbox = main_widget.add<GUI::SpinBox>(); + + auto& button_container = main_widget.add<GUI::Widget>(); + button_container.set_layout<GUI::HorizontalBoxLayout>(); + + auto& ok_button = button_container.add<GUI::Button>("OK"); + ok_button.on_click = [this](auto) { + done(ExecOK); + }; + + auto& cancel_button = button_container.add<GUI::Button>("Cancel"); + cancel_button.on_click = [this](auto) { + done(ExecCancel); + }; + + width_spinbox.on_change = [this](int value) { + m_image_size.set_width(value); + }; + + height_spinbox.on_change = [this](int value) { + m_image_size.set_height(value); + }; + + width_spinbox.set_range(0, 16384); + height_spinbox.set_range(0, 16384); +} + +} diff --git a/Userland/Applications/PixelPaint/CreateNewImageDialog.h b/Userland/Applications/PixelPaint/CreateNewImageDialog.h new file mode 100644 index 0000000000..b46b65adab --- /dev/null +++ b/Userland/Applications/PixelPaint/CreateNewImageDialog.h @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2020, Ben Jilks <benjyjilks@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Dialog.h> + +namespace PixelPaint { + +class CreateNewImageDialog final : public GUI::Dialog { + C_OBJECT(CreateNewImageDialog) + +public: + const Gfx::IntSize& image_size() const { return m_image_size; } + const String& image_name() const { return m_image_name; } + +private: + CreateNewImageDialog(GUI::Window* parent_window); + + String m_image_name; + Gfx::IntSize m_image_size; + + RefPtr<GUI::TextBox> m_name_textbox; +}; + +} diff --git a/Userland/Applications/PixelPaint/CreateNewLayerDialog.cpp b/Userland/Applications/PixelPaint/CreateNewLayerDialog.cpp new file mode 100644 index 0000000000..02f65b51ad --- /dev/null +++ b/Userland/Applications/PixelPaint/CreateNewLayerDialog.cpp @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "CreateNewLayerDialog.h" +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/Label.h> +#include <LibGUI/SpinBox.h> +#include <LibGUI/TextBox.h> + +namespace PixelPaint { + +CreateNewLayerDialog::CreateNewLayerDialog(const Gfx::IntSize& suggested_size, GUI::Window* parent_window) + : Dialog(parent_window) +{ + set_title("Create new layer"); + set_icon(parent_window->icon()); + resize(200, 200); + + auto& main_widget = set_main_widget<GUI::Widget>(); + main_widget.set_fill_with_background_color(true); + + auto& layout = main_widget.set_layout<GUI::VerticalBoxLayout>(); + layout.set_margins({ 4, 4, 4, 4 }); + + auto& name_label = main_widget.add<GUI::Label>("Name:"); + name_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + + m_name_textbox = main_widget.add<GUI::TextBox>(); + m_name_textbox->on_change = [this] { + m_layer_name = m_name_textbox->text(); + }; + + auto& width_label = main_widget.add<GUI::Label>("Width:"); + width_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + + auto& width_spinbox = main_widget.add<GUI::SpinBox>(); + + auto& height_label = main_widget.add<GUI::Label>("Height:"); + height_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + + auto& height_spinbox = main_widget.add<GUI::SpinBox>(); + + auto& button_container = main_widget.add<GUI::Widget>(); + button_container.set_layout<GUI::HorizontalBoxLayout>(); + + auto& ok_button = button_container.add<GUI::Button>("OK"); + ok_button.on_click = [this](auto) { + done(ExecOK); + }; + + auto& cancel_button = button_container.add<GUI::Button>("Cancel"); + cancel_button.on_click = [this](auto) { + done(ExecCancel); + }; + + width_spinbox.on_change = [this](int value) { + m_layer_size.set_width(value); + }; + + height_spinbox.on_change = [this](int value) { + m_layer_size.set_height(value); + }; + + width_spinbox.set_range(0, 16384); + height_spinbox.set_range(0, 16384); + + width_spinbox.set_value(suggested_size.width()); + height_spinbox.set_value(suggested_size.height()); +} + +} diff --git a/Userland/Applications/PixelPaint/CreateNewLayerDialog.h b/Userland/Applications/PixelPaint/CreateNewLayerDialog.h new file mode 100644 index 0000000000..bb07ff9943 --- /dev/null +++ b/Userland/Applications/PixelPaint/CreateNewLayerDialog.h @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Dialog.h> + +namespace PixelPaint { + +class CreateNewLayerDialog final : public GUI::Dialog { + C_OBJECT(CreateNewLayerDialog); + +public: + const Gfx::IntSize& layer_size() const { return m_layer_size; } + const String& layer_name() const { return m_layer_name; } + +private: + CreateNewLayerDialog(const Gfx::IntSize& suggested_size, GUI::Window* parent_window); + + Gfx::IntSize m_layer_size; + String m_layer_name; + + RefPtr<GUI::TextBox> m_name_textbox; +}; + +} diff --git a/Userland/Applications/PixelPaint/EllipseTool.cpp b/Userland/Applications/PixelPaint/EllipseTool.cpp new file mode 100644 index 0000000000..0501e2fb96 --- /dev/null +++ b/Userland/Applications/PixelPaint/EllipseTool.cpp @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "EllipseTool.h" +#include "ImageEditor.h" +#include "Layer.h" +#include <LibGUI/Action.h> +#include <LibGUI/Menu.h> +#include <LibGUI/Painter.h> +#include <LibGfx/Rect.h> +#include <math.h> + +namespace PixelPaint { + +EllipseTool::EllipseTool() +{ +} + +EllipseTool::~EllipseTool() +{ +} + +void EllipseTool::draw_using(GUI::Painter& painter, const Gfx::IntRect& ellipse_intersecting_rect) +{ + switch (m_mode) { + case Mode::Outline: + painter.draw_ellipse_intersecting(ellipse_intersecting_rect, m_editor->color_for(m_drawing_button), m_thickness); + break; + default: + ASSERT_NOT_REACHED(); + } +} + +void EllipseTool::on_mousedown(Layer&, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + if (event.button() != GUI::MouseButton::Left && event.button() != GUI::MouseButton::Right) + return; + + if (m_drawing_button != GUI::MouseButton::None) + return; + + m_drawing_button = event.button(); + m_ellipse_start_position = event.position(); + m_ellipse_end_position = event.position(); + m_editor->update(); +} + +void EllipseTool::on_mouseup(Layer& layer, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + if (event.button() == m_drawing_button) { + GUI::Painter painter(layer.bitmap()); + draw_using(painter, Gfx::IntRect::from_two_points(m_ellipse_start_position, m_ellipse_end_position)); + m_drawing_button = GUI::MouseButton::None; + m_editor->update(); + m_editor->did_complete_action(); + } +} + +void EllipseTool::on_mousemove(Layer&, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + if (m_drawing_button == GUI::MouseButton::None) + return; + + m_ellipse_end_position = event.position(); + m_editor->update(); +} + +void EllipseTool::on_second_paint(const Layer& layer, GUI::PaintEvent& event) +{ + if (m_drawing_button == GUI::MouseButton::None) + return; + + GUI::Painter painter(*m_editor); + painter.add_clip_rect(event.rect()); + auto preview_start = m_editor->layer_position_to_editor_position(layer, m_ellipse_start_position).to_type<int>(); + auto preview_end = m_editor->layer_position_to_editor_position(layer, m_ellipse_end_position).to_type<int>(); + draw_using(painter, Gfx::IntRect::from_two_points(preview_start, preview_end)); +} + +void EllipseTool::on_keydown(GUI::KeyEvent& event) +{ + if (event.key() == Key_Escape && m_drawing_button != GUI::MouseButton::None) { + m_drawing_button = GUI::MouseButton::None; + m_editor->update(); + event.accept(); + } +} + +void EllipseTool::on_tool_button_contextmenu(GUI::ContextMenuEvent& event) +{ + if (!m_context_menu) { + m_context_menu = GUI::Menu::construct(); + m_context_menu->add_action(GUI::Action::create("Outline", [this](auto&) { + m_mode = Mode::Outline; + })); + m_context_menu->add_separator(); + m_thickness_actions.set_exclusive(true); + auto insert_action = [&](int size, bool checked = false) { + auto action = GUI::Action::create_checkable(String::number(size), [this, size](auto&) { + m_thickness = size; + }); + action->set_checked(checked); + m_thickness_actions.add_action(*action); + m_context_menu->add_action(move(action)); + }; + insert_action(1, true); + insert_action(2); + insert_action(3); + insert_action(4); + } + m_context_menu->popup(event.screen_position()); +} + +} diff --git a/Userland/Applications/PixelPaint/EllipseTool.h b/Userland/Applications/PixelPaint/EllipseTool.h new file mode 100644 index 0000000000..0b1c10c89a --- /dev/null +++ b/Userland/Applications/PixelPaint/EllipseTool.h @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Tool.h" +#include <LibGUI/ActionGroup.h> +#include <LibGfx/Point.h> + +namespace PixelPaint { + +class EllipseTool final : public Tool { +public: + EllipseTool(); + virtual ~EllipseTool() override; + + virtual void on_mousedown(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_mousemove(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_mouseup(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_tool_button_contextmenu(GUI::ContextMenuEvent&) override; + virtual void on_second_paint(const Layer&, GUI::PaintEvent&) override; + virtual void on_keydown(GUI::KeyEvent&) override; + +private: + enum class Mode { + Outline, + // FIXME: Add Mode::Fill + }; + + void draw_using(GUI::Painter&, const Gfx::IntRect&); + + GUI::MouseButton m_drawing_button { GUI::MouseButton::None }; + Gfx::IntPoint m_ellipse_start_position; + Gfx::IntPoint m_ellipse_end_position; + RefPtr<GUI::Menu> m_context_menu; + int m_thickness { 1 }; + GUI::ActionGroup m_thickness_actions; + Mode m_mode { Mode::Outline }; +}; + +} diff --git a/Userland/Applications/PixelPaint/EraseTool.cpp b/Userland/Applications/PixelPaint/EraseTool.cpp new file mode 100644 index 0000000000..19d1482ba8 --- /dev/null +++ b/Userland/Applications/PixelPaint/EraseTool.cpp @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "EraseTool.h" +#include "ImageEditor.h" +#include "Layer.h" +#include <LibGUI/Action.h> +#include <LibGUI/Menu.h> +#include <LibGUI/Painter.h> +#include <LibGfx/Bitmap.h> + +namespace PixelPaint { + +EraseTool::EraseTool() +{ +} + +EraseTool::~EraseTool() +{ +} + +Gfx::IntRect EraseTool::build_rect(const Gfx::IntPoint& pos, const Gfx::IntRect& widget_rect) +{ + const int base_eraser_size = 10; + const int eraser_size = (base_eraser_size * m_thickness); + const int eraser_radius = eraser_size / 2; + const auto ex = pos.x(); + const auto ey = pos.y(); + return Gfx::IntRect(ex - eraser_radius, ey - eraser_radius, eraser_size, eraser_size).intersected(widget_rect); +} + +void EraseTool::on_mousedown(Layer& layer, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + if (event.button() != GUI::MouseButton::Left && event.button() != GUI::MouseButton::Right) + return; + Gfx::IntRect r = build_rect(event.position(), layer.rect()); + GUI::Painter painter(layer.bitmap()); + painter.clear_rect(r, get_color()); + layer.did_modify_bitmap(*m_editor->image()); +} + +void EraseTool::on_mousemove(Layer& layer, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + if (event.buttons() & GUI::MouseButton::Left || event.buttons() & GUI::MouseButton::Right) { + Gfx::IntRect r = build_rect(event.position(), layer.rect()); + GUI::Painter painter(layer.bitmap()); + painter.clear_rect(r, get_color()); + layer.did_modify_bitmap(*m_editor->image()); + } +} + +void EraseTool::on_mouseup(Layer&, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + if (event.button() != GUI::MouseButton::Left && event.button() != GUI::MouseButton::Right) + return; + m_editor->did_complete_action(); +} + +void EraseTool::on_tool_button_contextmenu(GUI::ContextMenuEvent& event) +{ + if (!m_context_menu) { + m_context_menu = GUI::Menu::construct(); + + auto eraser_color_toggler = GUI::Action::create_checkable("Use secondary color", [&](auto& action) { + m_use_secondary_color = action.is_checked(); + }); + eraser_color_toggler->set_checked(m_use_secondary_color); + + m_context_menu->add_action(eraser_color_toggler); + m_context_menu->add_separator(); + + m_thickness_actions.set_exclusive(true); + auto insert_action = [&](int size, bool checked = false) { + auto action = GUI::Action::create_checkable(String::number(size), [this, size](auto&) { + m_thickness = size; + }); + action->set_checked(checked); + m_thickness_actions.add_action(*action); + m_context_menu->add_action(move(action)); + }; + insert_action(1, true); + insert_action(2); + insert_action(3); + insert_action(4); + } + + m_context_menu->popup(event.screen_position()); +} + +Color EraseTool::get_color() const +{ + if (m_use_secondary_color) + return m_editor->secondary_color(); + return Color(255, 255, 255, 0); +} + +} diff --git a/Userland/Applications/PixelPaint/EraseTool.h b/Userland/Applications/PixelPaint/EraseTool.h new file mode 100644 index 0000000000..16241e87f0 --- /dev/null +++ b/Userland/Applications/PixelPaint/EraseTool.h @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Tool.h" +#include <LibGUI/ActionGroup.h> +#include <LibGfx/Forward.h> +#include <LibGfx/Point.h> + +namespace PixelPaint { + +class EraseTool final : public Tool { +public: + EraseTool(); + virtual ~EraseTool() override; + + virtual void on_mousedown(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_mousemove(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_mouseup(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_tool_button_contextmenu(GUI::ContextMenuEvent&) override; + +private: + Gfx::Color get_color() const; + Gfx::IntRect build_rect(const Gfx::IntPoint& pos, const Gfx::IntRect& widget_rect); + RefPtr<GUI::Menu> m_context_menu; + + bool m_use_secondary_color { false }; + int m_thickness { 1 }; + GUI::ActionGroup m_thickness_actions; +}; + +} diff --git a/Userland/Applications/PixelPaint/FilterParams.h b/Userland/Applications/PixelPaint/FilterParams.h new file mode 100644 index 0000000000..398caecf9f --- /dev/null +++ b/Userland/Applications/PixelPaint/FilterParams.h @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Action.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/CheckBox.h> +#include <LibGUI/Dialog.h> +#include <LibGUI/Menu.h> +#include <LibGUI/Painter.h> +#include <LibGUI/TextBox.h> +#include <LibGfx/Filters/BoxBlurFilter.h> +#include <LibGfx/Filters/GenericConvolutionFilter.h> +#include <LibGfx/Filters/LaplacianFilter.h> +#include <LibGfx/Filters/SharpenFilter.h> +#include <LibGfx/Filters/SpatialGaussianBlurFilter.h> + +namespace PixelPaint { + +template<typename Filter> +struct FilterParameters { +}; + +template<size_t N> +class GenericConvolutionFilterInputDialog : public GUI::Dialog { + C_OBJECT(GenericConvolutionFilterInputDialog); + +public: + const Matrix<N, float>& matrix() const { return m_matrix; } + bool should_wrap() const { return m_should_wrap; } + +private: + explicit GenericConvolutionFilterInputDialog(GUI::Window* parent_window) + : Dialog(parent_window) + { + // FIXME: Help! Make this GUI less ugly. + StringBuilder builder; + builder.appendf("%zux%zu", N, N); + builder.append(" Convolution"); + set_title(builder.string_view()); + + resize(200, 250); + auto& main_widget = set_main_widget<GUI::Frame>(); + main_widget.set_frame_shape(Gfx::FrameShape::Container); + main_widget.set_frame_shadow(Gfx::FrameShadow::Raised); + main_widget.set_fill_with_background_color(true); + auto& layout = main_widget.template set_layout<GUI::VerticalBoxLayout>(); + layout.set_margins({ 4, 4, 4, 4 }); + + size_t index = 0; + size_t columns = N; + size_t rows = N; + + for (size_t row = 0; row < rows; ++row) { + auto& horizontal_container = main_widget.template add<GUI::Widget>(); + horizontal_container.template set_layout<GUI::HorizontalBoxLayout>(); + for (size_t column = 0; column < columns; ++column) { + if (index < columns * rows) { + auto& textbox = horizontal_container.template add<GUI::TextBox>(); + textbox.on_change = [&, row = row, column = column] { + auto& element = m_matrix.elements()[row][column]; + char* endptr = nullptr; + auto value = strtof(textbox.text().characters(), &endptr); + if (endptr != nullptr) + element = value; + else + textbox.set_text(""); + }; + } else { + horizontal_container.template add<GUI::Widget>(); + } + } + } + + auto& norm_checkbox = main_widget.template add<GUI::CheckBox>("Normalize"); + norm_checkbox.set_checked(false); + + auto& wrap_checkbox = main_widget.template add<GUI::CheckBox>("Wrap"); + wrap_checkbox.set_checked(m_should_wrap); + + auto& button = main_widget.template add<GUI::Button>("Done"); + button.on_click = [&](auto) { + m_should_wrap = wrap_checkbox.is_checked(); + if (norm_checkbox.is_checked()) + normalize(m_matrix); + done(ExecOK); + }; + } + + Matrix<N, float> m_matrix {}; + bool m_should_wrap { false }; +}; + +template<size_t N> +struct FilterParameters<Gfx::SpatialGaussianBlurFilter<N>> { + static OwnPtr<typename Gfx::SpatialGaussianBlurFilter<N>::Parameters> get() + { + constexpr static ssize_t offset = N / 2; + Matrix<N, float> kernel; + auto sigma = 1.0f; + auto s = 2.0f * sigma * sigma; + + for (auto x = -offset; x <= offset; x++) { + for (auto y = -offset; y <= offset; y++) { + auto r = sqrt(x * x + y * y); + kernel.elements()[x + offset][y + offset] = (exp(-(r * r) / s)) / (M_PI * s); + } + } + + normalize(kernel); + + return make<typename Gfx::GenericConvolutionFilter<N>::Parameters>(kernel); + } +}; + +template<> +struct FilterParameters<Gfx::SharpenFilter> { + static OwnPtr<Gfx::GenericConvolutionFilter<3>::Parameters> get() + { + return make<Gfx::GenericConvolutionFilter<3>::Parameters>(Matrix<3, float>(0, -1, 0, -1, 5, -1, 0, -1, 0)); + } +}; + +template<> +struct FilterParameters<Gfx::LaplacianFilter> { + static OwnPtr<Gfx::GenericConvolutionFilter<3>::Parameters> get(bool diagonal) + { + if (diagonal) + return make<Gfx::GenericConvolutionFilter<3>::Parameters>(Matrix<3, float>(-1, -1, -1, -1, 8, -1, -1, -1, -1)); + + return make<Gfx::GenericConvolutionFilter<3>::Parameters>(Matrix<3, float>(0, -1, 0, -1, 4, -1, 0, -1, 0)); + } +}; + +template<size_t N> +struct FilterParameters<Gfx::GenericConvolutionFilter<N>> { + static OwnPtr<typename Gfx::GenericConvolutionFilter<N>::Parameters> get(GUI::Window* parent_window) + { + auto input = GenericConvolutionFilterInputDialog<N>::construct(parent_window); + input->exec(); + if (input->result() == GUI::Dialog::ExecOK) + return make<typename Gfx::GenericConvolutionFilter<N>::Parameters>(input->matrix(), input->should_wrap()); + + return {}; + } +}; + +template<size_t N> +struct FilterParameters<Gfx::BoxBlurFilter<N>> { + static OwnPtr<typename Gfx::GenericConvolutionFilter<N>::Parameters> get() + { + Matrix<N, float> kernel; + + for (size_t i = 0; i < N; ++i) { + for (size_t j = 0; j < N; ++j) { + kernel.elements()[i][j] = 1; + } + } + + normalize(kernel); + + return make<typename Gfx::GenericConvolutionFilter<N>::Parameters>(kernel); + } +}; + +} diff --git a/Userland/Applications/PixelPaint/Image.cpp b/Userland/Applications/PixelPaint/Image.cpp new file mode 100644 index 0000000000..09a075dfe0 --- /dev/null +++ b/Userland/Applications/PixelPaint/Image.cpp @@ -0,0 +1,331 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "Image.h" +#include "Layer.h" +#include <AK/Base64.h> +#include <AK/JsonObject.h> +#include <AK/JsonObjectSerializer.h> +#include <AK/JsonValue.h> +#include <AK/StringBuilder.h> +#include <LibGUI/Painter.h> +#include <LibGfx/BMPWriter.h> +#include <LibGfx/ImageDecoder.h> +#include <stdio.h> + +//#define PAINT_DEBUG + +namespace PixelPaint { + +RefPtr<Image> Image::create_with_size(const Gfx::IntSize& size) +{ + if (size.is_empty()) + return nullptr; + + if (size.width() > 16384 || size.height() > 16384) + return nullptr; + + return adopt(*new Image(size)); +} + +Image::Image(const Gfx::IntSize& size) + : m_size(size) +{ +} + +void Image::paint_into(GUI::Painter& painter, const Gfx::IntRect& dest_rect) +{ + float scale = (float)dest_rect.width() / (float)rect().width(); + Gfx::PainterStateSaver saver(painter); + painter.add_clip_rect(dest_rect); + for (auto& layer : m_layers) { + if (!layer.is_visible()) + continue; + auto target = dest_rect.translated(layer.location().x() * scale, layer.location().y() * scale); + target.set_size(layer.size().width() * scale, layer.size().height() * scale); + painter.draw_scaled_bitmap(target, layer.bitmap(), layer.rect(), (float)layer.opacity_percent() / 100.0f); + } +} + +RefPtr<Image> Image::create_from_file(const String& file_path) +{ + auto file = fopen(file_path.characters(), "r"); + fseek(file, 0L, SEEK_END); + auto length = ftell(file); + rewind(file); + + auto buffer = ByteBuffer::create_uninitialized(length); + fread(buffer.data(), sizeof(u8), length, file); + fclose(file); + + auto json_or_error = JsonValue::from_string(String::copy(buffer)); + if (!json_or_error.has_value()) + return nullptr; + + auto json = json_or_error.value().as_object(); + auto image = create_with_size({ json.get("width").to_i32(), json.get("height").to_i32() }); + json.get("layers").as_array().for_each([&](JsonValue json_layer) { + auto json_layer_object = json_layer.as_object(); + auto width = json_layer_object.get("width").to_i32(); + auto height = json_layer_object.get("height").to_i32(); + auto name = json_layer_object.get("name").as_string(); + auto layer = Layer::create_with_size(*image, { width, height }, name); + layer->set_location({ json_layer_object.get("locationx").to_i32(), json_layer_object.get("locationy").to_i32() }); + layer->set_opacity_percent(json_layer_object.get("opacity_percent").to_i32()); + layer->set_visible(json_layer_object.get("visible").as_bool()); + layer->set_selected(json_layer_object.get("selected").as_bool()); + + auto bitmap_base64_encoded = json_layer_object.get("bitmap").as_string(); + auto bitmap_data = decode_base64(bitmap_base64_encoded); + auto image_decoder = Gfx::ImageDecoder::create(bitmap_data); + layer->set_bitmap(*image_decoder->bitmap()); + image->add_layer(*layer); + }); + + return image; +} + +void Image::save(const String& file_path) const +{ + // Build json file + StringBuilder builder; + JsonObjectSerializer json(builder); + json.add("width", m_size.width()); + json.add("height", m_size.height()); + { + auto json_layers = json.add_array("layers"); + for (const auto& layer : m_layers) { + Gfx::BMPWriter bmp_dumber; + auto json_layer = json_layers.add_object(); + json_layer.add("width", layer.size().width()); + json_layer.add("height", layer.size().height()); + json_layer.add("name", layer.name()); + json_layer.add("locationx", layer.location().x()); + json_layer.add("locationy", layer.location().y()); + json_layer.add("opacity_percent", layer.opacity_percent()); + json_layer.add("visible", layer.is_visible()); + json_layer.add("selected", layer.is_selected()); + json_layer.add("bitmap", encode_base64(bmp_dumber.dump(layer.bitmap()))); + } + } + json.finish(); + + // Write json to disk + auto file = fopen(file_path.characters(), "w"); + auto byte_buffer = builder.to_byte_buffer(); + fwrite(byte_buffer.data(), sizeof(u8), byte_buffer.size(), file); + fclose(file); +} + +void Image::export_bmp(const String& file_path) +{ + auto bitmap = Gfx::Bitmap::create(Gfx::BitmapFormat::RGB32, m_size); + GUI::Painter painter(*bitmap); + paint_into(painter, { 0, 0, m_size.width(), m_size.height() }); + + Gfx::BMPWriter dumper; + auto bmp = dumper.dump(bitmap); + auto file = fopen(file_path.characters(), "wb"); + fwrite(bmp.data(), sizeof(u8), bmp.size(), file); + fclose(file); +} + +void Image::add_layer(NonnullRefPtr<Layer> layer) +{ + for (auto& existing_layer : m_layers) { + ASSERT(&existing_layer != layer.ptr()); + } + m_layers.append(move(layer)); + + for (auto* client : m_clients) + client->image_did_add_layer(m_layers.size() - 1); + + did_modify_layer_stack(); +} + +RefPtr<Image> Image::take_snapshot() const +{ + auto snapshot = create_with_size(m_size); + for (const auto& layer : m_layers) + snapshot->add_layer(*Layer::create_snapshot(*snapshot, layer)); + return snapshot; +} + +void Image::restore_snapshot(const Image& snapshot) +{ + m_layers.clear(); + select_layer(nullptr); + for (const auto& snapshot_layer : snapshot.m_layers) { + auto layer = Layer::create_snapshot(*this, snapshot_layer); + if (layer->is_selected()) + select_layer(layer.ptr()); + add_layer(*layer); + } + + did_modify_layer_stack(); +} + +size_t Image::index_of(const Layer& layer) const +{ + for (size_t i = 0; i < m_layers.size(); ++i) { + if (&m_layers.at(i) == &layer) + return i; + } + ASSERT_NOT_REACHED(); +} + +void Image::move_layer_to_back(Layer& layer) +{ + NonnullRefPtr<Layer> protector(layer); + auto index = index_of(layer); + m_layers.remove(index); + m_layers.prepend(layer); + + did_modify_layer_stack(); +} + +void Image::move_layer_to_front(Layer& layer) +{ + NonnullRefPtr<Layer> protector(layer); + auto index = index_of(layer); + m_layers.remove(index); + m_layers.append(layer); + + did_modify_layer_stack(); +} + +void Image::move_layer_down(Layer& layer) +{ + NonnullRefPtr<Layer> protector(layer); + auto index = index_of(layer); + if (!index) + return; + m_layers.remove(index); + m_layers.insert(index - 1, layer); + + did_modify_layer_stack(); +} + +void Image::move_layer_up(Layer& layer) +{ + NonnullRefPtr<Layer> protector(layer); + auto index = index_of(layer); + if (index == m_layers.size() - 1) + return; + m_layers.remove(index); + m_layers.insert(index + 1, layer); + + did_modify_layer_stack(); +} + +void Image::change_layer_index(size_t old_index, size_t new_index) +{ + ASSERT(old_index < m_layers.size()); + ASSERT(new_index < m_layers.size()); + auto layer = m_layers.take(old_index); + m_layers.insert(new_index, move(layer)); + did_modify_layer_stack(); +} + +void Image::did_modify_layer_stack() +{ + for (auto* client : m_clients) + client->image_did_modify_layer_stack(); + + did_change(); +} + +void Image::remove_layer(Layer& layer) +{ + NonnullRefPtr<Layer> protector(layer); + auto index = index_of(layer); + m_layers.remove(index); + + for (auto* client : m_clients) + client->image_did_remove_layer(index); + + did_modify_layer_stack(); +} + +void Image::select_layer(Layer* layer) +{ + for (auto* client : m_clients) + client->image_select_layer(layer); +} + +void Image::add_client(ImageClient& client) +{ + ASSERT(!m_clients.contains(&client)); + m_clients.set(&client); +} + +void Image::remove_client(ImageClient& client) +{ + ASSERT(m_clients.contains(&client)); + m_clients.remove(&client); +} + +void Image::layer_did_modify_bitmap(Badge<Layer>, const Layer& layer) +{ + auto layer_index = index_of(layer); + for (auto* client : m_clients) + client->image_did_modify_layer(layer_index); + + did_change(); +} + +void Image::layer_did_modify_properties(Badge<Layer>, const Layer& layer) +{ + auto layer_index = index_of(layer); + for (auto* client : m_clients) + client->image_did_modify_layer(layer_index); + + did_change(); +} + +void Image::did_change() +{ + for (auto* client : m_clients) + client->image_did_change(); +} + +ImageUndoCommand::ImageUndoCommand(Image& image) + : m_snapshot(image.take_snapshot()) + , m_image(image) +{ +} + +void ImageUndoCommand::undo() +{ + m_image.restore_snapshot(*m_snapshot); +} + +void ImageUndoCommand::redo() +{ + undo(); +} + +} diff --git a/Userland/Applications/PixelPaint/Image.h b/Userland/Applications/PixelPaint/Image.h new file mode 100644 index 0000000000..63184b3ad3 --- /dev/null +++ b/Userland/Applications/PixelPaint/Image.h @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/HashTable.h> +#include <AK/NonnullRefPtrVector.h> +#include <AK/RefCounted.h> +#include <AK/RefPtr.h> +#include <AK/Vector.h> +#include <LibGUI/Command.h> +#include <LibGUI/Forward.h> +#include <LibGfx/Forward.h> +#include <LibGfx/Rect.h> +#include <LibGfx/Size.h> + +namespace PixelPaint { + +class Layer; + +class ImageClient { +public: + virtual void image_did_add_layer(size_t) { } + virtual void image_did_remove_layer(size_t) { } + virtual void image_did_modify_layer(size_t) { } + virtual void image_did_modify_layer_stack() { } + virtual void image_did_change() { } + virtual void image_select_layer(Layer*) { } +}; + +class Image : public RefCounted<Image> { +public: + static RefPtr<Image> create_with_size(const Gfx::IntSize&); + static RefPtr<Image> create_from_file(const String& file_path); + + size_t layer_count() const { return m_layers.size(); } + const Layer& layer(size_t index) const { return m_layers.at(index); } + Layer& layer(size_t index) { return m_layers.at(index); } + + const Gfx::IntSize& size() const { return m_size; } + Gfx::IntRect rect() const { return { {}, m_size }; } + + void add_layer(NonnullRefPtr<Layer>); + RefPtr<Image> take_snapshot() const; + void restore_snapshot(const Image&); + + void paint_into(GUI::Painter&, const Gfx::IntRect& dest_rect); + void save(const String& file_path) const; + void export_bmp(const String& file_path); + + void move_layer_to_front(Layer&); + void move_layer_to_back(Layer&); + void move_layer_up(Layer&); + void move_layer_down(Layer&); + void change_layer_index(size_t old_index, size_t new_index); + void remove_layer(Layer&); + void select_layer(Layer*); + + void add_client(ImageClient&); + void remove_client(ImageClient&); + + void layer_did_modify_bitmap(Badge<Layer>, const Layer&); + void layer_did_modify_properties(Badge<Layer>, const Layer&); + + size_t index_of(const Layer&) const; + +private: + explicit Image(const Gfx::IntSize&); + + void did_change(); + void did_modify_layer_stack(); + + Gfx::IntSize m_size; + NonnullRefPtrVector<Layer> m_layers; + + HashTable<ImageClient*> m_clients; +}; + +class ImageUndoCommand : public GUI::Command { +public: + ImageUndoCommand(Image& image); + + virtual void undo() override; + virtual void redo() override; + +private: + RefPtr<Image> m_snapshot; + Image& m_image; +}; + +} diff --git a/Userland/Applications/PixelPaint/ImageEditor.cpp b/Userland/Applications/PixelPaint/ImageEditor.cpp new file mode 100644 index 0000000000..f29551c050 --- /dev/null +++ b/Userland/Applications/PixelPaint/ImageEditor.cpp @@ -0,0 +1,418 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "ImageEditor.h" +#include "Image.h" +#include "Layer.h" +#include "MoveTool.h" +#include "Tool.h" +#include <LibGUI/Command.h> +#include <LibGUI/Painter.h> +#include <LibGfx/Palette.h> +#include <LibGfx/Rect.h> + +namespace PixelPaint { + +ImageEditor::ImageEditor() + : m_undo_stack(make<GUI::UndoStack>()) +{ + set_focus_policy(GUI::FocusPolicy::StrongFocus); +} + +ImageEditor::~ImageEditor() +{ + if (m_image) + m_image->remove_client(*this); +} + +void ImageEditor::set_image(RefPtr<Image> image) +{ + if (m_image) + m_image->remove_client(*this); + + m_image = move(image); + m_active_layer = nullptr; + m_undo_stack = make<GUI::UndoStack>(); + m_undo_stack->push(make<ImageUndoCommand>(*m_image)); + update(); + relayout(); + + if (m_image) + m_image->add_client(*this); +} + +void ImageEditor::did_complete_action() +{ + if (!m_image) + return; + m_undo_stack->finalize_current_combo(); + m_undo_stack->push(make<ImageUndoCommand>(*m_image)); +} + +bool ImageEditor::undo() +{ + if (!m_image) + return false; + if (m_undo_stack->can_undo()) { + m_undo_stack->undo(); + layers_did_change(); + return true; + } + return false; +} + +bool ImageEditor::redo() +{ + if (!m_image) + return false; + if (m_undo_stack->can_redo()) { + m_undo_stack->redo(); + layers_did_change(); + return true; + } + return false; +} + +void ImageEditor::paint_event(GUI::PaintEvent& event) +{ + GUI::Frame::paint_event(event); + + GUI::Painter painter(*this); + painter.add_clip_rect(event.rect()); + painter.add_clip_rect(frame_inner_rect()); + + Gfx::StylePainter::paint_transparency_grid(painter, rect(), palette()); + + if (m_image) { + painter.draw_rect(m_editor_image_rect.inflated(2, 2), Color::Black); + m_image->paint_into(painter, m_editor_image_rect); + } + + if (m_active_layer) { + painter.draw_rect(enclosing_int_rect(image_rect_to_editor_rect(m_active_layer->relative_rect())).inflated(2, 2), Color::Black); + } +} + +Gfx::FloatRect ImageEditor::layer_rect_to_editor_rect(const Layer& layer, const Gfx::IntRect& layer_rect) const +{ + return image_rect_to_editor_rect(layer_rect.translated(layer.location())); +} + +Gfx::FloatRect ImageEditor::image_rect_to_editor_rect(const Gfx::IntRect& image_rect) const +{ + Gfx::FloatRect editor_rect; + editor_rect.set_location(image_position_to_editor_position(image_rect.location())); + editor_rect.set_width((float)image_rect.width() * m_scale); + editor_rect.set_height((float)image_rect.height() * m_scale); + return editor_rect; +} + +Gfx::FloatRect ImageEditor::editor_rect_to_image_rect(const Gfx::IntRect& editor_rect) const +{ + Gfx::FloatRect image_rect; + image_rect.set_location(editor_position_to_image_position(editor_rect.location())); + image_rect.set_width((float)editor_rect.width() / m_scale); + image_rect.set_height((float)editor_rect.height() / m_scale); + return image_rect; +} + +Gfx::FloatPoint ImageEditor::layer_position_to_editor_position(const Layer& layer, const Gfx::IntPoint& layer_position) const +{ + return image_position_to_editor_position(layer_position.translated(layer.location())); +} + +Gfx::FloatPoint ImageEditor::image_position_to_editor_position(const Gfx::IntPoint& image_position) const +{ + Gfx::FloatPoint editor_position; + editor_position.set_x(m_editor_image_rect.x() + ((float)image_position.x() * m_scale)); + editor_position.set_y(m_editor_image_rect.y() + ((float)image_position.y() * m_scale)); + return editor_position; +} + +Gfx::FloatPoint ImageEditor::editor_position_to_image_position(const Gfx::IntPoint& editor_position) const +{ + Gfx::FloatPoint image_position; + image_position.set_x(((float)editor_position.x() - m_editor_image_rect.x()) / m_scale); + image_position.set_y(((float)editor_position.y() - m_editor_image_rect.y()) / m_scale); + return image_position; +} + +void ImageEditor::second_paint_event(GUI::PaintEvent& event) +{ + if (m_active_tool && m_active_layer) + m_active_tool->on_second_paint(*m_active_layer, event); +} + +GUI::MouseEvent ImageEditor::event_with_pan_and_scale_applied(const GUI::MouseEvent& event) const +{ + auto image_position = editor_position_to_image_position(event.position()); + return { + static_cast<GUI::Event::Type>(event.type()), + Gfx::IntPoint(image_position.x(), image_position.y()), + event.buttons(), + event.button(), + event.modifiers(), + event.wheel_delta() + }; +} + +GUI::MouseEvent ImageEditor::event_adjusted_for_layer(const GUI::MouseEvent& event, const Layer& layer) const +{ + auto image_position = editor_position_to_image_position(event.position()); + image_position.move_by(-layer.location().x(), -layer.location().y()); + return { + static_cast<GUI::Event::Type>(event.type()), + Gfx::IntPoint(image_position.x(), image_position.y()), + event.buttons(), + event.button(), + event.modifiers(), + event.wheel_delta() + }; +} + +void ImageEditor::mousedown_event(GUI::MouseEvent& event) +{ + if (event.button() == GUI::MouseButton::Middle) { + m_click_position = event.position(); + m_saved_pan_origin = m_pan_origin; + return; + } + + if (!m_active_tool) + return; + + if (is<MoveTool>(*m_active_tool)) { + if (auto* other_layer = layer_at_editor_position(event.position())) { + set_active_layer(other_layer); + } + } + + if (!m_active_layer) + return; + + auto layer_event = event_adjusted_for_layer(event, *m_active_layer); + auto image_event = event_with_pan_and_scale_applied(event); + m_active_tool->on_mousedown(*m_active_layer, layer_event, image_event); +} + +void ImageEditor::mousemove_event(GUI::MouseEvent& event) +{ + if (event.buttons() & GUI::MouseButton::Middle) { + auto delta = event.position() - m_click_position; + m_pan_origin = m_saved_pan_origin.translated( + -delta.x() / m_scale, + -delta.y() / m_scale); + + relayout(); + return; + } + + if (!m_active_layer || !m_active_tool) + return; + auto layer_event = event_adjusted_for_layer(event, *m_active_layer); + auto image_event = event_with_pan_and_scale_applied(event); + + m_active_tool->on_mousemove(*m_active_layer, layer_event, image_event); +} + +void ImageEditor::mouseup_event(GUI::MouseEvent& event) +{ + if (!m_active_layer || !m_active_tool) + return; + auto layer_event = event_adjusted_for_layer(event, *m_active_layer); + auto image_event = event_with_pan_and_scale_applied(event); + m_active_tool->on_mouseup(*m_active_layer, layer_event, image_event); +} + +void ImageEditor::mousewheel_event(GUI::MouseEvent& event) +{ + auto old_scale = m_scale; + + m_scale += -event.wheel_delta() * 0.1f; + if (m_scale < 0.1f) + m_scale = 0.1f; + if (m_scale > 100.0f) + m_scale = 100.0f; + + auto focus_point = Gfx::FloatPoint( + m_pan_origin.x() - ((float)event.x() - (float)width() / 2.0) / old_scale, + m_pan_origin.y() - ((float)event.y() - (float)height() / 2.0) / old_scale); + + m_pan_origin = Gfx::FloatPoint( + focus_point.x() - m_scale / old_scale * (focus_point.x() - m_pan_origin.x()), + focus_point.y() - m_scale / old_scale * (focus_point.y() - m_pan_origin.y())); + + if (old_scale != m_scale) + relayout(); +} + +void ImageEditor::context_menu_event(GUI::ContextMenuEvent& event) +{ + if (!m_active_layer || !m_active_tool) + return; + m_active_tool->on_context_menu(*m_active_layer, event); +} + +void ImageEditor::resize_event(GUI::ResizeEvent& event) +{ + relayout(); + GUI::Frame::resize_event(event); +} + +void ImageEditor::keydown_event(GUI::KeyEvent& event) +{ + if (m_active_tool) + m_active_tool->on_keydown(event); +} + +void ImageEditor::keyup_event(GUI::KeyEvent& event) +{ + if (m_active_tool) + m_active_tool->on_keyup(event); +} + +void ImageEditor::set_active_layer(Layer* layer) +{ + if (m_active_layer == layer) + return; + m_active_layer = layer; + + if (m_active_layer) { + size_t index = 0; + for (; index < m_image->layer_count(); ++index) { + if (&m_image->layer(index) == layer) + break; + } + if (on_active_layer_change) + on_active_layer_change(layer); + } else { + if (on_active_layer_change) + on_active_layer_change({}); + } + + layers_did_change(); +} + +void ImageEditor::set_active_tool(Tool* tool) +{ + if (m_active_tool == tool) + return; + + if (m_active_tool) + m_active_tool->clear(); + + m_active_tool = tool; + + if (m_active_tool) + m_active_tool->setup(*this); +} + +void ImageEditor::layers_did_change() +{ + update(); +} + +Color ImageEditor::color_for(GUI::MouseButton button) const +{ + if (button == GUI::MouseButton::Left) + return m_primary_color; + if (button == GUI::MouseButton::Right) + return m_secondary_color; + ASSERT_NOT_REACHED(); +} + +Color ImageEditor::color_for(const GUI::MouseEvent& event) const +{ + if (event.buttons() & GUI::MouseButton::Left) + return m_primary_color; + if (event.buttons() & GUI::MouseButton::Right) + return m_secondary_color; + ASSERT_NOT_REACHED(); +} + +void ImageEditor::set_primary_color(Color color) +{ + if (m_primary_color == color) + return; + m_primary_color = color; + if (on_primary_color_change) + on_primary_color_change(color); +} + +void ImageEditor::set_secondary_color(Color color) +{ + if (m_secondary_color == color) + return; + m_secondary_color = color; + if (on_secondary_color_change) + on_secondary_color_change(color); +} + +Layer* ImageEditor::layer_at_editor_position(const Gfx::IntPoint& editor_position) +{ + if (!m_image) + return nullptr; + auto image_position = editor_position_to_image_position(editor_position); + for (ssize_t i = m_image->layer_count() - 1; i >= 0; --i) { + auto& layer = m_image->layer(i); + if (!layer.is_visible()) + continue; + if (layer.relative_rect().contains(Gfx::IntPoint(image_position.x(), image_position.y()))) + return const_cast<Layer*>(&layer); + } + return nullptr; +} + +void ImageEditor::relayout() +{ + if (!image()) + return; + auto& image = *this->image(); + + Gfx::IntSize new_size; + new_size.set_width(image.size().width() * m_scale); + new_size.set_height(image.size().height() * m_scale); + m_editor_image_rect.set_size(new_size); + + Gfx::IntPoint new_location; + new_location.set_x((width() / 2) - (new_size.width() / 2) - (m_pan_origin.x() * m_scale)); + new_location.set_y((height() / 2) - (new_size.height() / 2) - (m_pan_origin.y() * m_scale)); + m_editor_image_rect.set_location(new_location); + + update(); +} + +void ImageEditor::image_did_change() +{ + update(); +} + +void ImageEditor::image_select_layer(Layer* layer) +{ + set_active_layer(layer); +} + +} diff --git a/Userland/Applications/PixelPaint/ImageEditor.h b/Userland/Applications/PixelPaint/ImageEditor.h new file mode 100644 index 0000000000..b6cdc8fb62 --- /dev/null +++ b/Userland/Applications/PixelPaint/ImageEditor.h @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Image.h" +#include <LibGUI/Frame.h> +#include <LibGUI/UndoStack.h> +#include <LibGfx/Point.h> + +namespace PixelPaint { + +class Layer; +class Tool; + +class ImageEditor final + : public GUI::Frame + , public ImageClient { + C_OBJECT(ImageEditor); + +public: + virtual ~ImageEditor() override; + + const Image* image() const { return m_image; } + Image* image() { return m_image; } + + void set_image(RefPtr<Image>); + + Layer* active_layer() { return m_active_layer; } + void set_active_layer(Layer*); + + Tool* active_tool() { return m_active_tool; } + void set_active_tool(Tool*); + + void did_complete_action(); + bool undo(); + bool redo(); + + void layers_did_change(); + + Layer* layer_at_editor_position(const Gfx::IntPoint&); + + Color primary_color() const { return m_primary_color; } + void set_primary_color(Color); + + Color secondary_color() const { return m_secondary_color; } + void set_secondary_color(Color); + + Color color_for(const GUI::MouseEvent&) const; + Color color_for(GUI::MouseButton) const; + + Function<void(Color)> on_primary_color_change; + Function<void(Color)> on_secondary_color_change; + + Function<void(Layer*)> on_active_layer_change; + + Gfx::FloatRect layer_rect_to_editor_rect(const Layer&, const Gfx::IntRect&) const; + Gfx::FloatRect image_rect_to_editor_rect(const Gfx::IntRect&) const; + Gfx::FloatRect editor_rect_to_image_rect(const Gfx::IntRect&) const; + Gfx::FloatPoint layer_position_to_editor_position(const Layer&, const Gfx::IntPoint&) const; + Gfx::FloatPoint image_position_to_editor_position(const Gfx::IntPoint&) const; + Gfx::FloatPoint editor_position_to_image_position(const Gfx::IntPoint&) const; + +private: + ImageEditor(); + + virtual void paint_event(GUI::PaintEvent&) override; + virtual void second_paint_event(GUI::PaintEvent&) override; + virtual void mousedown_event(GUI::MouseEvent&) override; + virtual void mousemove_event(GUI::MouseEvent&) override; + virtual void mouseup_event(GUI::MouseEvent&) override; + virtual void mousewheel_event(GUI::MouseEvent&) override; + virtual void keydown_event(GUI::KeyEvent&) override; + virtual void keyup_event(GUI::KeyEvent&) override; + virtual void context_menu_event(GUI::ContextMenuEvent&) override; + virtual void resize_event(GUI::ResizeEvent&) override; + + virtual void image_did_change() override; + virtual void image_select_layer(Layer*) override; + + GUI::MouseEvent event_adjusted_for_layer(const GUI::MouseEvent&, const Layer&) const; + GUI::MouseEvent event_with_pan_and_scale_applied(const GUI::MouseEvent&) const; + + void relayout(); + + RefPtr<Image> m_image; + RefPtr<Layer> m_active_layer; + OwnPtr<GUI::UndoStack> m_undo_stack; + + Tool* m_active_tool { nullptr }; + + Color m_primary_color { Color::Black }; + Color m_secondary_color { Color::White }; + + Gfx::IntRect m_editor_image_rect; + float m_scale { 1 }; + Gfx::FloatPoint m_pan_origin; + Gfx::FloatPoint m_saved_pan_origin; + Gfx::IntPoint m_click_position; +}; + +} diff --git a/Userland/Applications/PixelPaint/Layer.cpp b/Userland/Applications/PixelPaint/Layer.cpp new file mode 100644 index 0000000000..e607113c32 --- /dev/null +++ b/Userland/Applications/PixelPaint/Layer.cpp @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "Layer.h" +#include "Image.h" +#include <LibGfx/Bitmap.h> + +namespace PixelPaint { + +RefPtr<Layer> Layer::create_with_size(Image& image, const Gfx::IntSize& size, const String& name) +{ + if (size.is_empty()) + return nullptr; + + if (size.width() > 16384 || size.height() > 16384) + return nullptr; + + return adopt(*new Layer(image, size, name)); +} + +RefPtr<Layer> Layer::create_with_bitmap(Image& image, const Gfx::Bitmap& bitmap, const String& name) +{ + if (bitmap.size().is_empty()) + return nullptr; + + if (bitmap.size().width() > 16384 || bitmap.size().height() > 16384) + return nullptr; + + return adopt(*new Layer(image, bitmap, name)); +} + +RefPtr<Layer> Layer::create_snapshot(Image& image, const Layer& layer) +{ + auto snapshot = create_with_bitmap(image, *layer.bitmap().clone(), layer.name()); + snapshot->set_opacity_percent(layer.opacity_percent()); + snapshot->set_visible(layer.is_visible()); + snapshot->set_selected(layer.is_selected()); + snapshot->set_location(layer.location()); + return snapshot; +} + +Layer::Layer(Image& image, const Gfx::IntSize& size, const String& name) + : m_image(image) + , m_name(name) +{ + m_bitmap = Gfx::Bitmap::create(Gfx::BitmapFormat::RGBA32, size); +} + +Layer::Layer(Image& image, const Gfx::Bitmap& bitmap, const String& name) + : m_image(image) + , m_name(name) + , m_bitmap(bitmap) +{ +} + +void Layer::did_modify_bitmap(Image& image) +{ + image.layer_did_modify_bitmap({}, *this); +} + +void Layer::set_visible(bool visible) +{ + if (m_visible == visible) + return; + m_visible = visible; + m_image.layer_did_modify_properties({}, *this); +} + +void Layer::set_opacity_percent(int opacity_percent) +{ + if (m_opacity_percent == opacity_percent) + return; + m_opacity_percent = opacity_percent; + m_image.layer_did_modify_properties({}, *this); +} + +void Layer::set_name(const String& name) +{ + if (m_name == name) + return; + m_name = name; + m_image.layer_did_modify_properties({}, *this); +} + +} diff --git a/Userland/Applications/PixelPaint/Layer.h b/Userland/Applications/PixelPaint/Layer.h new file mode 100644 index 0000000000..b3b7d4d603 --- /dev/null +++ b/Userland/Applications/PixelPaint/Layer.h @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/Noncopyable.h> +#include <AK/RefCounted.h> +#include <AK/String.h> +#include <AK/Weakable.h> +#include <LibGfx/Bitmap.h> + +namespace PixelPaint { + +class Image; + +class Layer + : public RefCounted<Layer> + , public Weakable<Layer> { + + AK_MAKE_NONCOPYABLE(Layer); + AK_MAKE_NONMOVABLE(Layer); + +public: + static RefPtr<Layer> create_with_size(Image&, const Gfx::IntSize&, const String& name); + static RefPtr<Layer> create_with_bitmap(Image&, const Gfx::Bitmap&, const String& name); + static RefPtr<Layer> create_snapshot(Image&, const Layer&); + + ~Layer() { } + + const Gfx::IntPoint& location() const { return m_location; } + void set_location(const Gfx::IntPoint& location) { m_location = location; } + + const Gfx::Bitmap& bitmap() const { return *m_bitmap; } + Gfx::Bitmap& bitmap() { return *m_bitmap; } + Gfx::IntSize size() const { return bitmap().size(); } + + Gfx::IntRect relative_rect() const { return { location(), size() }; } + Gfx::IntRect rect() const { return { {}, size() }; } + + const String& name() const { return m_name; } + void set_name(const String&); + + void set_bitmap(Gfx::Bitmap& bitmap) { m_bitmap = bitmap; } + + void did_modify_bitmap(Image&); + + void set_selected(bool selected) { m_selected = selected; } + bool is_selected() const { return m_selected; } + + bool is_visible() const { return m_visible; } + void set_visible(bool visible); + + int opacity_percent() const { return m_opacity_percent; } + void set_opacity_percent(int); + +private: + Layer(Image&, const Gfx::IntSize&, const String& name); + Layer(Image&, const Gfx::Bitmap&, const String& name); + + Image& m_image; + + String m_name; + Gfx::IntPoint m_location; + RefPtr<Gfx::Bitmap> m_bitmap; + + bool m_selected { false }; + bool m_visible { true }; + + int m_opacity_percent { 100 }; +}; + +} diff --git a/Userland/Applications/PixelPaint/LayerListWidget.cpp b/Userland/Applications/PixelPaint/LayerListWidget.cpp new file mode 100644 index 0000000000..756772bb87 --- /dev/null +++ b/Userland/Applications/PixelPaint/LayerListWidget.cpp @@ -0,0 +1,285 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "LayerListWidget.h" +#include "Image.h" +#include "ImageEditor.h" +#include "Layer.h" +#include <LibGUI/Painter.h> +#include <LibGfx/Palette.h> + +namespace PixelPaint { + +LayerListWidget::LayerListWidget() +{ +} + +LayerListWidget::~LayerListWidget() +{ + if (m_image) + m_image->remove_client(*this); +} + +void LayerListWidget::set_image(Image* image) +{ + if (m_image == image) + return; + if (m_image) + m_image->remove_client(*this); + m_image = image; + if (m_image) + m_image->add_client(*this); + + rebuild_gadgets(); +} + +void LayerListWidget::rebuild_gadgets() +{ + m_gadgets.clear(); + if (m_image) { + for (size_t layer_index = 0; layer_index < m_image->layer_count(); ++layer_index) { + m_gadgets.append({ layer_index, {}, {}, false, {} }); + } + } + relayout_gadgets(); +} + +void LayerListWidget::resize_event(GUI::ResizeEvent& event) +{ + Widget::resize_event(event); + relayout_gadgets(); +} + +void LayerListWidget::paint_event(GUI::PaintEvent& event) +{ + GUI::Painter painter(*this); + painter.add_clip_rect(event.rect()); + + painter.fill_rect(event.rect(), palette().button()); + + if (!m_image) + return; + + painter.fill_rect(event.rect(), palette().button()); + + auto paint_gadget = [&](auto& gadget) { + auto& layer = m_image->layer(gadget.layer_index); + + auto adjusted_rect = gadget.rect; + + if (gadget.is_moving) { + adjusted_rect.move_by(0, gadget.movement_delta.y()); + } + + if (gadget.is_moving) { + painter.fill_rect(adjusted_rect, palette().selection().lightened(1.5f)); + } else if (layer.is_selected()) { + painter.fill_rect(adjusted_rect, palette().selection()); + } + + painter.draw_rect(adjusted_rect, Color::Black); + + Gfx::IntRect thumbnail_rect { adjusted_rect.x(), adjusted_rect.y(), adjusted_rect.height(), adjusted_rect.height() }; + thumbnail_rect.shrink(8, 8); + painter.draw_scaled_bitmap(thumbnail_rect, layer.bitmap(), layer.bitmap().rect()); + + Gfx::IntRect text_rect { thumbnail_rect.right() + 10, adjusted_rect.y(), adjusted_rect.width(), adjusted_rect.height() }; + text_rect.intersect(adjusted_rect); + + painter.draw_text(text_rect, layer.name(), Gfx::TextAlignment::CenterLeft, layer.is_selected() ? palette().selection_text() : palette().button_text()); + }; + + for (auto& gadget : m_gadgets) { + if (!gadget.is_moving) + paint_gadget(gadget); + } + + if (m_moving_gadget_index.has_value()) + paint_gadget(m_gadgets[m_moving_gadget_index.value()]); +} + +Optional<size_t> LayerListWidget::gadget_at(const Gfx::IntPoint& position) +{ + for (size_t i = 0; i < m_gadgets.size(); ++i) { + if (m_gadgets[i].rect.contains(position)) + return i; + } + return {}; +} + +void LayerListWidget::mousedown_event(GUI::MouseEvent& event) +{ + if (!m_image) + return; + if (event.button() != GUI::MouseButton::Left) + return; + auto gadget_index = gadget_at(event.position()); + if (!gadget_index.has_value()) { + if (on_layer_select) + on_layer_select(nullptr); + return; + } + m_moving_gadget_index = gadget_index; + m_moving_event_origin = event.position(); + auto& gadget = m_gadgets[m_moving_gadget_index.value()]; + auto& layer = m_image->layer(gadget_index.value()); + set_selected_layer(&layer); + gadget.is_moving = true; + gadget.movement_delta = {}; + update(); +} + +void LayerListWidget::mousemove_event(GUI::MouseEvent& event) +{ + if (!m_image) + return; + if (!m_moving_gadget_index.has_value()) + return; + + auto delta = event.position() - m_moving_event_origin; + auto& gadget = m_gadgets[m_moving_gadget_index.value()]; + ASSERT(gadget.is_moving); + gadget.movement_delta = delta; + relayout_gadgets(); +} + +void LayerListWidget::mouseup_event(GUI::MouseEvent& event) +{ + if (!m_image) + return; + if (event.button() != GUI::MouseButton::Left) + return; + if (!m_moving_gadget_index.has_value()) + return; + + size_t old_index = m_moving_gadget_index.value(); + size_t new_index = hole_index_during_move(); + if (new_index >= m_image->layer_count()) + new_index = m_image->layer_count() - 1; + + m_moving_gadget_index = {}; + m_image->change_layer_index(old_index, new_index); +} + +void LayerListWidget::image_did_add_layer(size_t layer_index) +{ + if (m_moving_gadget_index.has_value()) { + m_gadgets[m_moving_gadget_index.value()].is_moving = false; + m_moving_gadget_index = {}; + } + Gadget gadget { layer_index, {}, {}, false, {} }; + m_gadgets.insert(layer_index, move(gadget)); + relayout_gadgets(); +} + +void LayerListWidget::image_did_remove_layer(size_t layer_index) +{ + if (m_moving_gadget_index.has_value()) { + m_gadgets[m_moving_gadget_index.value()].is_moving = false; + m_moving_gadget_index = {}; + } + m_gadgets.remove(layer_index); + relayout_gadgets(); +} + +void LayerListWidget::image_did_modify_layer(size_t layer_index) +{ + update(m_gadgets[layer_index].rect); +} + +void LayerListWidget::image_did_modify_layer_stack() +{ + rebuild_gadgets(); +} + +static constexpr int gadget_height = 30; +static constexpr int gadget_spacing = 1; +static constexpr int vertical_step = gadget_height + gadget_spacing; + +size_t LayerListWidget::hole_index_during_move() const +{ + ASSERT(is_moving_gadget()); + auto& moving_gadget = m_gadgets[m_moving_gadget_index.value()]; + int center_y_of_moving_gadget = moving_gadget.rect.translated(0, moving_gadget.movement_delta.y()).center().y(); + return center_y_of_moving_gadget / vertical_step; +} + +void LayerListWidget::select_bottom_layer() +{ + if (!m_image || !m_image->layer_count()) + return; + set_selected_layer(&m_image->layer(0)); +} + +void LayerListWidget::select_top_layer() +{ + if (!m_image || !m_image->layer_count()) + return; + set_selected_layer(&m_image->layer(m_image->layer_count() - 1)); +} + +void LayerListWidget::move_selection(int delta) +{ + if (!m_image || !m_image->layer_count()) + return; + int new_layer_index = min(max(0, (int)m_image->layer_count() + delta), (int)m_image->layer_count() - 1); + set_selected_layer(&m_image->layer(new_layer_index)); +} + +void LayerListWidget::relayout_gadgets() +{ + int y = 0; + + Optional<size_t> hole_index; + if (is_moving_gadget()) + hole_index = hole_index_during_move(); + + size_t index = 0; + for (auto& gadget : m_gadgets) { + if (gadget.is_moving) + continue; + if (hole_index.has_value() && index == hole_index.value()) + y += vertical_step; + gadget.rect = { 0, y, width(), gadget_height }; + y += vertical_step; + ++index; + } + + update(); +} + +void LayerListWidget::set_selected_layer(Layer* layer) +{ + if (!m_image) + return; + for (size_t i = 0; i < m_image->layer_count(); ++i) + m_image->layer(i).set_selected(layer == &m_image->layer(i)); + if (on_layer_select) + on_layer_select(layer); + update(); +} + +} diff --git a/Userland/Applications/PixelPaint/LayerListWidget.h b/Userland/Applications/PixelPaint/LayerListWidget.h new file mode 100644 index 0000000000..9550913b8e --- /dev/null +++ b/Userland/Applications/PixelPaint/LayerListWidget.h @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Image.h" +#include <LibGUI/Widget.h> + +namespace PixelPaint { + +class LayerListWidget final + : public GUI::Widget + , ImageClient { + C_OBJECT(LayerListWidget); + +public: + virtual ~LayerListWidget() override; + + void set_image(Image*); + + void set_selected_layer(Layer*); + Function<void(Layer*)> on_layer_select; + + void select_bottom_layer(); + void select_top_layer(); + void move_selection(int delta); + +private: + explicit LayerListWidget(); + + virtual void paint_event(GUI::PaintEvent&) override; + virtual void mousedown_event(GUI::MouseEvent&) override; + virtual void mousemove_event(GUI::MouseEvent&) override; + virtual void mouseup_event(GUI::MouseEvent&) override; + virtual void resize_event(GUI::ResizeEvent&) override; + + virtual void image_did_add_layer(size_t) override; + virtual void image_did_remove_layer(size_t) override; + virtual void image_did_modify_layer(size_t) override; + virtual void image_did_modify_layer_stack() override; + + void rebuild_gadgets(); + void relayout_gadgets(); + + size_t hole_index_during_move() const; + + struct Gadget { + size_t layer_index { 0 }; + Gfx::IntRect rect; + Gfx::IntRect temporary_rect_during_move; + bool is_moving { false }; + Gfx::IntPoint movement_delta; + }; + + bool is_moving_gadget() const { return m_moving_gadget_index.has_value(); } + + Optional<size_t> gadget_at(const Gfx::IntPoint&); + + Vector<Gadget> m_gadgets; + RefPtr<Image> m_image; + + Optional<size_t> m_moving_gadget_index; + Gfx::IntPoint m_moving_event_origin; +}; + +} diff --git a/Userland/Applications/PixelPaint/LayerPropertiesWidget.cpp b/Userland/Applications/PixelPaint/LayerPropertiesWidget.cpp new file mode 100644 index 0000000000..cfdee65c0a --- /dev/null +++ b/Userland/Applications/PixelPaint/LayerPropertiesWidget.cpp @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "LayerPropertiesWidget.h" +#include "Layer.h" +#include <LibGUI/BoxLayout.h> +#include <LibGUI/CheckBox.h> +#include <LibGUI/GroupBox.h> +#include <LibGUI/Label.h> +#include <LibGUI/OpacitySlider.h> +#include <LibGUI/TextBox.h> +#include <LibGfx/Font.h> + +namespace PixelPaint { + +LayerPropertiesWidget::LayerPropertiesWidget() +{ + set_layout<GUI::VerticalBoxLayout>(); + + auto& group_box = add<GUI::GroupBox>("Layer properties"); + auto& layout = group_box.set_layout<GUI::VerticalBoxLayout>(); + + layout.set_margins({ 10, 20, 10, 10 }); + + auto& name_container = group_box.add<GUI::Widget>(); + name_container.set_fixed_height(20); + name_container.set_layout<GUI::HorizontalBoxLayout>(); + + auto& name_label = name_container.add<GUI::Label>("Name:"); + name_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + name_label.set_fixed_size(80, 20); + + m_name_textbox = name_container.add<GUI::TextBox>(); + m_name_textbox->set_fixed_height(20); + m_name_textbox->on_change = [this] { + if (m_layer) + m_layer->set_name(m_name_textbox->text()); + }; + + auto& opacity_container = group_box.add<GUI::Widget>(); + opacity_container.set_fixed_height(20); + opacity_container.set_layout<GUI::HorizontalBoxLayout>(); + + auto& opacity_label = opacity_container.add<GUI::Label>("Opacity:"); + opacity_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + opacity_label.set_fixed_size(80, 20); + + m_opacity_slider = opacity_container.add<GUI::OpacitySlider>(); + m_opacity_slider->set_range(0, 100); + m_opacity_slider->on_change = [this](int value) { + if (m_layer) + m_layer->set_opacity_percent(value); + }; + + m_visibility_checkbox = group_box.add<GUI::CheckBox>("Visible"); + m_visibility_checkbox->set_fixed_height(20); + m_visibility_checkbox->on_checked = [this](bool checked) { + if (m_layer) + m_layer->set_visible(checked); + }; +} + +LayerPropertiesWidget::~LayerPropertiesWidget() +{ +} + +void LayerPropertiesWidget::set_layer(Layer* layer) +{ + if (m_layer == layer) + return; + + if (layer) { + m_layer = layer->make_weak_ptr(); + m_name_textbox->set_text(layer->name()); + m_opacity_slider->set_value(layer->opacity_percent()); + m_visibility_checkbox->set_checked(layer->is_visible()); + set_enabled(true); + } else { + m_layer = nullptr; + set_enabled(false); + } +} + +} diff --git a/Userland/Applications/PixelPaint/LayerPropertiesWidget.h b/Userland/Applications/PixelPaint/LayerPropertiesWidget.h new file mode 100644 index 0000000000..e07e02ec93 --- /dev/null +++ b/Userland/Applications/PixelPaint/LayerPropertiesWidget.h @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Widget.h> + +namespace PixelPaint { + +class Layer; + +class LayerPropertiesWidget final : public GUI::Widget { + C_OBJECT(LayerPropertiesWidget); + +public: + virtual ~LayerPropertiesWidget() override; + + void set_layer(Layer*); + +private: + LayerPropertiesWidget(); + + RefPtr<GUI::CheckBox> m_visibility_checkbox; + RefPtr<GUI::OpacitySlider> m_opacity_slider; + RefPtr<GUI::TextBox> m_name_textbox; + + WeakPtr<Layer> m_layer; +}; + +} diff --git a/Userland/Applications/PixelPaint/LineTool.cpp b/Userland/Applications/PixelPaint/LineTool.cpp new file mode 100644 index 0000000000..7c3e37e836 --- /dev/null +++ b/Userland/Applications/PixelPaint/LineTool.cpp @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "LineTool.h" +#include "ImageEditor.h" +#include "Layer.h" +#include <LibGUI/Action.h> +#include <LibGUI/Menu.h> +#include <LibGUI/Painter.h> +#include <math.h> + +namespace PixelPaint { + +static Gfx::IntPoint constrain_line_angle(const Gfx::IntPoint& start_pos, const Gfx::IntPoint& end_pos, float angle_increment) +{ + float current_angle = atan2(end_pos.y() - start_pos.y(), end_pos.x() - start_pos.x()) + M_PI * 2.; + + float constrained_angle = ((int)((current_angle + angle_increment / 2.) / angle_increment)) * angle_increment; + + auto diff = end_pos - start_pos; + float line_length = sqrt(diff.x() * diff.x() + diff.y() * diff.y()); + + return { start_pos.x() + (int)(cos(constrained_angle) * line_length), + start_pos.y() + (int)(sin(constrained_angle) * line_length) }; +} + +LineTool::LineTool() +{ +} + +LineTool::~LineTool() +{ +} + +void LineTool::on_mousedown(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent&) +{ + if (layer_event.button() != GUI::MouseButton::Left && layer_event.button() != GUI::MouseButton::Right) + return; + + if (m_drawing_button != GUI::MouseButton::None) + return; + + m_drawing_button = layer_event.button(); + + m_line_start_position = layer_event.position(); + m_line_end_position = layer_event.position(); + + m_editor->update(); +} + +void LineTool::on_mouseup(Layer& layer, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + if (event.button() == m_drawing_button) { + GUI::Painter painter(layer.bitmap()); + painter.draw_line(m_line_start_position, m_line_end_position, m_editor->color_for(m_drawing_button), m_thickness); + m_drawing_button = GUI::MouseButton::None; + layer.did_modify_bitmap(*m_editor->image()); + m_editor->did_complete_action(); + } +} + +void LineTool::on_mousemove(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent&) +{ + if (m_drawing_button == GUI::MouseButton::None) + return; + + if (!m_constrain_angle) { + m_line_end_position = layer_event.position(); + } else { + const float ANGLE_STEP = M_PI / 8.0f; + m_line_end_position = constrain_line_angle(m_line_start_position, layer_event.position(), ANGLE_STEP); + } + m_editor->update(); +} + +void LineTool::on_second_paint(const Layer& layer, GUI::PaintEvent& event) +{ + if (m_drawing_button == GUI::MouseButton::None) + return; + + GUI::Painter painter(*m_editor); + painter.add_clip_rect(event.rect()); + auto preview_start = m_editor->layer_position_to_editor_position(layer, m_line_start_position).to_type<int>(); + auto preview_end = m_editor->layer_position_to_editor_position(layer, m_line_end_position).to_type<int>(); + painter.draw_line(preview_start, preview_end, m_editor->color_for(m_drawing_button), m_thickness); +} + +void LineTool::on_keydown(GUI::KeyEvent& event) +{ + if (event.key() == Key_Escape && m_drawing_button != GUI::MouseButton::None) { + m_drawing_button = GUI::MouseButton::None; + m_editor->update(); + event.accept(); + } + + if (event.key() == Key_Shift) { + m_constrain_angle = true; + m_editor->update(); + event.accept(); + } +} + +void LineTool::on_keyup(GUI::KeyEvent& event) +{ + if (event.key() == Key_Shift) { + m_constrain_angle = false; + m_editor->update(); + event.accept(); + } +} + +void LineTool::on_tool_button_contextmenu(GUI::ContextMenuEvent& event) +{ + if (!m_context_menu) { + m_context_menu = GUI::Menu::construct(); + m_thickness_actions.set_exclusive(true); + auto insert_action = [&](int size, bool checked = false) { + auto action = GUI::Action::create_checkable(String::number(size), [this, size](auto&) { + m_thickness = size; + }); + action->set_checked(checked); + m_thickness_actions.add_action(*action); + m_context_menu->add_action(move(action)); + }; + insert_action(1, true); + insert_action(2); + insert_action(3); + insert_action(4); + } + m_context_menu->popup(event.screen_position()); +} + +} diff --git a/Userland/Applications/PixelPaint/LineTool.h b/Userland/Applications/PixelPaint/LineTool.h new file mode 100644 index 0000000000..aa8757655c --- /dev/null +++ b/Userland/Applications/PixelPaint/LineTool.h @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Tool.h" +#include <LibGUI/ActionGroup.h> +#include <LibGfx/Point.h> + +namespace PixelPaint { + +class LineTool final : public Tool { +public: + LineTool(); + virtual ~LineTool() override; + + virtual void on_mousedown(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_mousemove(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_mouseup(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_tool_button_contextmenu(GUI::ContextMenuEvent&) override; + virtual void on_second_paint(const Layer&, GUI::PaintEvent&) override; + virtual void on_keydown(GUI::KeyEvent&) override; + virtual void on_keyup(GUI::KeyEvent&) override; + +private: + GUI::MouseButton m_drawing_button { GUI::MouseButton::None }; + Gfx::IntPoint m_line_start_position; + Gfx::IntPoint m_line_end_position; + + RefPtr<GUI::Menu> m_context_menu; + GUI::ActionGroup m_thickness_actions; + int m_thickness { 1 }; + bool m_constrain_angle { false }; +}; + +} diff --git a/Userland/Applications/PixelPaint/MoveTool.cpp b/Userland/Applications/PixelPaint/MoveTool.cpp new file mode 100644 index 0000000000..ebeba5ba89 --- /dev/null +++ b/Userland/Applications/PixelPaint/MoveTool.cpp @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "MoveTool.h" +#include "Image.h" +#include "ImageEditor.h" +#include "Layer.h" +#include <LibGUI/Action.h> +#include <LibGUI/Menu.h> +#include <LibGUI/Window.h> +#include <LibGfx/Bitmap.h> + +namespace PixelPaint { + +MoveTool::MoveTool() +{ +} + +MoveTool::~MoveTool() +{ +} + +void MoveTool::on_mousedown(Layer& layer, GUI::MouseEvent& event, GUI::MouseEvent& image_event) +{ + if (event.button() != GUI::MouseButton::Left) + return; + if (!layer.rect().contains(event.position())) + return; + m_layer_being_moved = layer; + m_event_origin = image_event.position(); + m_layer_origin = layer.location(); + m_editor->window()->set_cursor(Gfx::StandardCursor::Move); +} + +void MoveTool::on_mousemove(Layer&, GUI::MouseEvent&, GUI::MouseEvent& image_event) +{ + if (!m_layer_being_moved) + return; + auto delta = image_event.position() - m_event_origin; + m_layer_being_moved->set_location(m_layer_origin.translated(delta)); + m_editor->layers_did_change(); +} + +void MoveTool::on_mouseup(Layer&, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + if (event.button() != GUI::MouseButton::Left) + return; + m_layer_being_moved = nullptr; + m_editor->window()->set_cursor(Gfx::StandardCursor::None); + m_editor->did_complete_action(); +} + +void MoveTool::on_keydown(GUI::KeyEvent& event) +{ + if (event.modifiers() != 0) + return; + + auto* layer = m_editor->active_layer(); + if (!layer) + return; + + auto new_location = layer->location(); + + switch (event.key()) { + case Key_Up: + new_location.move_by(0, -1); + break; + case Key_Down: + new_location.move_by(0, 1); + break; + case Key_Left: + new_location.move_by(-1, 0); + break; + case Key_Right: + new_location.move_by(1, 0); + break; + default: + return; + } + + layer->set_location(new_location); + m_editor->layers_did_change(); +} + +void MoveTool::on_context_menu(Layer& layer, GUI::ContextMenuEvent& event) +{ + if (!m_context_menu) { + m_context_menu = GUI::Menu::construct(); + m_context_menu->add_action(GUI::CommonActions::make_move_to_front_action( + [this](auto&) { + m_editor->image()->move_layer_to_front(*m_context_menu_layer); + m_editor->layers_did_change(); + }, + m_editor)); + m_context_menu->add_action(GUI::CommonActions::make_move_to_back_action( + [this](auto&) { + m_editor->image()->move_layer_to_back(*m_context_menu_layer); + m_editor->layers_did_change(); + }, + m_editor)); + m_context_menu->add_separator(); + m_context_menu->add_action(GUI::Action::create( + "Delete layer", Gfx::Bitmap::load_from_file("/res/icons/16x16/delete.png"), [this](auto&) { + m_editor->image()->remove_layer(*m_context_menu_layer); + // FIXME: This should not be done imperatively here. Perhaps a Image::Client interface that ImageEditor can implement? + if (m_editor->active_layer() == m_context_menu_layer) + m_editor->set_active_layer(nullptr); + m_editor->layers_did_change(); + }, + m_editor)); + } + m_context_menu_layer = layer; + m_context_menu->popup(event.screen_position()); +} + +} diff --git a/Userland/Applications/PixelPaint/MoveTool.h b/Userland/Applications/PixelPaint/MoveTool.h new file mode 100644 index 0000000000..a0c16e5282 --- /dev/null +++ b/Userland/Applications/PixelPaint/MoveTool.h @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Tool.h" + +namespace PixelPaint { + +class MoveTool final : public Tool { +public: + MoveTool(); + virtual ~MoveTool() override; + + virtual void on_mousedown(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_mousemove(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_mouseup(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_keydown(GUI::KeyEvent&) override; + virtual void on_context_menu(Layer&, GUI::ContextMenuEvent&) override; + +private: + RefPtr<Layer> m_layer_being_moved; + Gfx::IntPoint m_event_origin; + Gfx::IntPoint m_layer_origin; + RefPtr<GUI::Menu> m_context_menu; + RefPtr<Layer> m_context_menu_layer; +}; + +} diff --git a/Userland/Applications/PixelPaint/PaletteWidget.cpp b/Userland/Applications/PixelPaint/PaletteWidget.cpp new file mode 100644 index 0000000000..52620b4b31 --- /dev/null +++ b/Userland/Applications/PixelPaint/PaletteWidget.cpp @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "PaletteWidget.h" +#include "ImageEditor.h" +#include <LibGUI/BoxLayout.h> +#include <LibGUI/ColorPicker.h> +#include <LibGfx/Palette.h> + +namespace PixelPaint { + +class ColorWidget : public GUI::Frame { + C_OBJECT(ColorWidget); + +public: + explicit ColorWidget(Color color, PaletteWidget& palette_widget) + : m_palette_widget(palette_widget) + , m_color(color) + { + } + + virtual ~ColorWidget() override + { + } + + virtual void mousedown_event(GUI::MouseEvent& event) override + { + if (event.modifiers() & KeyModifier::Mod_Ctrl && event.button() == GUI::MouseButton::Left) { + auto dialog = GUI::ColorPicker::construct(m_color, window()); + if (dialog->exec() == GUI::Dialog::ExecOK) { + m_color = dialog->color(); + auto pal = palette(); + pal.set_color(ColorRole::Background, m_color); + set_palette(pal); + update(); + } + return; + } + + if (event.button() == GUI::MouseButton::Left) + m_palette_widget.set_primary_color(m_color); + else if (event.button() == GUI::MouseButton::Right) + m_palette_widget.set_secondary_color(m_color); + } + +private: + PaletteWidget& m_palette_widget; + Color m_color; +}; + +PaletteWidget::PaletteWidget(ImageEditor& editor) + : m_editor(editor) +{ + set_frame_shape(Gfx::FrameShape::Panel); + set_frame_shadow(Gfx::FrameShadow::Raised); + set_frame_thickness(0); + set_fill_with_background_color(true); + + set_fixed_height(34); + + m_secondary_color_widget = add<GUI::Frame>(); + m_secondary_color_widget->set_relative_rect({ 2, 2, 60, 31 }); + m_secondary_color_widget->set_fill_with_background_color(true); + set_secondary_color(m_editor.secondary_color()); + + m_primary_color_widget = add<GUI::Frame>(); + Gfx::IntRect rect { 0, 0, 38, 15 }; + rect.center_within(m_secondary_color_widget->relative_rect()); + m_primary_color_widget->set_relative_rect(rect); + m_primary_color_widget->set_fill_with_background_color(true); + set_primary_color(m_editor.primary_color()); + + m_editor.on_primary_color_change = [this](Color color) { + set_primary_color(color); + }; + + m_editor.on_secondary_color_change = [this](Color color) { + set_secondary_color(color); + }; + + auto& color_container = add<GUI::Widget>(); + color_container.set_relative_rect(m_secondary_color_widget->relative_rect().right() + 2, 2, 500, 32); + color_container.set_layout<GUI::VerticalBoxLayout>(); + color_container.layout()->set_spacing(1); + + auto& top_color_container = color_container.add<GUI::Widget>(); + top_color_container.set_layout<GUI::HorizontalBoxLayout>(); + top_color_container.layout()->set_spacing(1); + + auto& bottom_color_container = color_container.add<GUI::Widget>(); + bottom_color_container.set_layout<GUI::HorizontalBoxLayout>(); + bottom_color_container.layout()->set_spacing(1); + + auto add_color_widget = [&](GUI::Widget& container, Color color) { + auto& color_widget = container.add<ColorWidget>(color, *this); + color_widget.set_fill_with_background_color(true); + auto pal = color_widget.palette(); + pal.set_color(ColorRole::Background, color); + color_widget.set_palette(pal); + }; + + add_color_widget(top_color_container, Color::from_rgb(0x000000)); + add_color_widget(top_color_container, Color::from_rgb(0x808080)); + add_color_widget(top_color_container, Color::from_rgb(0x800000)); + add_color_widget(top_color_container, Color::from_rgb(0x808000)); + add_color_widget(top_color_container, Color::from_rgb(0x008000)); + add_color_widget(top_color_container, Color::from_rgb(0x008080)); + add_color_widget(top_color_container, Color::from_rgb(0x000080)); + add_color_widget(top_color_container, Color::from_rgb(0x800080)); + add_color_widget(top_color_container, Color::from_rgb(0x808040)); + add_color_widget(top_color_container, Color::from_rgb(0x004040)); + add_color_widget(top_color_container, Color::from_rgb(0x0080ff)); + add_color_widget(top_color_container, Color::from_rgb(0x004080)); + add_color_widget(top_color_container, Color::from_rgb(0x8000ff)); + add_color_widget(top_color_container, Color::from_rgb(0x804000)); + + add_color_widget(bottom_color_container, Color::from_rgb(0xffffff)); + add_color_widget(bottom_color_container, Color::from_rgb(0xc0c0c0)); + add_color_widget(bottom_color_container, Color::from_rgb(0xff0000)); + add_color_widget(bottom_color_container, Color::from_rgb(0xffff00)); + add_color_widget(bottom_color_container, Color::from_rgb(0x00ff00)); + add_color_widget(bottom_color_container, Color::from_rgb(0x00ffff)); + add_color_widget(bottom_color_container, Color::from_rgb(0x0000ff)); + add_color_widget(bottom_color_container, Color::from_rgb(0xff00ff)); + add_color_widget(bottom_color_container, Color::from_rgb(0xffff80)); + add_color_widget(bottom_color_container, Color::from_rgb(0x00ff80)); + add_color_widget(bottom_color_container, Color::from_rgb(0x80ffff)); + add_color_widget(bottom_color_container, Color::from_rgb(0x8080ff)); + add_color_widget(bottom_color_container, Color::from_rgb(0xff0080)); + add_color_widget(bottom_color_container, Color::from_rgb(0xff8040)); +} + +PaletteWidget::~PaletteWidget() +{ +} + +void PaletteWidget::set_primary_color(Color color) +{ + m_editor.set_primary_color(color); + auto pal = m_primary_color_widget->palette(); + pal.set_color(ColorRole::Background, color); + m_primary_color_widget->set_palette(pal); + m_primary_color_widget->update(); +} + +void PaletteWidget::set_secondary_color(Color color) +{ + m_editor.set_secondary_color(color); + auto pal = m_secondary_color_widget->palette(); + pal.set_color(ColorRole::Background, color); + m_secondary_color_widget->set_palette(pal); + m_secondary_color_widget->update(); +} + +} diff --git a/Userland/Applications/PixelPaint/PaletteWidget.h b/Userland/Applications/PixelPaint/PaletteWidget.h new file mode 100644 index 0000000000..02c843f038 --- /dev/null +++ b/Userland/Applications/PixelPaint/PaletteWidget.h @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Frame.h> + +namespace PixelPaint { + +class ImageEditor; + +class PaletteWidget final : public GUI::Frame { + C_OBJECT(PaletteWidget); + +public: + virtual ~PaletteWidget() override; + + void set_primary_color(Color); + void set_secondary_color(Color); + +private: + explicit PaletteWidget(ImageEditor&); + + ImageEditor& m_editor; + RefPtr<GUI::Frame> m_primary_color_widget; + RefPtr<GUI::Frame> m_secondary_color_widget; +}; + +} diff --git a/Userland/Applications/PixelPaint/PenTool.cpp b/Userland/Applications/PixelPaint/PenTool.cpp new file mode 100644 index 0000000000..579231edf7 --- /dev/null +++ b/Userland/Applications/PixelPaint/PenTool.cpp @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "PenTool.h" +#include "ImageEditor.h" +#include "Layer.h" +#include <LibGUI/Action.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Label.h> +#include <LibGUI/Menu.h> +#include <LibGUI/Painter.h> +#include <LibGUI/Slider.h> + +namespace PixelPaint { + +PenTool::PenTool() +{ +} + +PenTool::~PenTool() +{ +} + +void PenTool::on_mousedown(Layer& layer, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + if (event.button() != GUI::MouseButton::Left && event.button() != GUI::MouseButton::Right) + return; + + GUI::Painter painter(layer.bitmap()); + painter.draw_line(event.position(), event.position(), m_editor->color_for(event), m_thickness); + layer.did_modify_bitmap(*m_editor->image()); + m_last_drawing_event_position = event.position(); +} + +void PenTool::on_mouseup(Layer&, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + if (event.button() == GUI::MouseButton::Left || event.button() == GUI::MouseButton::Right) { + m_last_drawing_event_position = { -1, -1 }; + m_editor->did_complete_action(); + } +} + +void PenTool::on_mousemove(Layer& layer, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + if (!(event.buttons() & GUI::MouseButton::Left || event.buttons() & GUI::MouseButton::Right)) + return; + GUI::Painter painter(layer.bitmap()); + + if (m_last_drawing_event_position != Gfx::IntPoint(-1, -1)) + painter.draw_line(m_last_drawing_event_position, event.position(), m_editor->color_for(event), m_thickness); + else + painter.draw_line(event.position(), event.position(), m_editor->color_for(event), m_thickness); + layer.did_modify_bitmap(*m_editor->image()); + + m_last_drawing_event_position = event.position(); +} + +void PenTool::on_tool_button_contextmenu(GUI::ContextMenuEvent& event) +{ + if (!m_context_menu) { + m_context_menu = GUI::Menu::construct(); + m_thickness_actions.set_exclusive(true); + auto insert_action = [&](int size, bool checked = false) { + auto action = GUI::Action::create_checkable(String::number(size), [this, size](auto&) { + m_thickness = size; + }); + action->set_checked(checked); + m_thickness_actions.add_action(*action); + m_context_menu->add_action(move(action)); + }; + insert_action(1, true); + insert_action(2); + insert_action(3); + insert_action(4); + } + m_context_menu->popup(event.screen_position()); +} + +GUI::Widget* PenTool::get_properties_widget() +{ + if (!m_properties_widget) { + m_properties_widget = GUI::Widget::construct(); + m_properties_widget->set_layout<GUI::VerticalBoxLayout>(); + + auto& thickness_container = m_properties_widget->add<GUI::Widget>(); + thickness_container.set_fixed_height(20); + thickness_container.set_layout<GUI::HorizontalBoxLayout>(); + + auto& thickness_label = thickness_container.add<GUI::Label>("Thickness:"); + thickness_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + thickness_label.set_fixed_size(80, 20); + + auto& thickness_slider = thickness_container.add<GUI::HorizontalSlider>(); + thickness_slider.set_fixed_height(20); + thickness_slider.set_range(1, 20); + thickness_slider.set_value(m_thickness); + thickness_slider.on_change = [this](int value) { + m_thickness = value; + }; + } + + return m_properties_widget.ptr(); +} + +} diff --git a/Userland/Applications/PixelPaint/PenTool.h b/Userland/Applications/PixelPaint/PenTool.h new file mode 100644 index 0000000000..137e58251e --- /dev/null +++ b/Userland/Applications/PixelPaint/PenTool.h @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Tool.h" +#include <LibGUI/ActionGroup.h> +#include <LibGfx/Point.h> + +namespace PixelPaint { + +class PenTool final : public Tool { +public: + PenTool(); + virtual ~PenTool() override; + + virtual void on_mousedown(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_mousemove(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_mouseup(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_tool_button_contextmenu(GUI::ContextMenuEvent&) override; + virtual GUI::Widget* get_properties_widget() override; + +private: + Gfx::IntPoint m_last_drawing_event_position { -1, -1 }; + RefPtr<GUI::Menu> m_context_menu; + RefPtr<GUI::Widget> m_properties_widget; + int m_thickness { 1 }; + GUI::ActionGroup m_thickness_actions; +}; + +} diff --git a/Userland/Applications/PixelPaint/PickerTool.cpp b/Userland/Applications/PixelPaint/PickerTool.cpp new file mode 100644 index 0000000000..86a7acf961 --- /dev/null +++ b/Userland/Applications/PixelPaint/PickerTool.cpp @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "PickerTool.h" +#include "ImageEditor.h" +#include "Layer.h" +#include <LibGfx/Bitmap.h> + +namespace PixelPaint { + +PickerTool::PickerTool() +{ +} + +PickerTool::~PickerTool() +{ +} + +void PickerTool::on_mousedown(Layer& layer, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + if (!layer.rect().contains(event.position())) + return; + auto color = layer.bitmap().get_pixel(event.position()); + if (event.button() == GUI::MouseButton::Left) + m_editor->set_primary_color(color); + else if (event.button() == GUI::MouseButton::Right) + m_editor->set_secondary_color(color); +} + +} diff --git a/Userland/Applications/PixelPaint/PickerTool.h b/Userland/Applications/PixelPaint/PickerTool.h new file mode 100644 index 0000000000..163a0aeeee --- /dev/null +++ b/Userland/Applications/PixelPaint/PickerTool.h @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Tool.h" + +namespace PixelPaint { + +class PickerTool final : public Tool { +public: + PickerTool(); + virtual ~PickerTool() override; + + virtual void on_mousedown(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; +}; + +} diff --git a/Userland/Applications/PixelPaint/RectangleTool.cpp b/Userland/Applications/PixelPaint/RectangleTool.cpp new file mode 100644 index 0000000000..a5b023f18b --- /dev/null +++ b/Userland/Applications/PixelPaint/RectangleTool.cpp @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "RectangleTool.h" +#include "ImageEditor.h" +#include "Layer.h" +#include <LibGUI/Action.h> +#include <LibGUI/Menu.h> +#include <LibGUI/Painter.h> +#include <LibGfx/Rect.h> +#include <math.h> + +namespace PixelPaint { + +RectangleTool::RectangleTool() +{ +} + +RectangleTool::~RectangleTool() +{ +} + +void RectangleTool::draw_using(GUI::Painter& painter, const Gfx::IntRect& rect) +{ + switch (m_mode) { + case Mode::Fill: + painter.fill_rect(rect, m_editor->color_for(m_drawing_button)); + break; + case Mode::Outline: + painter.draw_rect(rect, m_editor->color_for(m_drawing_button)); + break; + case Mode::Gradient: + painter.fill_rect_with_gradient(rect, m_editor->primary_color(), m_editor->secondary_color()); + break; + default: + ASSERT_NOT_REACHED(); + } +} + +void RectangleTool::on_mousedown(Layer&, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + if (event.button() != GUI::MouseButton::Left && event.button() != GUI::MouseButton::Right) + return; + + if (m_drawing_button != GUI::MouseButton::None) + return; + + m_drawing_button = event.button(); + m_rectangle_start_position = event.position(); + m_rectangle_end_position = event.position(); + m_editor->update(); +} + +void RectangleTool::on_mouseup(Layer& layer, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + if (event.button() == m_drawing_button) { + GUI::Painter painter(layer.bitmap()); + auto rect = Gfx::IntRect::from_two_points(m_rectangle_start_position, m_rectangle_end_position); + draw_using(painter, rect); + m_drawing_button = GUI::MouseButton::None; + layer.did_modify_bitmap(*m_editor->image()); + m_editor->did_complete_action(); + } +} + +void RectangleTool::on_mousemove(Layer&, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + if (m_drawing_button == GUI::MouseButton::None) + return; + + m_rectangle_end_position = event.position(); + m_editor->update(); +} + +void RectangleTool::on_second_paint(const Layer& layer, GUI::PaintEvent& event) +{ + if (m_drawing_button == GUI::MouseButton::None) + return; + + GUI::Painter painter(*m_editor); + painter.add_clip_rect(event.rect()); + auto rect = Gfx::IntRect::from_two_points( + m_editor->layer_position_to_editor_position(layer, m_rectangle_start_position).to_type<int>(), + m_editor->layer_position_to_editor_position(layer, m_rectangle_end_position).to_type<int>()); + draw_using(painter, rect); +} + +void RectangleTool::on_keydown(GUI::KeyEvent& event) +{ + if (event.key() == Key_Escape && m_drawing_button != GUI::MouseButton::None) { + m_drawing_button = GUI::MouseButton::None; + m_editor->update(); + event.accept(); + } +} + +void RectangleTool::on_tool_button_contextmenu(GUI::ContextMenuEvent& event) +{ + if (!m_context_menu) { + m_context_menu = GUI::Menu::construct(); + m_context_menu->add_action(GUI::Action::create("Fill", [this](auto&) { + m_mode = Mode::Fill; + })); + m_context_menu->add_action(GUI::Action::create("Outline", [this](auto&) { + m_mode = Mode::Outline; + })); + m_context_menu->add_action(GUI::Action::create("Gradient", [this](auto&) { + m_mode = Mode::Gradient; + })); + } + m_context_menu->popup(event.screen_position()); +} + +} diff --git a/Userland/Applications/PixelPaint/RectangleTool.h b/Userland/Applications/PixelPaint/RectangleTool.h new file mode 100644 index 0000000000..f7d8c53273 --- /dev/null +++ b/Userland/Applications/PixelPaint/RectangleTool.h @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Tool.h" +#include <LibGUI/Forward.h> +#include <LibGfx/Point.h> + +namespace PixelPaint { + +class RectangleTool final : public Tool { +public: + RectangleTool(); + virtual ~RectangleTool() override; + + virtual void on_mousedown(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_mousemove(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_mouseup(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_tool_button_contextmenu(GUI::ContextMenuEvent&) override; + virtual void on_second_paint(const Layer&, GUI::PaintEvent&) override; + virtual void on_keydown(GUI::KeyEvent&) override; + +private: + enum class Mode { + Outline, + Fill, + Gradient, + }; + + void draw_using(GUI::Painter&, const Gfx::IntRect&); + + GUI::MouseButton m_drawing_button { GUI::MouseButton::None }; + Gfx::IntPoint m_rectangle_start_position; + Gfx::IntPoint m_rectangle_end_position; + RefPtr<GUI::Menu> m_context_menu; + Mode m_mode { Mode::Outline }; +}; + +} diff --git a/Userland/Applications/PixelPaint/SprayTool.cpp b/Userland/Applications/PixelPaint/SprayTool.cpp new file mode 100644 index 0000000000..9997c29674 --- /dev/null +++ b/Userland/Applications/PixelPaint/SprayTool.cpp @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "SprayTool.h" +#include "ImageEditor.h" +#include "Layer.h" +#include <AK/Queue.h> +#include <LibGUI/Action.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Label.h> +#include <LibGUI/Menu.h> +#include <LibGUI/Painter.h> +#include <LibGUI/Slider.h> +#include <LibGfx/Bitmap.h> +#include <math.h> +#include <stdio.h> + +namespace PixelPaint { + +SprayTool::SprayTool() +{ + m_timer = Core::Timer::construct(); + m_timer->on_timeout = [&]() { + paint_it(); + }; + m_timer->set_interval(200); +} + +SprayTool::~SprayTool() +{ +} + +static double nrand() +{ + return double(rand()) / double(RAND_MAX); +} + +void SprayTool::paint_it() +{ + auto* layer = m_editor->active_layer(); + if (!layer) + return; + + auto& bitmap = layer->bitmap(); + GUI::Painter painter(bitmap); + ASSERT(bitmap.bpp() == 32); + m_editor->update(); + const double minimal_radius = 2; + const double base_radius = minimal_radius * m_thickness; + for (int i = 0; i < M_PI * base_radius * base_radius * (m_density / 100.0f); i++) { + double radius = base_radius * nrand(); + double angle = 2 * M_PI * nrand(); + const int xpos = m_last_pos.x() + radius * cos(angle); + const int ypos = m_last_pos.y() - radius * sin(angle); + if (xpos < 0 || xpos >= bitmap.width()) + continue; + if (ypos < 0 || ypos >= bitmap.height()) + continue; + bitmap.set_pixel<Gfx::StorageFormat::RGBA32>(xpos, ypos, m_color); + } + + layer->did_modify_bitmap(*m_editor->image()); +} + +void SprayTool::on_mousedown(Layer&, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + m_color = m_editor->color_for(event); + m_last_pos = event.position(); + m_timer->start(); + paint_it(); +} + +void SprayTool::on_mousemove(Layer&, GUI::MouseEvent& event, GUI::MouseEvent&) +{ + m_last_pos = event.position(); + if (m_timer->is_active()) { + paint_it(); + m_timer->restart(m_timer->interval()); + } +} + +void SprayTool::on_mouseup(Layer&, GUI::MouseEvent&, GUI::MouseEvent&) +{ + if (m_timer->is_active()) { + m_timer->stop(); + m_editor->did_complete_action(); + } +} + +void SprayTool::on_tool_button_contextmenu(GUI::ContextMenuEvent& event) +{ + if (!m_context_menu) { + m_context_menu = GUI::Menu::construct(); + m_thickness_actions.set_exclusive(true); + auto insert_action = [&](int size, bool checked = false) { + auto action = GUI::Action::create_checkable(String::number(size), [this, size](auto&) { + m_thickness = size; + }); + action->set_checked(checked); + m_thickness_actions.add_action(*action); + m_context_menu->add_action(move(action)); + }; + insert_action(1, true); + insert_action(2); + insert_action(3); + insert_action(4); + } + m_context_menu->popup(event.screen_position()); +} + +GUI::Widget* SprayTool::get_properties_widget() +{ + if (!m_properties_widget) { + m_properties_widget = GUI::Widget::construct(); + m_properties_widget->set_layout<GUI::VerticalBoxLayout>(); + + auto& thickness_container = m_properties_widget->add<GUI::Widget>(); + thickness_container.set_fixed_height(20); + thickness_container.set_layout<GUI::HorizontalBoxLayout>(); + + auto& thickness_label = thickness_container.add<GUI::Label>("Thickness:"); + thickness_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + thickness_label.set_fixed_size(80, 20); + + auto& thickness_slider = thickness_container.add<GUI::HorizontalSlider>(); + thickness_slider.set_fixed_height(20); + thickness_slider.set_range(1, 20); + thickness_slider.set_value(m_thickness); + thickness_slider.on_change = [this](int value) { + m_thickness = value; + }; + + auto& density_container = m_properties_widget->add<GUI::Widget>(); + density_container.set_fixed_height(20); + density_container.set_layout<GUI::HorizontalBoxLayout>(); + + auto& density_label = density_container.add<GUI::Label>("Density:"); + density_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + density_label.set_fixed_size(80, 20); + + auto& density_slider = density_container.add<GUI::HorizontalSlider>(); + density_slider.set_fixed_height(30); + density_slider.set_range(1, 100); + density_slider.set_value(m_density); + density_slider.on_change = [this](int value) { + m_density = value; + }; + } + + return m_properties_widget.ptr(); +} + +} diff --git a/Userland/Applications/PixelPaint/SprayTool.h b/Userland/Applications/PixelPaint/SprayTool.h new file mode 100644 index 0000000000..a3e55efd52 --- /dev/null +++ b/Userland/Applications/PixelPaint/SprayTool.h @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Tool.h" +#include <LibCore/Timer.h> +#include <LibGUI/ActionGroup.h> +#include <LibGUI/Painter.h> + +namespace PixelPaint { + +class SprayTool final : public Tool { +public: + SprayTool(); + virtual ~SprayTool() override; + + virtual void on_mousedown(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_mouseup(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_mousemove(Layer&, GUI::MouseEvent& layer_event, GUI::MouseEvent& image_event) override; + virtual void on_tool_button_contextmenu(GUI::ContextMenuEvent&) override; + virtual GUI::Widget* get_properties_widget() override; + +private: + void paint_it(); + + RefPtr<GUI::Widget> m_properties_widget; + RefPtr<Core::Timer> m_timer; + Gfx::IntPoint m_last_pos; + Color m_color; + RefPtr<GUI::Menu> m_context_menu; + GUI::ActionGroup m_thickness_actions; + int m_thickness { 10 }; + int m_density { 40 }; +}; + +} diff --git a/Userland/Applications/PixelPaint/Tool.cpp b/Userland/Applications/PixelPaint/Tool.cpp new file mode 100644 index 0000000000..846e017410 --- /dev/null +++ b/Userland/Applications/PixelPaint/Tool.cpp @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "Tool.h" +#include "ImageEditor.h" +#include <LibGUI/Action.h> + +namespace PixelPaint { + +Tool::Tool() +{ +} + +Tool::~Tool() +{ +} + +void Tool::setup(ImageEditor& editor) +{ + m_editor = editor; +} + +void Tool::set_action(GUI::Action* action) +{ + m_action = action; +} + +} diff --git a/Userland/Applications/PixelPaint/Tool.h b/Userland/Applications/PixelPaint/Tool.h new file mode 100644 index 0000000000..e7deb5f1cb --- /dev/null +++ b/Userland/Applications/PixelPaint/Tool.h @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Event.h> +#include <LibGUI/Forward.h> + +namespace PixelPaint { + +class ImageEditor; +class Layer; + +class Tool { +public: + virtual ~Tool(); + + virtual void on_mousedown(Layer&, GUI::MouseEvent&, GUI::MouseEvent&) { } + virtual void on_mousemove(Layer&, GUI::MouseEvent&, GUI::MouseEvent&) { } + virtual void on_mouseup(Layer&, GUI::MouseEvent&, GUI::MouseEvent&) { } + virtual void on_context_menu(Layer&, GUI::ContextMenuEvent&) { } + virtual void on_tool_button_contextmenu(GUI::ContextMenuEvent&) { } + virtual void on_second_paint(const Layer&, GUI::PaintEvent&) { } + virtual void on_keydown(GUI::KeyEvent&) { } + virtual void on_keyup(GUI::KeyEvent&) { } + virtual GUI::Widget* get_properties_widget() { return nullptr; } + + void clear() { m_editor = nullptr; } + void setup(ImageEditor&); + + GUI::Action* action() { return m_action; } + void set_action(GUI::Action*); + +protected: + Tool(); + WeakPtr<ImageEditor> m_editor; + RefPtr<GUI::Action> m_action; +}; + +} diff --git a/Userland/Applications/PixelPaint/ToolPropertiesWidget.cpp b/Userland/Applications/PixelPaint/ToolPropertiesWidget.cpp new file mode 100644 index 0000000000..a27103d3e7 --- /dev/null +++ b/Userland/Applications/PixelPaint/ToolPropertiesWidget.cpp @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2020, Ben Jilks <benjyjilks@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "ToolPropertiesWidget.h" +#include "Tool.h" +#include <LibGUI/BoxLayout.h> +#include <LibGUI/GroupBox.h> + +namespace PixelPaint { + +ToolPropertiesWidget::ToolPropertiesWidget() +{ + set_layout<GUI::VerticalBoxLayout>(); + + m_group_box = add<GUI::GroupBox>("Tool properties"); + auto& layout = m_group_box->set_layout<GUI::VerticalBoxLayout>(); + layout.set_margins({ 10, 20, 10, 10 }); +} + +void ToolPropertiesWidget::set_active_tool(Tool* tool) +{ + if (tool == m_active_tool) + return; + + if (m_active_tool_widget != nullptr) + m_group_box->remove_child(*m_active_tool_widget); + + m_active_tool = tool; + m_active_tool_widget = tool->get_properties_widget(); + if (m_active_tool_widget != nullptr) + m_group_box->add_child(*m_active_tool_widget); +} + +ToolPropertiesWidget::~ToolPropertiesWidget() +{ +} + +} diff --git a/Userland/Applications/PixelPaint/ToolPropertiesWidget.h b/Userland/Applications/PixelPaint/ToolPropertiesWidget.h new file mode 100644 index 0000000000..41c1675cfa --- /dev/null +++ b/Userland/Applications/PixelPaint/ToolPropertiesWidget.h @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2020, Ben Jilks <benjyjilks@gmail.com> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/RefPtr.h> +#include <LibGUI/Forward.h> +#include <LibGUI/Widget.h> + +namespace PixelPaint { + +class Tool; + +class ToolPropertiesWidget final : public GUI::Widget { + C_OBJECT(ToolPropertiesWidget); + +public: + virtual ~ToolPropertiesWidget() override; + + void set_active_tool(Tool*); + +private: + ToolPropertiesWidget(); + + RefPtr<GUI::GroupBox> m_group_box; + + Tool* m_active_tool { nullptr }; + GUI::Widget* m_active_tool_widget { nullptr }; +}; + +} diff --git a/Userland/Applications/PixelPaint/ToolboxWidget.cpp b/Userland/Applications/PixelPaint/ToolboxWidget.cpp new file mode 100644 index 0000000000..c746901887 --- /dev/null +++ b/Userland/Applications/PixelPaint/ToolboxWidget.cpp @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "ToolboxWidget.h" +#include "BrushTool.h" +#include "BucketTool.h" +#include "EllipseTool.h" +#include "EraseTool.h" +#include "LineTool.h" +#include "MoveTool.h" +#include "PenTool.h" +#include "PickerTool.h" +#include "RectangleTool.h" +#include "SprayTool.h" +#include <AK/StringBuilder.h> +#include <LibGUI/Action.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/Window.h> + +namespace PixelPaint { + +class ToolButton final : public GUI::Button { + C_OBJECT(ToolButton) +public: + ToolButton(ToolboxWidget& toolbox, const String& name, const GUI::Shortcut& shortcut, OwnPtr<Tool> tool) + : m_toolbox(toolbox) + , m_tool(move(tool)) + { + StringBuilder builder; + builder.append(name); + builder.append(" ("); + builder.append(shortcut.to_string()); + builder.append(")"); + set_tooltip(builder.to_string()); + + m_action = GUI::Action::create_checkable( + name, shortcut, [this](auto& action) { + if (action.is_checked()) + m_toolbox.on_tool_selection(m_tool); + else + m_toolbox.on_tool_selection(nullptr); + }, + toolbox.window()); + + m_tool->set_action(m_action); + set_action(*m_action); + m_toolbox.m_action_group.add_action(*m_action); + } + + const Tool& tool() const { return *m_tool; } + Tool& tool() { return *m_tool; } + + virtual bool is_uncheckable() const override { return false; } + + virtual void context_menu_event(GUI::ContextMenuEvent& event) override + { + m_action->activate(); + m_tool->on_tool_button_contextmenu(event); + } + +private: + ToolboxWidget& m_toolbox; + OwnPtr<Tool> m_tool; + RefPtr<GUI::Action> m_action; +}; + +ToolboxWidget::ToolboxWidget() +{ + set_fill_with_background_color(true); + + set_frame_thickness(1); + set_frame_shape(Gfx::FrameShape::Panel); + set_frame_shadow(Gfx::FrameShadow::Raised); + + set_fixed_width(48); + + set_layout<GUI::VerticalBoxLayout>(); + layout()->set_margins({ 4, 4, 4, 4 }); + + m_action_group.set_exclusive(true); + m_action_group.set_unchecking_allowed(false); + + deferred_invoke([this](auto&) { + setup_tools(); + }); +} + +ToolboxWidget::~ToolboxWidget() +{ +} + +void ToolboxWidget::setup_tools() +{ + auto add_tool = [&](const StringView& name, const StringView& icon_name, const GUI::Shortcut& shortcut, NonnullOwnPtr<Tool> tool) -> ToolButton& { + m_tools.append(tool.ptr()); + auto& button = add<ToolButton>(*this, name, shortcut, move(tool)); + button.set_focus_policy(GUI::FocusPolicy::TabFocus); + button.set_fixed_height(32); + button.set_checkable(true); + button.set_icon(Gfx::Bitmap::load_from_file(String::formatted("/res/icons/pixelpaint/{}.png", icon_name))); + return button; + }; + + add_tool("Move", "move", { 0, Key_M }, make<MoveTool>()); + add_tool("Pen", "pen", { 0, Key_N }, make<PenTool>()); + add_tool("Brush", "brush", { 0, Key_P }, make<BrushTool>()); + add_tool("Bucket Fill", "bucket", { Mod_Shift, Key_B }, make<BucketTool>()); + add_tool("Spray", "spray", { Mod_Shift, Key_S }, make<SprayTool>()); + add_tool("Color Picker", "picker", { 0, Key_O }, make<PickerTool>()); + add_tool("Erase", "eraser", { Mod_Shift, Key_E }, make<EraseTool>()); + add_tool("Line", "line", { Mod_Ctrl | Mod_Shift, Key_L }, make<LineTool>()); + add_tool("Rectangle", "rectangle", { Mod_Ctrl | Mod_Shift, Key_R }, make<RectangleTool>()); + add_tool("Ellipse", "circle", { Mod_Ctrl | Mod_Shift, Key_E }, make<EllipseTool>()); +} + +} diff --git a/Userland/Applications/PixelPaint/ToolboxWidget.h b/Userland/Applications/PixelPaint/ToolboxWidget.h new file mode 100644 index 0000000000..23794179f6 --- /dev/null +++ b/Userland/Applications/PixelPaint/ToolboxWidget.h @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/ActionGroup.h> +#include <LibGUI/Frame.h> + +namespace PixelPaint { + +class Tool; + +class ToolboxWidget final : public GUI::Frame { + C_OBJECT(ToolboxWidget) +public: + virtual ~ToolboxWidget() override; + + Function<void(Tool*)> on_tool_selection; + + template<typename Callback> + void for_each_tool(Callback callback) + { + for (auto& tool : m_tools) + callback(*tool); + } + +private: + friend class ToolButton; + + void setup_tools(); + + explicit ToolboxWidget(); + GUI::ActionGroup m_action_group; + Vector<Tool*> m_tools; +}; + +} diff --git a/Userland/Applications/PixelPaint/main.cpp b/Userland/Applications/PixelPaint/main.cpp new file mode 100644 index 0000000000..02164a52ce --- /dev/null +++ b/Userland/Applications/PixelPaint/main.cpp @@ -0,0 +1,387 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "CreateNewImageDialog.h" +#include "CreateNewLayerDialog.h" +#include "FilterParams.h" +#include "Image.h" +#include "ImageEditor.h" +#include "Layer.h" +#include "LayerListWidget.h" +#include "LayerPropertiesWidget.h" +#include "PaletteWidget.h" +#include "Tool.h" +#include "ToolPropertiesWidget.h" +#include "ToolboxWidget.h" +#include <LibGUI/Action.h> +#include <LibGUI/Application.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Clipboard.h> +#include <LibGUI/FilePicker.h> +#include <LibGUI/Icon.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/TableView.h> +#include <LibGUI/Window.h> +#include <LibGfx/Bitmap.h> +#include <LibGfx/Matrix4x4.h> +#include <stdio.h> + +int main(int argc, char** argv) +{ + if (pledge("stdio thread shared_buffer accept rpath unix wpath cpath fattr", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio thread shared_buffer accept rpath wpath cpath", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app_icon = GUI::Icon::default_icon("app-pixel-paint"); + + auto window = GUI::Window::construct(); + window->set_title("PixelPaint"); + window->resize(950, 570); + window->set_icon(app_icon.bitmap_for_size(16)); + + auto& horizontal_container = window->set_main_widget<GUI::Widget>(); + horizontal_container.set_layout<GUI::HorizontalBoxLayout>(); + horizontal_container.layout()->set_spacing(0); + + auto& toolbox = horizontal_container.add<PixelPaint::ToolboxWidget>(); + + auto& vertical_container = horizontal_container.add<GUI::Widget>(); + vertical_container.set_layout<GUI::VerticalBoxLayout>(); + vertical_container.layout()->set_spacing(0); + + auto& image_editor = vertical_container.add<PixelPaint::ImageEditor>(); + image_editor.set_focus(true); + + vertical_container.add<PixelPaint::PaletteWidget>(image_editor); + + auto& right_panel = horizontal_container.add<GUI::Widget>(); + right_panel.set_fill_with_background_color(true); + right_panel.set_fixed_width(230); + right_panel.set_layout<GUI::VerticalBoxLayout>(); + + auto& layer_list_widget = right_panel.add<PixelPaint::LayerListWidget>(); + + auto& layer_properties_widget = right_panel.add<PixelPaint::LayerPropertiesWidget>(); + + auto& tool_properties_widget = right_panel.add<PixelPaint::ToolPropertiesWidget>(); + + toolbox.on_tool_selection = [&](auto* tool) { + image_editor.set_active_tool(tool); + tool_properties_widget.set_active_tool(tool); + }; + + window->show(); + + auto menubar = GUI::MenuBar::construct(); + auto& app_menu = menubar->add_menu("PixelPaint"); + + app_menu.add_action( + GUI::Action::create( + "New", [&](auto&) { + auto dialog = PixelPaint::CreateNewImageDialog::construct(window); + if (dialog->exec() == GUI::Dialog::ExecOK) { + auto image = PixelPaint::Image::create_with_size(dialog->image_size()); + auto bg_layer = PixelPaint::Layer::create_with_size(*image, image->size(), "Background"); + image->add_layer(*bg_layer); + bg_layer->bitmap().fill(Color::White); + + image_editor.set_image(image); + layer_list_widget.set_image(image); + image_editor.set_active_layer(bg_layer); + } + }, + window)); + app_menu.add_action(GUI::CommonActions::make_open_action([&](auto&) { + Optional<String> open_path = GUI::FilePicker::get_open_filepath(window); + + if (!open_path.has_value()) + return; + + auto image = PixelPaint::Image::create_from_file(open_path.value()); + image_editor.set_image(image); + layer_list_widget.set_image(image); + })); + app_menu.add_action(GUI::CommonActions::make_save_as_action([&](auto&) { + if (!image_editor.image()) + return; + + Optional<String> save_path = GUI::FilePicker::get_save_filepath(window, "untitled", "pp"); + + if (!save_path.has_value()) + return; + + image_editor.image()->save(save_path.value()); + })); + auto& export_submenu = app_menu.add_submenu("Export"); + export_submenu.add_action( + GUI::Action::create( + "As BMP", [&](auto&) { + if (!image_editor.image()) + return; + + Optional<String> save_path = GUI::FilePicker::get_save_filepath(window, "untitled", "bmp"); + + if (!save_path.has_value()) + return; + + image_editor.image()->export_bmp(save_path.value()); + }, + window)); + + app_menu.add_separator(); + app_menu.add_action(GUI::CommonActions::make_quit_action([](auto&) { + GUI::Application::the()->quit(); + return; + })); + + auto& edit_menu = menubar->add_menu("Edit"); + auto paste_action = GUI::CommonActions::make_paste_action([&](auto&) { + ASSERT(image_editor.image()); + auto bitmap = GUI::Clipboard::the().bitmap(); + if (!bitmap) + return; + + auto layer = PixelPaint::Layer::create_with_bitmap(*image_editor.image(), *bitmap, "Pasted layer"); + image_editor.image()->add_layer(layer.release_nonnull()); + }); + GUI::Clipboard::the().on_change = [&](auto& mime_type) { + paste_action->set_enabled(mime_type == "image/x-serenityos"); + }; + paste_action->set_enabled(GUI::Clipboard::the().mime_type() == "image/x-serenityos"); + + edit_menu.add_action(paste_action); + + auto undo_action = GUI::CommonActions::make_undo_action([&](auto&) { + ASSERT(image_editor.image()); + image_editor.undo(); + }); + edit_menu.add_action(undo_action); + + auto redo_action = GUI::CommonActions::make_redo_action([&](auto&) { + ASSERT(image_editor.image()); + image_editor.redo(); + }); + edit_menu.add_action(redo_action); + + auto& tool_menu = menubar->add_menu("Tool"); + toolbox.for_each_tool([&](auto& tool) { + if (tool.action()) + tool_menu.add_action(*tool.action()); + return IterationDecision::Continue; + }); + + auto& layer_menu = menubar->add_menu("Layer"); + layer_menu.add_action(GUI::Action::create( + "Create new layer...", { Mod_Ctrl | Mod_Shift, Key_N }, [&](auto&) { + auto dialog = PixelPaint::CreateNewLayerDialog::construct(image_editor.image()->size(), window); + if (dialog->exec() == GUI::Dialog::ExecOK) { + auto layer = PixelPaint::Layer::create_with_size(*image_editor.image(), dialog->layer_size(), dialog->layer_name()); + if (!layer) { + GUI::MessageBox::show_error(window, String::formatted("Unable to create layer with size {}", dialog->size().to_string())); + return; + } + image_editor.image()->add_layer(layer.release_nonnull()); + image_editor.layers_did_change(); + } + }, + window)); + + layer_menu.add_separator(); + layer_menu.add_action(GUI::Action::create( + "Select previous layer", { 0, Key_PageUp }, [&](auto&) { + layer_list_widget.move_selection(1); + }, + window)); + layer_menu.add_action(GUI::Action::create( + "Select next layer", { 0, Key_PageDown }, [&](auto&) { + layer_list_widget.move_selection(-1); + }, + window)); + layer_menu.add_action(GUI::Action::create( + "Select top layer", { 0, Key_Home }, [&](auto&) { + layer_list_widget.select_top_layer(); + }, + window)); + layer_menu.add_action(GUI::Action::create( + "Select bottom layer", { 0, Key_End }, [&](auto&) { + layer_list_widget.select_bottom_layer(); + }, + window)); + layer_menu.add_separator(); + layer_menu.add_action(GUI::Action::create( + "Move active layer up", { Mod_Ctrl, Key_PageUp }, [&](auto&) { + auto active_layer = image_editor.active_layer(); + if (!active_layer) + return; + image_editor.image()->move_layer_up(*active_layer); + }, + window)); + layer_menu.add_action(GUI::Action::create( + "Move active layer down", { Mod_Ctrl, Key_PageDown }, [&](auto&) { + auto active_layer = image_editor.active_layer(); + if (!active_layer) + return; + image_editor.image()->move_layer_down(*active_layer); + }, + window)); + layer_menu.add_separator(); + layer_menu.add_action(GUI::Action::create( + "Remove active layer", { Mod_Ctrl, Key_D }, [&](auto&) { + auto active_layer = image_editor.active_layer(); + if (!active_layer) + return; + image_editor.image()->remove_layer(*active_layer); + image_editor.set_active_layer(nullptr); + }, + window)); + + auto& filter_menu = menubar->add_menu("Filter"); + auto& spatial_filters_menu = filter_menu.add_submenu("Spatial"); + + auto& edge_detect_submenu = spatial_filters_menu.add_submenu("Edge Detect"); + edge_detect_submenu.add_action(GUI::Action::create("Laplacian (cardinal)", [&](auto&) { + if (auto* layer = image_editor.active_layer()) { + Gfx::LaplacianFilter filter; + if (auto parameters = PixelPaint::FilterParameters<Gfx::LaplacianFilter>::get(false)) { + filter.apply(layer->bitmap(), layer->rect(), layer->bitmap(), layer->rect(), *parameters); + image_editor.did_complete_action(); + } + } + })); + edge_detect_submenu.add_action(GUI::Action::create("Laplacian (diagonal)", [&](auto&) { + if (auto* layer = image_editor.active_layer()) { + Gfx::LaplacianFilter filter; + if (auto parameters = PixelPaint::FilterParameters<Gfx::LaplacianFilter>::get(true)) { + filter.apply(layer->bitmap(), layer->rect(), layer->bitmap(), layer->rect(), *parameters); + image_editor.did_complete_action(); + } + } + })); + auto& blur_submenu = spatial_filters_menu.add_submenu("Blur and Sharpen"); + blur_submenu.add_action(GUI::Action::create("Gaussian Blur (3x3)", [&](auto&) { + if (auto* layer = image_editor.active_layer()) { + Gfx::SpatialGaussianBlurFilter<3> filter; + if (auto parameters = PixelPaint::FilterParameters<Gfx::SpatialGaussianBlurFilter<3>>::get()) { + filter.apply(layer->bitmap(), layer->rect(), layer->bitmap(), layer->rect(), *parameters); + image_editor.did_complete_action(); + } + } + })); + blur_submenu.add_action(GUI::Action::create("Gaussian Blur (5x5)", [&](auto&) { + if (auto* layer = image_editor.active_layer()) { + Gfx::SpatialGaussianBlurFilter<5> filter; + if (auto parameters = PixelPaint::FilterParameters<Gfx::SpatialGaussianBlurFilter<5>>::get()) { + filter.apply(layer->bitmap(), layer->rect(), layer->bitmap(), layer->rect(), *parameters); + image_editor.did_complete_action(); + } + } + })); + blur_submenu.add_action(GUI::Action::create("Box Blur (3x3)", [&](auto&) { + if (auto* layer = image_editor.active_layer()) { + Gfx::BoxBlurFilter<3> filter; + if (auto parameters = PixelPaint::FilterParameters<Gfx::BoxBlurFilter<3>>::get()) { + filter.apply(layer->bitmap(), layer->rect(), layer->bitmap(), layer->rect(), *parameters); + image_editor.did_complete_action(); + } + } + })); + blur_submenu.add_action(GUI::Action::create("Box Blur (5x5)", [&](auto&) { + if (auto* layer = image_editor.active_layer()) { + Gfx::BoxBlurFilter<5> filter; + if (auto parameters = PixelPaint::FilterParameters<Gfx::BoxBlurFilter<5>>::get()) { + filter.apply(layer->bitmap(), layer->rect(), layer->bitmap(), layer->rect(), *parameters); + image_editor.did_complete_action(); + } + } + })); + blur_submenu.add_action(GUI::Action::create("Sharpen", [&](auto&) { + if (auto* layer = image_editor.active_layer()) { + Gfx::SharpenFilter filter; + if (auto parameters = PixelPaint::FilterParameters<Gfx::SharpenFilter>::get()) { + filter.apply(layer->bitmap(), layer->rect(), layer->bitmap(), layer->rect(), *parameters); + image_editor.did_complete_action(); + } + } + })); + + spatial_filters_menu.add_separator(); + spatial_filters_menu.add_action(GUI::Action::create("Generic 5x5 Convolution", [&](auto&) { + if (auto* layer = image_editor.active_layer()) { + Gfx::GenericConvolutionFilter<5> filter; + if (auto parameters = PixelPaint::FilterParameters<Gfx::GenericConvolutionFilter<5>>::get(window)) { + filter.apply(layer->bitmap(), layer->rect(), layer->bitmap(), layer->rect(), *parameters); + image_editor.did_complete_action(); + } + } + })); + + auto& help_menu = menubar->add_menu("Help"); + help_menu.add_action(GUI::CommonActions::make_about_action("PixelPaint", app_icon, window)); + + app->set_menubar(move(menubar)); + + image_editor.on_active_layer_change = [&](auto* layer) { + layer_list_widget.set_selected_layer(layer); + layer_properties_widget.set_layer(layer); + }; + + auto image = PixelPaint::Image::create_with_size({ 640, 480 }); + + auto bg_layer = PixelPaint::Layer::create_with_size(*image, { 640, 480 }, "Background"); + image->add_layer(*bg_layer); + bg_layer->bitmap().fill(Color::White); + + auto fg_layer1 = PixelPaint::Layer::create_with_size(*image, { 200, 200 }, "FG Layer 1"); + fg_layer1->set_location({ 50, 50 }); + image->add_layer(*fg_layer1); + fg_layer1->bitmap().fill(Color::Yellow); + + auto fg_layer2 = PixelPaint::Layer::create_with_size(*image, { 100, 100 }, "FG Layer 2"); + fg_layer2->set_location({ 300, 300 }); + image->add_layer(*fg_layer2); + fg_layer2->bitmap().fill(Color::Blue); + + layer_list_widget.on_layer_select = [&](auto* layer) { + image_editor.set_active_layer(layer); + }; + + layer_list_widget.set_image(image); + + image_editor.set_image(image); + image_editor.set_active_layer(bg_layer); + + return app->exec(); +} diff --git a/Userland/Applications/QuickShow/CMakeLists.txt b/Userland/Applications/QuickShow/CMakeLists.txt new file mode 100644 index 0000000000..8d2b46e217 --- /dev/null +++ b/Userland/Applications/QuickShow/CMakeLists.txt @@ -0,0 +1,7 @@ +set(SOURCES + main.cpp + QSWidget.cpp +) + +serenity_app(QuickShow ICON filetype-image) +target_link_libraries(QuickShow LibGUI LibGfx) diff --git a/Userland/Applications/QuickShow/QSWidget.cpp b/Userland/Applications/QuickShow/QSWidget.cpp new file mode 100644 index 0000000000..94dbe858ea --- /dev/null +++ b/Userland/Applications/QuickShow/QSWidget.cpp @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "QSWidget.h" +#include <AK/StringBuilder.h> +#include <LibCore/DirIterator.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/Painter.h> +#include <LibGUI/Window.h> +#include <LibGfx/Bitmap.h> +#include <LibGfx/Orientation.h> +#include <LibGfx/Palette.h> + +QSWidget::QSWidget() +{ + set_fill_with_background_color(false); +} + +QSWidget::~QSWidget() +{ +} + +void QSWidget::clear() +{ + m_bitmap = nullptr; + m_path = {}; + + set_scale(100); + update(); +} + +void QSWidget::flip(Gfx::Orientation orientation) +{ + m_bitmap = m_bitmap->flipped(orientation); + set_scale(m_scale); + + resize_window(); +} + +void QSWidget::rotate(Gfx::RotationDirection rotation_direction) +{ + m_bitmap = m_bitmap->rotated(rotation_direction); + set_scale(m_scale); + + resize_window(); +} + +void QSWidget::navigate(Directions direction) +{ + if (m_path == nullptr) + return; + + auto parts = m_path.split('/'); + parts.remove(parts.size() - 1); + StringBuilder sb; + sb.append("/"); + sb.join("/", parts); + AK::String current_dir = sb.to_string(); + + if (m_files_in_same_dir.is_empty()) { + Core::DirIterator iterator(current_dir, Core::DirIterator::Flags::SkipDots); + while (iterator.has_next()) { + String file = iterator.next_full_path(); + if (!Gfx::Bitmap::is_path_a_supported_image_format(file)) + continue; + m_files_in_same_dir.append(file); + } + } + + auto current_index = m_files_in_same_dir.find_first_index(m_path); + if (!current_index.has_value()) { + return; + } + + size_t index = current_index.value(); + if (direction == Directions::Back) { + if (index == 0) { + GUI::MessageBox::show(window(), "This is the first file.", "Cannot open image", GUI::MessageBox::Type::Error); + return; + } + + index--; + } else if (direction == Directions::Forward) { + if (index == m_files_in_same_dir.size() - 1) { + GUI::MessageBox::show(window(), "This is the last file.", "Cannot open image", GUI::MessageBox::Type::Error); + return; + } + + index++; + } else if (direction == Directions::First) { + index = 0; + } else if (direction == Directions::Last) { + index = m_files_in_same_dir.size() - 1; + } + + this->load_from_file(m_files_in_same_dir.at(index)); +} + +void QSWidget::set_scale(int scale) +{ + if (m_bitmap.is_null()) + return; + + if (m_scale == scale) { + update(); + return; + } + + if (scale < 10) + scale = 10; + if (scale > 1000) + scale = 1000; + + if (scale == 100) + m_pan_origin = { 0, 0 }; + + m_scale = scale; + float scale_factor = (float)m_scale / 100.0f; + + Gfx::IntSize new_size; + new_size.set_width(m_bitmap->width() * scale_factor); + new_size.set_height(m_bitmap->height() * scale_factor); + m_bitmap_rect.set_size(new_size); + + if (on_scale_change) + on_scale_change(m_scale, m_bitmap_rect); + + relayout(); +} + +void QSWidget::relayout() +{ + if (m_bitmap.is_null()) + return; + + float scale_factor = (float)m_scale / 100.0f; + Gfx::IntSize new_size = m_bitmap_rect.size(); + + Gfx::IntPoint new_location; + new_location.set_x((width() / 2) - (new_size.width() / 2) - (m_pan_origin.x() * scale_factor)); + new_location.set_y((height() / 2) - (new_size.height() / 2) - (m_pan_origin.y() * scale_factor)); + m_bitmap_rect.set_location(new_location); + + update(); +} + +void QSWidget::resize_event(GUI::ResizeEvent& event) +{ + relayout(); + GUI::Widget::resize_event(event); +} + +void QSWidget::doubleclick_event(GUI::MouseEvent&) +{ + on_doubleclick(); +} + +void QSWidget::paint_event(GUI::PaintEvent& event) +{ + Frame::paint_event(event); + + GUI::Painter painter(*this); + painter.add_clip_rect(event.rect()); + painter.add_clip_rect(frame_inner_rect()); + + Gfx::StylePainter::paint_transparency_grid(painter, frame_inner_rect(), palette()); + + if (!m_bitmap.is_null()) + painter.draw_scaled_bitmap(m_bitmap_rect, *m_bitmap, m_bitmap->rect()); +} + +void QSWidget::mousedown_event(GUI::MouseEvent& event) +{ + if (event.button() != GUI::MouseButton::Left) + return; + m_click_position = event.position(); + m_saved_pan_origin = m_pan_origin; +} + +void QSWidget::mouseup_event([[maybe_unused]] GUI::MouseEvent& event) { } + +void QSWidget::mousemove_event(GUI::MouseEvent& event) +{ + if (!(event.buttons() & GUI::MouseButton::Left)) + return; + + auto delta = event.position() - m_click_position; + float scale_factor = (float)m_scale / 100.0f; + m_pan_origin = m_saved_pan_origin.translated( + -delta.x() / scale_factor, + -delta.y() / scale_factor); + + relayout(); +} + +void QSWidget::mousewheel_event(GUI::MouseEvent& event) +{ + int new_scale = m_scale - event.wheel_delta() * 10; + if (new_scale < 10) + new_scale = 10; + if (new_scale > 1000) + new_scale = 1000; + + if (new_scale == m_scale) { + return; + } + + auto old_scale_factor = (float)m_scale / 100.0f; + auto new_scale_factor = (float)new_scale / 100.0f; + + auto focus_point = Gfx::FloatPoint( + m_pan_origin.x() - ((float)event.x() - (float)width() / 2.0) / old_scale_factor, + m_pan_origin.y() - ((float)event.y() - (float)height() / 2.0) / old_scale_factor); + + m_pan_origin = Gfx::FloatPoint( + focus_point.x() - new_scale_factor / old_scale_factor * (focus_point.x() - m_pan_origin.x()), + focus_point.y() - new_scale_factor / old_scale_factor * (focus_point.y() - m_pan_origin.y())); + + set_scale(new_scale); +} + +void QSWidget::load_from_file(const String& path) +{ + auto bitmap = Gfx::Bitmap::load_from_file(path); + if (!bitmap) { + GUI::MessageBox::show(window(), String::formatted("Failed to open {}", path), "Cannot open image", GUI::MessageBox::Type::Error); + return; + } + + m_path = path; + m_bitmap = bitmap; + m_scale = -1; + set_scale(100); +} + +void QSWidget::drop_event(GUI::DropEvent& event) +{ + event.accept(); + if (on_drop) + on_drop(event); +} + +void QSWidget::resize_window() +{ + if (window()->is_fullscreen()) + return; + + if (!m_bitmap) + return; + + auto new_size = m_bitmap->size(); + + if (new_size.width() < 300) + new_size.set_width(300); + if (new_size.height() < 200) + new_size.set_height(200); + + new_size.set_height(new_size.height() + m_toolbar_height); + window()->resize(new_size); +} diff --git a/Userland/Applications/QuickShow/QSWidget.h b/Userland/Applications/QuickShow/QSWidget.h new file mode 100644 index 0000000000..047241e25e --- /dev/null +++ b/Userland/Applications/QuickShow/QSWidget.h @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Frame.h> +#include <LibGfx/Bitmap.h> +#include <LibGfx/Point.h> + +class QSLabel; + +class QSWidget final : public GUI::Frame { + C_OBJECT(QSWidget) +public: + enum Directions { + First, + Back, + Forward, + Last + }; + + virtual ~QSWidget() override; + + const Gfx::Bitmap* bitmap() const { return m_bitmap.ptr(); } + const String& path() const { return m_path; } + void set_scale(int); + int scale() { return m_scale; } + void set_toolbar_height(int height) { m_toolbar_height = height; } + int toolbar_height() { return m_toolbar_height; } + + void clear(); + void flip(Gfx::Orientation); + void rotate(Gfx::RotationDirection); + void navigate(Directions); + void load_from_file(const String&); + + Function<void(int, Gfx::IntRect)> on_scale_change; + Function<void()> on_doubleclick; + Function<void(const GUI::DropEvent&)> on_drop; + +private: + QSWidget(); + virtual void doubleclick_event(GUI::MouseEvent&) override; + virtual void paint_event(GUI::PaintEvent&) override; + virtual void resize_event(GUI::ResizeEvent&) override; + virtual void mousedown_event(GUI::MouseEvent&) override; + virtual void mouseup_event(GUI::MouseEvent&) override; + virtual void mousemove_event(GUI::MouseEvent&) override; + virtual void mousewheel_event(GUI::MouseEvent&) override; + virtual void drop_event(GUI::DropEvent&) override; + + void relayout(); + void resize_window(); + + String m_path; + RefPtr<Gfx::Bitmap> m_bitmap; + int m_toolbar_height { 28 }; + + Gfx::IntRect m_bitmap_rect; + int m_scale { -1 }; + Gfx::FloatPoint m_pan_origin; + + Gfx::IntPoint m_click_position; + Gfx::FloatPoint m_saved_pan_origin; + Vector<String> m_files_in_same_dir; +}; diff --git a/Userland/Applications/QuickShow/main.cpp b/Userland/Applications/QuickShow/main.cpp new file mode 100644 index 0000000000..2319c24a9a --- /dev/null +++ b/Userland/Applications/QuickShow/main.cpp @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "QSWidget.h" +#include <AK/URL.h> +#include <LibCore/ArgsParser.h> +#include <LibCore/MimeData.h> +#include <LibGUI/Action.h> +#include <LibGUI/Application.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Clipboard.h> +#include <LibGUI/Desktop.h> +#include <LibGUI/FilePicker.h> +#include <LibGUI/Label.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/ToolBar.h> +#include <LibGUI/ToolBarContainer.h> +#include <LibGUI/Window.h> +#include <LibGfx/Bitmap.h> +#include <LibGfx/Palette.h> +#include <LibGfx/Rect.h> +#include <serenity.h> +#include <spawn.h> +#include <stdio.h> +#include <string.h> + +int main(int argc, char** argv) +{ + if (pledge("stdio shared_buffer accept cpath rpath wpath unix cpath fattr proc exec thread", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio shared_buffer accept cpath rpath wpath proc exec thread", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app_icon = GUI::Icon::default_icon("filetype-image"); + + const char* path = nullptr; + Core::ArgsParser args_parser; + args_parser.add_positional_argument(path, "The image file to be displayed.", "file", Core::ArgsParser::Required::No); + args_parser.parse(argc, argv); + + auto window = GUI::Window::construct(); + window->set_double_buffering_enabled(true); + window->resize(300, 200); + window->set_icon(app_icon.bitmap_for_size(16)); + window->set_title("QuickShow"); + + auto& root_widget = window->set_main_widget<GUI::Widget>(); + root_widget.set_fill_with_background_color(true); + root_widget.set_layout<GUI::VerticalBoxLayout>(); + root_widget.layout()->set_spacing(2); + + auto& toolbar_container = root_widget.add<GUI::ToolBarContainer>(); + auto& main_toolbar = toolbar_container.add<GUI::ToolBar>(); + + auto& widget = root_widget.add<QSWidget>(); + widget.on_scale_change = [&](int scale, Gfx::IntRect rect) { + if (!widget.bitmap()) { + window->set_title("QuickShow"); + return; + } + + window->set_title(String::formatted("{} {} {}% - QuickShow", widget.path(), widget.bitmap()->size().to_string(), scale)); + + if (window->is_fullscreen()) + return; + + if (window->is_maximized()) + return; + + auto w = max(window->width(), rect.width() + 4); + auto h = max(window->height(), rect.height() + widget.toolbar_height() + 6); + window->resize(w, h); + }; + widget.on_drop = [&](auto& event) { + window->move_to_front(); + + if (event.mime_data().has_urls()) { + auto urls = event.mime_data().urls(); + + if (!urls.is_empty()) { + auto url = urls.first(); + widget.load_from_file(url.path()); + } + + pid_t child; + for (size_t i = 1; i < urls.size(); ++i) { + const char* argv[] = { "/bin/QuickShow", urls[i].path().characters(), nullptr }; + if ((errno = posix_spawn(&child, "/bin/QuickShow", nullptr, nullptr, const_cast<char**>(argv), environ))) { + perror("posix_spawn"); + } else { + if (disown(child) < 0) + perror("disown"); + } + } + } + }; + widget.on_doubleclick = [&] { + window->set_fullscreen(!window->is_fullscreen()); + toolbar_container.set_visible(!window->is_fullscreen()); + }; + + // Actions + auto open_action = GUI::CommonActions::make_open_action( + [&](auto&) { + Optional<String> path = GUI::FilePicker::get_open_filepath(window, "Open image..."); + if (path.has_value()) { + widget.load_from_file(path.value()); + } + }); + + auto delete_action = GUI::CommonActions::make_delete_action( + [&](auto&) { + auto path = widget.path(); + if (path.is_empty()) + return; + + auto msgbox_result = GUI::MessageBox::show(window, + String::formatted("Really delete {}?", path), + "Confirm deletion", + GUI::MessageBox::Type::Warning, + GUI::MessageBox::InputType::OKCancel); + + if (msgbox_result == GUI::MessageBox::ExecCancel) + return; + + auto unlink_result = unlink(widget.path().characters()); + dbgln("unlink_result::{}", unlink_result); + + if (unlink_result < 0) { + int saved_errno = errno; + GUI::MessageBox::show(window, + String::formatted("unlink({}) failed: {}", path, strerror(saved_errno)), + "Delete failed", + GUI::MessageBox::Type::Error); + + return; + } + + widget.clear(); + }); + + auto quit_action = GUI::CommonActions::make_quit_action( + [&](auto&) { + app->quit(); + }); + + auto rotate_left_action = GUI::Action::create("Rotate Left", { Mod_None, Key_L }, + [&](auto&) { + widget.rotate(Gfx::RotationDirection::Left); + }); + + auto rotate_right_action = GUI::Action::create("Rotate Right", { Mod_None, Key_R }, + [&](auto&) { + widget.rotate(Gfx::RotationDirection::Right); + }); + + auto vertical_flip_action = GUI::Action::create("Vertical Flip", { Mod_None, Key_V }, + [&](auto&) { + widget.flip(Gfx::Orientation::Vertical); + }); + + auto horizontal_flip_action = GUI::Action::create("Horizontal Flip", { Mod_None, Key_H }, + [&](auto&) { + widget.flip(Gfx::Orientation::Horizontal); + }); + + auto desktop_wallpaper_action = GUI::Action::create("Set as desktop wallpaper", + [&](auto&) { + GUI::Desktop::the().set_wallpaper(widget.path()); + }); + + auto go_first_action = GUI::Action::create("First", { Mod_None, Key_Home }, Gfx::Bitmap::load_from_file("/res/icons/16x16/go-first.png"), + [&](auto&) { + widget.navigate(QSWidget::Directions::First); + }); + + auto go_back_action = GUI::Action::create("Back", { Mod_None, Key_Left }, Gfx::Bitmap::load_from_file("/res/icons/16x16/go-back.png"), + [&](auto&) { + widget.navigate(QSWidget::Directions::Back); + }); + + auto go_forward_action = GUI::Action::create("Forward", { Mod_None, Key_Right }, Gfx::Bitmap::load_from_file("/res/icons/16x16/go-forward.png"), + [&](auto&) { + widget.navigate(QSWidget::Directions::Forward); + }); + + auto go_last_action = GUI::Action::create("Last", { Mod_None, Key_End }, Gfx::Bitmap::load_from_file("/res/icons/16x16/go-last.png"), + [&](auto&) { + widget.navigate(QSWidget::Directions::Last); + }); + + auto full_sceen_action = GUI::CommonActions::make_fullscreen_action( + [&](auto&) { + widget.on_doubleclick(); + }); + + auto zoom_in_action = GUI::Action::create("Zoom In", { Mod_None, Key_Plus }, Gfx::Bitmap::load_from_file("/res/icons/16x16/zoom-in.png"), + [&](auto&) { + widget.set_scale(widget.scale() + 10); + }); + + auto zoom_reset_action = GUI::Action::create("Zoom 100%", { Mod_None, Key_0 }, Gfx::Bitmap::load_from_file("/res/icons/16x16/zoom-reset.png"), + [&](auto&) { + widget.set_scale(100); + }); + + auto zoom_out_action = GUI::Action::create("Zoom Out", { Mod_None, Key_Minus }, Gfx::Bitmap::load_from_file("/res/icons/16x16/zoom-out.png"), + [&](auto&) { + widget.set_scale(widget.scale() - 10); + }); + + auto hide_show_toolbar_action = GUI::Action::create("Hide/Show Toolbar", { Mod_Ctrl, Key_T }, + [&](auto&) { + toolbar_container.set_visible(!toolbar_container.is_visible()); + }); + + auto copy_action = GUI::CommonActions::make_copy_action([&](auto&) { + if (widget.bitmap()) + GUI::Clipboard::the().set_bitmap(*widget.bitmap()); + }); + + main_toolbar.add_action(open_action); + main_toolbar.add_action(delete_action); + main_toolbar.add_separator(); + main_toolbar.add_action(go_first_action); + main_toolbar.add_action(go_back_action); + main_toolbar.add_action(go_forward_action); + main_toolbar.add_action(go_last_action); + main_toolbar.add_separator(); + main_toolbar.add_action(zoom_in_action); + main_toolbar.add_action(zoom_reset_action); + main_toolbar.add_action(zoom_out_action); + + auto menubar = GUI::MenuBar::construct(); + + auto& app_menu = menubar->add_menu("QuickShow"); + app_menu.add_action(open_action); + app_menu.add_action(delete_action); + app_menu.add_separator(); + app_menu.add_action(quit_action); + + auto& image_menu = menubar->add_menu("Image"); + image_menu.add_action(rotate_left_action); + image_menu.add_action(rotate_right_action); + image_menu.add_action(vertical_flip_action); + image_menu.add_action(horizontal_flip_action); + image_menu.add_separator(); + image_menu.add_action(desktop_wallpaper_action); + + auto& navigate_menu = menubar->add_menu("Navigate"); + navigate_menu.add_action(go_first_action); + navigate_menu.add_action(go_back_action); + navigate_menu.add_action(go_forward_action); + navigate_menu.add_action(go_last_action); + + auto& view_menu = menubar->add_menu("View"); + view_menu.add_action(full_sceen_action); + view_menu.add_separator(); + view_menu.add_action(zoom_in_action); + view_menu.add_action(zoom_reset_action); + view_menu.add_action(zoom_out_action); + view_menu.add_separator(); + view_menu.add_action(hide_show_toolbar_action); + + auto& help_menu = menubar->add_menu("Help"); + help_menu.add_action(GUI::CommonActions::make_about_action("QuickShow", app_icon, window)); + + app->set_menubar(move(menubar)); + + if (path != nullptr) { + widget.load_from_file(path); + } + + window->show(); + + return app->exec(); +} diff --git a/Userland/Applications/SoundPlayer/CMakeLists.txt b/Userland/Applications/SoundPlayer/CMakeLists.txt new file mode 100644 index 0000000000..99e1da4653 --- /dev/null +++ b/Userland/Applications/SoundPlayer/CMakeLists.txt @@ -0,0 +1,9 @@ +set(SOURCES + main.cpp + PlaybackManager.cpp + SampleWidget.cpp + SoundPlayerWidget.cpp +) + +serenity_app(SoundPlayer ICON app-sound-player) +target_link_libraries(SoundPlayer LibAudio LibGUI) diff --git a/Userland/Applications/SoundPlayer/PlaybackManager.cpp b/Userland/Applications/SoundPlayer/PlaybackManager.cpp new file mode 100644 index 0000000000..1421e75215 --- /dev/null +++ b/Userland/Applications/SoundPlayer/PlaybackManager.cpp @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "PlaybackManager.h" + +PlaybackManager::PlaybackManager(NonnullRefPtr<Audio::ClientConnection> connection) + : m_connection(connection) +{ + m_timer = Core::Timer::construct(100, [&]() { + if (!m_loader) + return; + next_buffer(); + }); + m_timer->stop(); +} + +PlaybackManager::~PlaybackManager() +{ +} + +void PlaybackManager::set_loader(NonnullRefPtr<Audio::Loader>&& loader) +{ + stop(); + m_loader = loader; + if (m_loader) { + m_total_length = m_loader->total_samples() / static_cast<float>(m_loader->sample_rate()); + m_timer->start(); + load_next_buffer(); + } else { + m_timer->stop(); + } +} + +void PlaybackManager::stop() +{ + set_paused(true); + m_connection->clear_buffer(true); + m_buffers.clear(); + m_last_seek = 0; + m_next_buffer = nullptr; + m_current_buffer = nullptr; + m_next_ptr = 0; + + if (m_loader) + m_loader->reset(); +} + +void PlaybackManager::play() +{ + set_paused(false); +} + +void PlaybackManager::loop(bool loop) +{ + m_loop = loop; +} + +void PlaybackManager::seek(const int position) +{ + if (!m_loader) + return; + + m_last_seek = position; + bool paused_state = m_paused; + set_paused(true); + + m_connection->clear_buffer(true); + m_next_buffer = nullptr; + m_current_buffer = nullptr; + m_next_ptr = 0; + m_buffers.clear(); + m_loader->seek(position); + + if (!paused_state) + set_paused(false); +} + +void PlaybackManager::pause() +{ + set_paused(true); +} + +void PlaybackManager::remove_dead_buffers() +{ + int id = m_connection->get_playing_buffer(); + int current_id = -1; + if (m_current_buffer) + current_id = m_current_buffer->shbuf_id(); + + if (id >= 0 && id != current_id) { + while (!m_buffers.is_empty()) { + --m_next_ptr; + auto buffer = m_buffers.take_first(); + + if (buffer->shbuf_id() == id) { + m_current_buffer = buffer; + break; + } + } + } +} + +void PlaybackManager::load_next_buffer() +{ + if (m_buffers.size() < 10) { + for (int i = 0; i < 20 && m_loader->loaded_samples() < m_loader->total_samples(); i++) { + auto buffer = m_loader->get_more_samples(PLAYBACK_MANAGER_BUFFER_SIZE); + if (buffer) + m_buffers.append(buffer); + } + } + + if (m_next_ptr < m_buffers.size()) { + m_next_buffer = m_buffers.at(m_next_ptr++); + } else { + m_next_buffer = nullptr; + } +} + +void PlaybackManager::set_paused(bool paused) +{ + if (!m_next_buffer && m_loader) + load_next_buffer(); + + m_paused = paused; + m_connection->set_paused(paused); +} + +bool PlaybackManager::toggle_pause() +{ + if (m_paused) { + play(); + } else { + pause(); + } + return m_paused; +} + +void PlaybackManager::next_buffer() +{ + if (on_update) + on_update(); + + if (m_paused) + return; + + remove_dead_buffers(); + if (!m_next_buffer) { + if (!m_connection->get_remaining_samples() && !m_paused) { + dbgln("Exhausted samples :^)"); + if (m_loop) + seek(0); + else + stop(); + } + + return; + } + + bool enqueued = m_connection->try_enqueue(*m_next_buffer); + if (!enqueued) + return; + + load_next_buffer(); +} diff --git a/Userland/Applications/SoundPlayer/PlaybackManager.h b/Userland/Applications/SoundPlayer/PlaybackManager.h new file mode 100644 index 0000000000..35be78fe9d --- /dev/null +++ b/Userland/Applications/SoundPlayer/PlaybackManager.h @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/Vector.h> +#include <LibAudio/Buffer.h> +#include <LibAudio/ClientConnection.h> +#include <LibAudio/Loader.h> +#include <LibCore/Timer.h> + +#define PLAYBACK_MANAGER_BUFFER_SIZE 64 * KiB +#define PLAYBACK_MANAGER_RATE 44100 + +class PlaybackManager final { +public: + PlaybackManager(NonnullRefPtr<Audio::ClientConnection>); + ~PlaybackManager(); + + void play(); + void stop(); + void pause(); + void seek(const int position); + void loop(bool); + bool toggle_pause(); + void set_loader(NonnullRefPtr<Audio::Loader>&&); + + int last_seek() const { return m_last_seek; } + bool is_paused() const { return m_paused; } + float total_length() const { return m_total_length; } + RefPtr<Audio::Buffer> current_buffer() const { return m_current_buffer; } + + NonnullRefPtr<Audio::ClientConnection> connection() const { return m_connection; } + + Function<void()> on_update; + +private: + void next_buffer(); + void set_paused(bool); + void load_next_buffer(); + void remove_dead_buffers(); + + bool m_paused { true }; + bool m_loop = { false }; + size_t m_next_ptr { 0 }; + size_t m_last_seek { 0 }; + float m_total_length { 0 }; + RefPtr<Audio::Loader> m_loader { nullptr }; + NonnullRefPtr<Audio::ClientConnection> m_connection; + RefPtr<Audio::Buffer> m_next_buffer; + RefPtr<Audio::Buffer> m_current_buffer; + Vector<RefPtr<Audio::Buffer>> m_buffers; + RefPtr<Core::Timer> m_timer; +}; diff --git a/Userland/Applications/SoundPlayer/SampleWidget.cpp b/Userland/Applications/SoundPlayer/SampleWidget.cpp new file mode 100644 index 0000000000..b3884a175a --- /dev/null +++ b/Userland/Applications/SoundPlayer/SampleWidget.cpp @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "SampleWidget.h" +#include <LibAudio/Buffer.h> +#include <LibGUI/Painter.h> +#include <math.h> + +SampleWidget::SampleWidget() +{ +} + +SampleWidget::~SampleWidget() +{ +} + +void SampleWidget::paint_event(GUI::PaintEvent& event) +{ + GUI::Frame::paint_event(event); + GUI::Painter painter(*this); + + painter.add_clip_rect(event.rect()); + painter.fill_rect(frame_inner_rect(), Color::Black); + + float sample_max = 0; + int count = 0; + int x_offset = frame_inner_rect().x(); + int x = x_offset; + int y_offset = frame_inner_rect().center().y(); + + if (m_buffer) { + int samples_per_pixel = m_buffer->sample_count() / frame_inner_rect().width(); + for (int sample_index = 0; sample_index < m_buffer->sample_count() && (x - x_offset) < frame_inner_rect().width(); ++sample_index) { + float sample = fabsf((float)m_buffer->samples()[sample_index].left); + + sample_max = max(sample, sample_max); + ++count; + + if (count >= samples_per_pixel) { + Gfx::IntPoint min_point = { x, y_offset + static_cast<int>(-sample_max * frame_inner_rect().height() / 2) }; + Gfx::IntPoint max_point = { x++, y_offset + static_cast<int>(sample_max * frame_inner_rect().height() / 2) }; + painter.draw_line(min_point, max_point, Color::Green); + + count = 0; + sample_max = 0; + } + } + } else { + painter.draw_line({ x, y_offset }, { frame_inner_rect().width(), y_offset }, Color::Green); + } +} + +void SampleWidget::set_buffer(Audio::Buffer* buffer) +{ + if (m_buffer == buffer) + return; + m_buffer = buffer; + update(); +} diff --git a/Userland/Applications/SoundPlayer/SampleWidget.h b/Userland/Applications/SoundPlayer/SampleWidget.h new file mode 100644 index 0000000000..7da9c20b38 --- /dev/null +++ b/Userland/Applications/SoundPlayer/SampleWidget.h @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Frame.h> + +namespace Audio { +class Buffer; +} + +class SampleWidget final : public GUI::Frame { + C_OBJECT(SampleWidget) +public: + virtual ~SampleWidget() override; + + void set_buffer(Audio::Buffer*); + +private: + SampleWidget(); + virtual void paint_event(GUI::PaintEvent&) override; + + RefPtr<Audio::Buffer> m_buffer; +}; diff --git a/Userland/Applications/SoundPlayer/SoundPlayerWidget.cpp b/Userland/Applications/SoundPlayer/SoundPlayerWidget.cpp new file mode 100644 index 0000000000..f2066aca89 --- /dev/null +++ b/Userland/Applications/SoundPlayer/SoundPlayerWidget.cpp @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "SoundPlayerWidget.h" +#include <AK/StringBuilder.h> +#include <LibCore/MimeData.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/Label.h> +#include <LibGUI/MessageBox.h> +#include <math.h> + +SoundPlayerWidget::SoundPlayerWidget(GUI::Window& window, NonnullRefPtr<Audio::ClientConnection> connection) + : m_window(window) + , m_connection(connection) + , m_manager(connection) +{ + set_fill_with_background_color(true); + set_layout<GUI::VerticalBoxLayout>(); + layout()->set_margins({ 2, 2, 2, 2 }); + + auto& status_widget = add<GUI::Widget>(); + status_widget.set_fill_with_background_color(true); + status_widget.set_layout<GUI::HorizontalBoxLayout>(); + + m_elapsed = status_widget.add<GUI::Label>(); + m_elapsed->set_frame_shape(Gfx::FrameShape::Container); + m_elapsed->set_frame_shadow(Gfx::FrameShadow::Sunken); + m_elapsed->set_frame_thickness(2); + m_elapsed->set_fixed_width(80); + + auto& sample_widget_container = status_widget.add<GUI::Widget>(); + sample_widget_container.set_layout<GUI::HorizontalBoxLayout>(); + + m_sample_widget = sample_widget_container.add<SampleWidget>(); + + m_remaining = status_widget.add<GUI::Label>(); + m_remaining->set_frame_shape(Gfx::FrameShape::Container); + m_remaining->set_frame_shadow(Gfx::FrameShadow::Sunken); + m_remaining->set_frame_thickness(2); + m_remaining->set_fixed_width(80); + + m_slider = add<Slider>(Orientation::Horizontal); + m_slider->set_min(0); + m_slider->set_enabled(false); + m_slider->on_knob_released = [&](int value) { m_manager.seek(denormalize_rate(value)); }; + + auto& control_widget = add<GUI::Widget>(); + control_widget.set_fill_with_background_color(true); + control_widget.set_layout<GUI::HorizontalBoxLayout>(); + control_widget.set_fixed_height(30); + control_widget.layout()->set_margins({ 10, 2, 10, 2 }); + control_widget.layout()->set_spacing(10); + + m_play = control_widget.add<GUI::Button>(); + m_play->set_icon(*m_pause_icon); + m_play->set_enabled(false); + m_play->on_click = [this](auto) { + m_play->set_icon(m_manager.toggle_pause() ? *m_play_icon : *m_pause_icon); + }; + + m_stop = control_widget.add<GUI::Button>(); + m_stop->set_enabled(false); + m_stop->set_icon(Gfx::Bitmap::load_from_file("/res/icons/16x16/stop.png")); + m_stop->on_click = [this](auto) { m_manager.stop(); }; + + m_status = add<GUI::Label>(); + m_status->set_frame_shape(Gfx::FrameShape::Box); + m_status->set_frame_shadow(Gfx::FrameShadow::Raised); + m_status->set_frame_thickness(4); + m_status->set_text_alignment(Gfx::TextAlignment::CenterLeft); + m_status->set_fixed_height(18); + m_status->set_text("No file open!"); + + update_position(0); + + m_manager.on_update = [&]() { update_ui(); }; +} + +SoundPlayerWidget::~SoundPlayerWidget() +{ +} + +SoundPlayerWidget::Slider::~Slider() +{ +} + +void SoundPlayerWidget::hide_scope(bool hide) +{ + m_sample_widget->set_visible(!hide); +} + +void SoundPlayerWidget::open_file(String path) +{ + NonnullRefPtr<Audio::Loader> loader = Audio::Loader::create(path); + if (loader->has_error() || !loader->sample_rate()) { + const String error_string = loader->error_string(); + GUI::MessageBox::show(window(), + String::formatted("Failed to load audio file: {} ({})", path, error_string.is_null() ? "Unknown error" : error_string), + "Filetype error", GUI::MessageBox::Type::Error); + return; + } + + m_sample_ratio = PLAYBACK_MANAGER_RATE / static_cast<float>(loader->sample_rate()); + + m_slider->set_max(normalize_rate(static_cast<int>(loader->total_samples()))); + m_slider->set_enabled(true); + m_play->set_enabled(true); + m_stop->set_enabled(true); + + m_window.set_title(String::formatted("{} - SoundPlayer", loader->file()->filename())); + m_status->set_text(String::formatted( + "Sample rate {}Hz, {} channel(s), {} bits per sample", + loader->sample_rate(), + loader->num_channels(), + loader->bits_per_sample())); + + m_manager.set_loader(move(loader)); + update_position(0); +} + +void SoundPlayerWidget::drop_event(GUI::DropEvent& event) +{ + event.accept(); + window()->move_to_front(); + + if (event.mime_data().has_urls()) { + auto urls = event.mime_data().urls(); + if (urls.is_empty()) + return; + open_file(urls.first().path()); + } +} + +int SoundPlayerWidget::normalize_rate(int rate) const +{ + return static_cast<int>(rate * m_sample_ratio); +} + +int SoundPlayerWidget::denormalize_rate(int rate) const +{ + return static_cast<int>(rate / m_sample_ratio); +} + +void SoundPlayerWidget::update_ui() +{ + m_sample_widget->set_buffer(m_manager.current_buffer()); + m_play->set_icon(m_manager.is_paused() ? *m_play_icon : *m_pause_icon); + update_position(m_manager.connection()->get_played_samples()); +} + +void SoundPlayerWidget::update_position(const int position) +{ + int total_norm_samples = position + normalize_rate(m_manager.last_seek()); + float seconds = (total_norm_samples / static_cast<float>(PLAYBACK_MANAGER_RATE)); + float remaining_seconds = m_manager.total_length() - seconds; + + m_elapsed->set_text(String::formatted( + "Elapsed:\n{}:{:02}.{:02}", + static_cast<int>(seconds / 60), + static_cast<int>(seconds) % 60, + static_cast<int>(seconds * 100) % 100)); + + m_remaining->set_text(String::formatted( + "Remaining:\n{}:{:02}.{:02}", + static_cast<int>(remaining_seconds / 60), + static_cast<int>(remaining_seconds) % 60, + static_cast<int>(remaining_seconds * 100) % 100)); + + m_slider->set_value(total_norm_samples); +} diff --git a/Userland/Applications/SoundPlayer/SoundPlayerWidget.h b/Userland/Applications/SoundPlayer/SoundPlayerWidget.h new file mode 100644 index 0000000000..ab7bc60aea --- /dev/null +++ b/Userland/Applications/SoundPlayer/SoundPlayerWidget.h @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "PlaybackManager.h" +#include "SampleWidget.h" +#include <LibGUI/Button.h> +#include <LibGUI/Label.h> +#include <LibGUI/Slider.h> +#include <LibGUI/Widget.h> +#include <LibGUI/Window.h> + +class SoundPlayerWidget final : public GUI::Widget { + C_OBJECT(SoundPlayerWidget) +public: + virtual ~SoundPlayerWidget() override; + void open_file(String path); + void hide_scope(bool); + PlaybackManager& manager() { return m_manager; } + +private: + explicit SoundPlayerWidget(GUI::Window&, NonnullRefPtr<Audio::ClientConnection>); + + virtual void drop_event(GUI::DropEvent&) override; + + void update_position(const int position); + void update_ui(); + int normalize_rate(int) const; + int denormalize_rate(int) const; + + class Slider final : public GUI::Slider { + C_OBJECT(Slider) + public: + virtual ~Slider() override; + Function<void(int)> on_knob_released; + void set_value(int value) + { + if (!knob_dragging()) + GUI::Slider::set_value(value); + } + + protected: + Slider(Orientation orientation) + : GUI::Slider(orientation) + { + } + + virtual void mouseup_event(GUI::MouseEvent& event) override + { + if (on_knob_released && is_enabled()) + on_knob_released(value()); + + GUI::Slider::mouseup_event(event); + } + }; + + GUI::Window& m_window; + NonnullRefPtr<Audio::ClientConnection> m_connection; + PlaybackManager m_manager; + float m_sample_ratio { 1.0 }; + RefPtr<GUI::Label> m_status; + RefPtr<GUI::Label> m_elapsed; + RefPtr<GUI::Label> m_remaining; + RefPtr<Slider> m_slider; + RefPtr<SampleWidget> m_sample_widget; + RefPtr<Gfx::Bitmap> m_play_icon { Gfx::Bitmap::load_from_file("/res/icons/16x16/play.png") }; + RefPtr<Gfx::Bitmap> m_pause_icon { Gfx::Bitmap::load_from_file("/res/icons/16x16/pause.png") }; + RefPtr<GUI::Button> m_play; + RefPtr<GUI::Button> m_stop; +}; diff --git a/Userland/Applications/SoundPlayer/main.cpp b/Userland/Applications/SoundPlayer/main.cpp new file mode 100644 index 0000000000..47dbd555c8 --- /dev/null +++ b/Userland/Applications/SoundPlayer/main.cpp @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "SoundPlayerWidget.h" +#include <LibAudio/ClientConnection.h> +#include <LibGUI/Action.h> +#include <LibGUI/Application.h> +#include <LibGUI/FilePicker.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/Window.h> +#include <LibGfx/CharacterBitmap.h> +#include <stdio.h> + +int main(int argc, char** argv) +{ + if (pledge("stdio shared_buffer accept rpath thread unix cpath fattr", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio shared_buffer accept rpath thread unix", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto audio_client = Audio::ClientConnection::construct(); + audio_client->handshake(); + + if (pledge("stdio shared_buffer accept rpath thread", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app_icon = GUI::Icon::default_icon("app-sound-player"); + + auto window = GUI::Window::construct(); + window->set_title("Sound Player"); + window->set_resizable(false); + window->resize(350, 140); + window->set_icon(app_icon.bitmap_for_size(16)); + + auto menubar = GUI::MenuBar::construct(); + auto& app_menu = menubar->add_menu("Sound Player"); + auto& player = window->set_main_widget<SoundPlayerWidget>(window, audio_client); + + if (argc > 1) { + String path = argv[1]; + player.open_file(path); + player.manager().play(); + } + + auto hide_scope = GUI::Action::create_checkable("Hide scope", { Mod_Ctrl, Key_H }, [&](auto& action) { + player.hide_scope(action.is_checked()); + }); + + app_menu.add_action(GUI::CommonActions::make_open_action([&](auto&) { + Optional<String> path = GUI::FilePicker::get_open_filepath(window, "Open sound file..."); + if (path.has_value()) { + player.open_file(path.value()); + } + })); + app_menu.add_action(move(hide_scope)); + app_menu.add_separator(); + app_menu.add_action(GUI::CommonActions::make_quit_action([&](auto&) { + app->quit(); + })); + + auto& playback_menu = menubar->add_menu("Playback"); + + auto loop = GUI::Action::create_checkable("Loop", { Mod_Ctrl, Key_R }, [&](auto& action) { + player.manager().loop(action.is_checked()); + }); + + playback_menu.add_action(move(loop)); + + auto& help_menu = menubar->add_menu("Help"); + help_menu.add_action(GUI::CommonActions::make_about_action("Sound Player", app_icon, window)); + + app->set_menubar(move(menubar)); + + window->show(); + return app->exec(); +} diff --git a/Userland/Applications/SpaceAnalyzer/CMakeLists.txt b/Userland/Applications/SpaceAnalyzer/CMakeLists.txt new file mode 100644 index 0000000000..23cc18f13e --- /dev/null +++ b/Userland/Applications/SpaceAnalyzer/CMakeLists.txt @@ -0,0 +1,10 @@ +compile_gml(SpaceAnalyzer.gml SpaceAnalyzerGML.h space_analyzer_gml) + +set(SOURCES + main.cpp + TreeMapWidget.cpp + SpaceAnalyzerGML.h +) + +serenity_app(SpaceAnalyzer ICON app-space-analyzer) +target_link_libraries(SpaceAnalyzer LibGfx LibGUI) diff --git a/Userland/Applications/SpaceAnalyzer/SpaceAnalyzer.gml b/Userland/Applications/SpaceAnalyzer/SpaceAnalyzer.gml new file mode 100644 index 0000000000..a4a3363c6a --- /dev/null +++ b/Userland/Applications/SpaceAnalyzer/SpaceAnalyzer.gml @@ -0,0 +1,20 @@ +@GUI::Widget { + layout: @GUI::VerticalBoxLayout { + spacing: 0 + } + + @GUI::ToolBarContainer { + @GUI::BreadcrumbBar { + fixed_height: 25 + name: "breadcrumb_bar" + } + } + + @SpaceAnalyzer::TreeMapWidget { + name: "tree_map" + } + + @GUI::StatusBar { + name: "status_bar" + } +} diff --git a/Userland/Applications/SpaceAnalyzer/TreeMapWidget.cpp b/Userland/Applications/SpaceAnalyzer/TreeMapWidget.cpp new file mode 100644 index 0000000000..97d9b58a9e --- /dev/null +++ b/Userland/Applications/SpaceAnalyzer/TreeMapWidget.cpp @@ -0,0 +1,376 @@ +/* + * Copyright (c) 2021, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "TreeMapWidget.h" +#include <AK/NumberFormat.h> +#include <LibGUI/Painter.h> +#include <LibGUI/WindowServerConnection.h> +#include <LibGfx/Font.h> +#include <WindowServer/WindowManager.h> + +namespace SpaceAnalyzer { + +REGISTER_WIDGET(SpaceAnalyzer, TreeMapWidget) + +TreeMapWidget::TreeMapWidget() + : m_viewpoint(0) +{ +} + +TreeMapWidget::~TreeMapWidget() +{ +} + +static const Color colors[] = { + Color(253, 231, 37), + Color(148, 216, 64), + Color(60, 188, 117), + Color(31, 150, 139), + Color(45, 112, 142), + Color(63, 71, 136), + Color(85, 121, 104), +}; + +static float get_normalized_aspect_ratio(float a, float b) +{ + if (a < b) { + return a / b; + } else { + return b / a; + } +} + +static bool node_is_leaf(const TreeMapNode& node) +{ + return node.num_children() == 0; +} + +bool TreeMapWidget::rect_can_contain_label(const Gfx::IntRect& rect) const +{ + return rect.height() > font().presentation_size() && rect.width() > 20; +} + +bool TreeMapWidget::rect_can_contain_children(const Gfx::IntRect& rect) const +{ + return rect.height() > 10 && rect.width() > 10; +} + +Gfx::IntRect TreeMapWidget::inner_rect_for_frame(const Gfx::IntRect& rect) const +{ + const int margin = 5; + Gfx::IntRect tmp_rect = rect; + tmp_rect.shrink(2, 2); // border + tmp_rect.shrink(2, 2); // shading + if (rect_can_contain_label(rect)) { + tmp_rect.set_y(tmp_rect.y() + font().presentation_size() + margin); + tmp_rect.set_height(tmp_rect.height() - (font().presentation_size() + margin * 2)); + tmp_rect.set_x(tmp_rect.x() + margin); + tmp_rect.set_width(tmp_rect.width() - margin * 2); + } + return tmp_rect; +} + +void TreeMapWidget::paint_cell_frame(GUI::Painter& painter, const TreeMapNode& node, const Gfx::IntRect& cell_rect, int depth, bool fill_frame) const +{ + const Gfx::IntRect border_rect = cell_rect.shrunken(2, 2); + const Gfx::IntRect outer_rect = border_rect.shrunken(2, 2); + const Gfx::IntRect inner_rect = inner_rect_for_frame(cell_rect); + + painter.clear_clip_rect(); + painter.add_clip_rect(cell_rect); + Color color = colors[depth % (sizeof(colors) / sizeof(colors[0]))]; + if (m_selected_node_cache == &node) { + color = color.darkened(0.8f); + } + + // Draw borders. + painter.draw_rect(cell_rect, Color::Black, false); + painter.draw_line(border_rect.bottom_left(), border_rect.top_left(), color.lightened()); + painter.draw_line(border_rect.top_left(), border_rect.top_right(), color.lightened()); + painter.draw_line(border_rect.top_right(), border_rect.bottom_right(), color.darkened()); + painter.draw_line(border_rect.bottom_left(), border_rect.bottom_right(), color.darkened()); + + // Paint the background. + if (fill_frame) { + painter.fill_rect(outer_rect, color); + } else { + for (auto& shard : outer_rect.shatter(inner_rect)) { + painter.fill_rect(shard, color); + } + } + + // Paint text. + if (rect_can_contain_label(outer_rect)) { + Gfx::IntRect text_rect = outer_rect; + text_rect.move_by(2, 2); + painter.draw_text(text_rect, node.name(), font(), Gfx::TextAlignment::TopLeft, Color::Black); + if (node_is_leaf(node)) { + text_rect.move_by(0, font().presentation_size() + 1); + painter.draw_text(text_rect, human_readable_size(node.area()), font(), Gfx::TextAlignment::TopLeft, Color::Black); + } + } +} + +template<typename Function> +void TreeMapWidget::lay_out_children(const TreeMapNode& node, const Gfx::IntRect& rect, int depth, Function callback) +{ + if (node.num_children() == 0) { + return; + } + + // Check if the children are sorted yet, if not do that now. + for (size_t k = 0; k < node.num_children() - 1; k++) { + if (node.child_at(k).area() < node.child_at(k + 1).area()) { + node.sort_children_by_area(); + break; + } + } + + int total_area = node.area(); + Gfx::IntRect canvas = rect; + bool remaining_nodes_are_too_small = false; + for (size_t i = 0; !remaining_nodes_are_too_small && i < node.num_children(); i++) { + const int i_node_area = node.child_at(i).area(); + if (i_node_area == 0) + break; + + const int long_side_size = max(canvas.width(), canvas.height()); + const int short_side_size = min(canvas.width(), canvas.height()); + + int row_or_column_size = (long long int)long_side_size * i_node_area / total_area; + int node_area_sum = i_node_area; + size_t k = i + 1; + + // Try to add nodes to this row or column so long as the worst aspect ratio of + // the new set of nodes is better than the worst aspect ratio of the current set. + { + float best_worst_aspect_ratio_so_far = get_normalized_aspect_ratio(row_or_column_size, short_side_size); + for (; k < node.num_children(); k++) { + // Do a preliminary calculation of the worst aspect ratio of the nodes at index i and k + // if that aspect ratio is better than the 'best_worst_aspect_ratio_so_far' we keep it, + // otherwise it is discarded. + int k_node_area = node.child_at(k).area(); + if (k_node_area == 0) { + break; + } + int new_node_area_sum = node_area_sum + k_node_area; + int new_row_or_column_size = (long long int)long_side_size * new_node_area_sum / total_area; + int i_node_size = (long long int)short_side_size * i_node_area / new_node_area_sum; + int k_node_size = (long long int)short_side_size * k_node_area / new_node_area_sum; + float i_node_aspect_ratio = get_normalized_aspect_ratio(new_row_or_column_size, i_node_size); + float k_node_aspect_ratio = get_normalized_aspect_ratio(new_row_or_column_size, k_node_size); + float new_worst_aspect_ratio = min(i_node_aspect_ratio, k_node_aspect_ratio); + if (new_worst_aspect_ratio < best_worst_aspect_ratio_so_far) { + break; + } + best_worst_aspect_ratio_so_far = new_worst_aspect_ratio; + node_area_sum = new_node_area_sum; + row_or_column_size = new_row_or_column_size; + } + } + + // Paint the elements from 'i' up to and including 'k-1'. + { + const int fixed_side_size = row_or_column_size; + int placement_area = node_area_sum; + int main_dim = short_side_size; + + // Lay out nodes in a row or column. + Orientation orientation = canvas.width() > canvas.height() ? Orientation::Horizontal : Orientation::Vertical; + Gfx::IntRect layout_rect = canvas; + layout_rect.set_primary_size_for_orientation(orientation, fixed_side_size); + for (size_t q = i; q < k; q++) { + auto& child = node.child_at(q); + int node_size = (long long int)main_dim * child.area() / placement_area; + Gfx::IntRect cell_rect = layout_rect; + cell_rect.set_secondary_size_for_orientation(orientation, node_size); + Gfx::IntRect inner_rect = inner_rect_for_frame(cell_rect); + bool is_visual_leaf = child.num_children() == 0 || !rect_can_contain_children(inner_rect); + callback(child, q, cell_rect, depth, is_visual_leaf ? IsVisualLeaf::Yes : IsVisualLeaf::No, IsRemainder::No); + if (cell_rect.width() * cell_rect.height() < 16) { + remaining_nodes_are_too_small = true; + } else { + lay_out_children(child, inner_rect, depth + 1, callback); + } + layout_rect.set_secondary_offset_for_orientation(orientation, layout_rect.secondary_offset_for_orientation(orientation) + node_size); + main_dim -= node_size; + placement_area -= child.area(); + } + canvas.set_primary_offset_for_orientation(orientation, canvas.primary_offset_for_orientation(orientation) + fixed_side_size); + canvas.set_primary_size_for_orientation(orientation, canvas.primary_size_for_orientation(orientation) - fixed_side_size); + } + + // Consume nodes that were added to this row or column. + i = k - 1; + total_area -= node_area_sum; + } + + // If not the entire canvas was filled with nodes, fill the remaining area with a dither pattern. + if (!canvas.is_empty()) { + callback(node, 0, canvas, depth, IsVisualLeaf::No, IsRemainder::Yes); + } +} + +const TreeMapNode* TreeMapWidget::path_node(size_t n) const +{ + if (!m_tree.ptr()) + return nullptr; + const TreeMapNode* iter = &m_tree->root(); + size_t path_index = 0; + while (iter && path_index < m_path.size() && path_index < n) { + size_t child_index = m_path[path_index]; + if (child_index >= iter->num_children()) { + return nullptr; + } + iter = &iter->child_at(child_index); + path_index++; + } + return iter; +} + +void TreeMapWidget::paint_event(GUI::PaintEvent& event) +{ + GUI::Frame::paint_event(event); + GUI::Painter painter(*this); + + m_selected_node_cache = path_node(m_path.size()); + + const TreeMapNode* node = path_node(m_viewpoint); + if (!node) { + painter.fill_rect(frame_inner_rect(), Color::MidGray); + } else if (node_is_leaf(*node)) { + paint_cell_frame(painter, *node, frame_inner_rect(), m_viewpoint - 1, true); + } else { + lay_out_children(*node, frame_inner_rect(), m_viewpoint, [&](const TreeMapNode& node, int, const Gfx::IntRect& rect, int depth, IsVisualLeaf visual_leaf, IsRemainder remainder) { + if (remainder == IsRemainder::No) { + bool fill = visual_leaf == IsVisualLeaf::Yes ? true : false; + paint_cell_frame(painter, node, rect, depth, fill); + } else { + Color color = colors[depth % (sizeof(colors) / sizeof(colors[0]))]; + painter.clear_clip_rect(); + painter.add_clip_rect(rect); + painter.draw_rect(rect, Color::Black); + painter.fill_rect_with_dither_pattern(rect.shrunken(2, 2), color, Color::Black); + } + }); + } +} + +Vector<int> TreeMapWidget::path_to_position(const Gfx::IntPoint& position) +{ + const TreeMapNode* node = path_node(m_viewpoint); + if (!node) { + return {}; + } + Vector<int> path; + lay_out_children(*node, frame_inner_rect(), m_viewpoint, [&](const TreeMapNode&, int index, const Gfx::IntRect& rect, int, IsVisualLeaf, IsRemainder is_remainder) { + if (is_remainder == IsRemainder::No && rect.contains(position)) { + path.append(index); + } + }); + return path; +} + +void TreeMapWidget::mousedown_event(GUI::MouseEvent& event) +{ + const TreeMapNode* node = path_node(m_viewpoint); + if (node && !node_is_leaf(*node)) { + Vector<int> path = path_to_position(event.position()); + if (!path.is_empty()) { + m_path.shrink(m_viewpoint); + m_path.append(path); + if (on_path_change) { + on_path_change(); + } + update(); + } + } +} + +void TreeMapWidget::doubleclick_event(GUI::MouseEvent& event) +{ + const TreeMapNode* node = path_node(m_viewpoint); + if (node && !node_is_leaf(*node)) { + Vector<int> path = path_to_position(event.position()); + m_path.shrink(m_viewpoint); + m_path.append(path); + m_viewpoint = m_path.size(); + if (on_path_change) { + on_path_change(); + } + update(); + } +} + +void TreeMapWidget::mousewheel_event(GUI::MouseEvent& event) +{ + int delta = event.wheel_delta(); + // FIXME: The wheel_delta is premultiplied in the window server, we actually want a raw value here. + int step_size = GUI::WindowServerConnection::the().send_sync<Messages::WindowServer::GetScrollStepSize>()->step_size(); + if (delta > 0) { + size_t step_back = delta / step_size; + if (step_back > m_viewpoint) + step_back = m_viewpoint; + set_viewpoint(m_viewpoint - step_back); + } else { + size_t step_up = (-delta) / step_size; + set_viewpoint(m_viewpoint + step_up); + } +} + +void TreeMapWidget::set_tree(RefPtr<TreeMap> tree) +{ + m_tree = tree; + m_path.clear(); + m_viewpoint = 0; + if (on_path_change) { + on_path_change(); + } + update(); +} + +void TreeMapWidget::set_viewpoint(size_t viewpoint) +{ + if (viewpoint > m_path.size()) + viewpoint = m_path.size(); + m_viewpoint = viewpoint; + if (on_path_change) { + on_path_change(); + } + update(); +} + +size_t TreeMapWidget::path_size() const +{ + return m_path.size() + 1; +} + +size_t TreeMapWidget::viewpoint() const +{ + return m_viewpoint; +} + +} diff --git a/Userland/Applications/SpaceAnalyzer/TreeMapWidget.h b/Userland/Applications/SpaceAnalyzer/TreeMapWidget.h new file mode 100644 index 0000000000..8b4e1e9269 --- /dev/null +++ b/Userland/Applications/SpaceAnalyzer/TreeMapWidget.h @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2021, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Frame.h> +#include <LibGfx/Rect.h> + +namespace SpaceAnalyzer { + +struct TreeMapNode { + virtual String name() const = 0; + virtual int64_t area() const = 0; + virtual size_t num_children() const = 0; + virtual const TreeMapNode& child_at(size_t i) const = 0; + virtual void sort_children_by_area() const = 0; +}; + +struct TreeMap : public RefCounted<TreeMap> { + virtual ~TreeMap() { } + virtual const TreeMapNode& root() const = 0; +}; + +class TreeMapWidget final : public GUI::Frame { + C_OBJECT(TreeMapWidget) + +public: + virtual ~TreeMapWidget() override; + Function<void()> on_path_change; + size_t path_size() const; + const TreeMapNode* path_node(size_t n) const; + size_t viewpoint() const; + void set_viewpoint(size_t); + void set_tree(RefPtr<TreeMap> tree); + +private: + TreeMapWidget(); + virtual void paint_event(GUI::PaintEvent&) override; + virtual void mousedown_event(GUI::MouseEvent&) override; + virtual void doubleclick_event(GUI::MouseEvent&) override; + virtual void mousewheel_event(GUI::MouseEvent&) override; + + bool rect_can_contain_children(const Gfx::IntRect& rect) const; + bool rect_can_contain_label(const Gfx::IntRect& rect) const; + Gfx::IntRect inner_rect_for_frame(const Gfx::IntRect& rect) const; + + enum class IsVisualLeaf { + Yes, + No + }; + enum class IsRemainder { + Yes, + No + }; + + template<typename Function> + void lay_out_children(const TreeMapNode&, const Gfx::IntRect&, int depth, Function); + void paint_cell_frame(GUI::Painter&, const TreeMapNode&, const Gfx::IntRect&, int depth, bool fill) const; + Vector<int> path_to_position(const Gfx::IntPoint&); + + RefPtr<TreeMap> m_tree; + Vector<int> m_path; + size_t m_viewpoint; + const void* m_selected_node_cache; +}; + +} diff --git a/Userland/Applications/SpaceAnalyzer/main.cpp b/Userland/Applications/SpaceAnalyzer/main.cpp new file mode 100644 index 0000000000..2ce061f786 --- /dev/null +++ b/Userland/Applications/SpaceAnalyzer/main.cpp @@ -0,0 +1,300 @@ +/* + * Copyright (c) 2021, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +#include "TreeMapWidget.h" +#include <AK/Queue.h> +#include <AK/QuickSort.h> +#include <AK/RefCounted.h> +#include <Applications/SpaceAnalyzer/SpaceAnalyzerGML.h> +#include <LibCore/DirIterator.h> +#include <LibCore/File.h> +#include <LibGUI/AboutDialog.h> +#include <LibGUI/Application.h> +#include <LibGUI/BreadcrumbBar.h> +#include <LibGUI/Icon.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/StatusBar.h> +#include <sys/stat.h> + +static const char* APP_NAME = "Space Analyzer"; + +struct TreeNode : public SpaceAnalyzer::TreeMapNode { + TreeNode(String name) + : m_name(move(name)) {}; + + virtual String name() const { return m_name; } + virtual int64_t area() const { return m_area; } + virtual size_t num_children() const + { + if (m_children) { + return m_children->size(); + } + return 0; + } + virtual const TreeNode& child_at(size_t i) const { return m_children->at(i); } + virtual void sort_children_by_area() const + { + if (m_children) { + Vector<TreeNode>* children = const_cast<Vector<TreeNode>*>(m_children.ptr()); + quick_sort(*children, [](auto& a, auto& b) { return b.m_area < a.m_area; }); + } + } + + String m_name; + int64_t m_area { 0 }; + OwnPtr<Vector<TreeNode>> m_children; +}; + +struct Tree : public SpaceAnalyzer::TreeMap { + Tree(String root_name) + : m_root(move(root_name)) {}; + virtual ~Tree() {}; + TreeNode m_root; + virtual const SpaceAnalyzer::TreeMapNode& root() const override + { + return m_root; + }; +}; + +struct MountInfo { + String mount_point; + String source; +}; + +static void fill_mounts(Vector<MountInfo>& output) +{ + // Output info about currently mounted filesystems. + auto df = Core::File::construct("/proc/df"); + if (!df->open(Core::IODevice::ReadOnly)) { + fprintf(stderr, "Failed to open /proc/df: %s\n", df->error_string()); + return; + } + + auto content = df->read_all(); + auto json = JsonValue::from_string(content); + ASSERT(json.has_value()); + + json.value().as_array().for_each([&output](auto& value) { + auto filesystem_object = value.as_object(); + MountInfo mount_info; + mount_info.mount_point = filesystem_object.get("mount_point").to_string(); + mount_info.source = filesystem_object.get("source").as_string_or("none"); + output.append(mount_info); + }); +} + +static MountInfo* find_mount_for_path(String path, Vector<MountInfo>& mounts) +{ + MountInfo* result = nullptr; + size_t length = 0; + for (auto& mount_info : mounts) { + String& mount_point = mount_info.mount_point; + if (path.starts_with(mount_point)) { + if (!result || mount_point.length() > length) { + result = &mount_info; + length = mount_point.length(); + } + } + } + return result; +} + +static long long int update_totals(TreeNode& node) +{ + long long int result = 0; + if (node.m_children) { + for (auto& child : *node.m_children) { + result += update_totals(child); + } + node.m_area = result; + } else { + result = node.m_area; + } + return result; +} + +struct QueueEntry { + QueueEntry(String path, TreeNode* node) + : path(move(path)) + , node(node) {}; + String path; + TreeNode* node { nullptr }; +}; + +static void populate_filesize_tree(TreeNode& root, Vector<MountInfo>& mounts, HashMap<int, int>& error_accumulator) +{ + ASSERT(!root.m_name.ends_with("/")); + + Queue<QueueEntry> queue; + queue.enqueue(QueueEntry(root.m_name, &root)); + + StringBuilder builder = StringBuilder(); + builder.append(root.m_name); + builder.append("/"); + MountInfo* root_mount_info = find_mount_for_path(builder.to_string(), mounts); + if (!root_mount_info) { + return; + } + while (!queue.is_empty()) { + QueueEntry queue_entry = queue.dequeue(); + + builder.clear(); + builder.append(queue_entry.path); + builder.append("/"); + + MountInfo* mount_info = find_mount_for_path(builder.to_string(), mounts); + if (!mount_info || (mount_info != root_mount_info && mount_info->source != root_mount_info->source)) { + continue; + } + + Core::DirIterator dir_iterator(builder.to_string(), Core::DirIterator::SkipParentAndBaseDir); + if (dir_iterator.has_error()) { + int error_sum = error_accumulator.get(dir_iterator.error()).value_or(0); + error_accumulator.set(dir_iterator.error(), error_sum + 1); + } else { + queue_entry.node->m_children = make<Vector<TreeNode>>(); + while (dir_iterator.has_next()) { + queue_entry.node->m_children->append(TreeNode(dir_iterator.next_path())); + } + for (auto& child : *queue_entry.node->m_children) { + String& name = child.m_name; + int name_len = name.length(); + builder.append(name); + struct stat st; + int stat_result = lstat(builder.to_string().characters(), &st); + if (stat_result < 0) { + int error_sum = error_accumulator.get(errno).value_or(0); + error_accumulator.set(errno, error_sum + 1); + } else { + if (S_ISDIR(st.st_mode)) { + queue.enqueue(QueueEntry(builder.to_string(), &child)); + } else { + child.m_area = st.st_size; + } + } + builder.trim(name_len); + } + } + } + + update_totals(root); +} + +static void analyze(RefPtr<Tree> tree, SpaceAnalyzer::TreeMapWidget& treemapwidget, GUI::StatusBar& statusbar) +{ + // Build an in-memory tree mirroring the filesystem and for each node + // calculate the sum of the file size for all its descendants. + TreeNode* root = &tree->m_root; + Vector<MountInfo> mounts; + fill_mounts(mounts); + HashMap<int, int> error_accumulator; + populate_filesize_tree(*root, mounts, error_accumulator); + + // Display an error summary in the statusbar. + if (!error_accumulator.is_empty()) { + StringBuilder builder; + bool first = true; + builder.append("Some directories were not analyzed: "); + for (auto& key : error_accumulator.keys()) { + if (!first) { + builder.append(", "); + } + builder.append(strerror(key)); + builder.append(" ("); + int value = error_accumulator.get(key).value(); + builder.append(String::number(value)); + if (value == 1) { + builder.append(" time"); + } else { + builder.append(" times"); + } + builder.append(")"); + first = false; + } + statusbar.set_text(builder.to_string()); + } else { + statusbar.set_text("No errors"); + } + treemapwidget.set_tree(tree); +} + +int main(int argc, char* argv[]) +{ + auto app = GUI::Application::construct(argc, argv); + + RefPtr<Tree> tree = adopt(*new Tree("")); + + // Configure application window. + auto app_icon = GUI::Icon::default_icon("app-space-analyzer"); + auto window = GUI::Window::construct(); + window->set_title(APP_NAME); + window->resize(640, 480); + window->set_icon(app_icon.bitmap_for_size(16)); + + // Load widgets. + auto& mainwidget = window->set_main_widget<GUI::Widget>(); + mainwidget.load_from_gml(space_analyzer_gml); + auto& breadcrumbbar = *mainwidget.find_descendant_of_type_named<GUI::BreadcrumbBar>("breadcrumb_bar"); + auto& treemapwidget = *mainwidget.find_descendant_of_type_named<SpaceAnalyzer::TreeMapWidget>("tree_map"); + auto& statusbar = *mainwidget.find_descendant_of_type_named<GUI::StatusBar>("status_bar"); + + // Configure the menubar. + auto menubar = GUI::MenuBar::construct(); + auto& app_menu = menubar->add_menu(APP_NAME); + app_menu.add_action(GUI::Action::create("Analyze", [&](auto&) { + analyze(tree, treemapwidget, statusbar); + })); + app_menu.add_action(GUI::CommonActions::make_quit_action([&](auto&) { + app->quit(); + })); + auto& help_menu = menubar->add_menu("Help"); + help_menu.add_action(GUI::CommonActions::make_about_action(APP_NAME, app_icon, window)); + app->set_menubar(move(menubar)); + + // Configure event handlers. + breadcrumbbar.on_segment_click = [&](size_t index) { + ASSERT(index < treemapwidget.path_size()); + treemapwidget.set_viewpoint(index); + }; + treemapwidget.on_path_change = [&]() { + breadcrumbbar.clear_segments(); + for (size_t k = 0; k < treemapwidget.path_size(); k++) { + if (k == 0) { + breadcrumbbar.append_segment("/"); + } else { + const SpaceAnalyzer::TreeMapNode* node = treemapwidget.path_node(k); + breadcrumbbar.append_segment(node->name()); + } + } + breadcrumbbar.set_selected_segment(treemapwidget.viewpoint()); + }; + + // At startup automatically do an analysis of root. + analyze(tree, treemapwidget, statusbar); + + window->show(); + return app->exec(); +} diff --git a/Userland/Applications/Spreadsheet/CMakeLists.txt b/Userland/Applications/Spreadsheet/CMakeLists.txt new file mode 100644 index 0000000000..f41922655a --- /dev/null +++ b/Userland/Applications/Spreadsheet/CMakeLists.txt @@ -0,0 +1,28 @@ +compile_gml(CondFormatting.gml CondFormattingGML.h cond_fmt_gml) +compile_gml(CondView.gml CondFormattingViewGML.h cond_fmt_view_gml) + +set(SOURCES + Cell.cpp + CellSyntaxHighlighter.cpp + CellType/Date.cpp + CellType/Format.cpp + CellType/Identity.cpp + CellType/Numeric.cpp + CellType/String.cpp + CellType/Type.cpp + CellTypeDialog.cpp + CondFormattingGML.h + CondFormattingViewGML.h + HelpWindow.cpp + JSIntegration.cpp + Readers/XSV.cpp + Spreadsheet.cpp + SpreadsheetModel.cpp + SpreadsheetView.cpp + SpreadsheetWidget.cpp + Workbook.cpp + main.cpp +) + +serenity_app(Spreadsheet ICON app-spreadsheet) +target_link_libraries(Spreadsheet LibGUI LibJS LibWeb) diff --git a/Userland/Applications/Spreadsheet/Cell.cpp b/Userland/Applications/Spreadsheet/Cell.cpp new file mode 100644 index 0000000000..d2f511753f --- /dev/null +++ b/Userland/Applications/Spreadsheet/Cell.cpp @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "Cell.h" +#include "Spreadsheet.h" +#include <AK/StringBuilder.h> +#include <AK/TemporaryChange.h> + +namespace Spreadsheet { + +void Cell::set_data(String new_data) +{ + if (m_data == new_data) + return; + + if (new_data.starts_with("=")) { + new_data = new_data.substring(1, new_data.length() - 1); + m_kind = Formula; + } else { + m_kind = LiteralString; + } + + m_data = move(new_data); + m_dirty = true; + m_evaluated_externally = false; +} + +void Cell::set_data(JS::Value new_data) +{ + m_dirty = true; + m_evaluated_externally = true; + + StringBuilder builder; + + builder.append(new_data.to_string_without_side_effects()); + m_data = builder.build(); + + m_evaluated_data = move(new_data); +} + +void Cell::set_type(const CellType* type) +{ + m_type = type; +} + +void Cell::set_type(const StringView& name) +{ + auto* cell_type = CellType::get_by_name(name); + if (cell_type) { + return set_type(cell_type); + } + + ASSERT_NOT_REACHED(); +} + +void Cell::set_type_metadata(CellTypeMetadata&& metadata) +{ + m_type_metadata = move(metadata); +} + +const CellType& Cell::type() const +{ + if (m_type) + return *m_type; + + if (m_kind == LiteralString) { + if (m_data.to_int().has_value()) + return *CellType::get_by_name("Numeric"); + } + + return *CellType::get_by_name("Identity"); +} + +String Cell::typed_display() const +{ + return type().display(const_cast<Cell&>(*this), m_type_metadata); +} + +JS::Value Cell::typed_js_data() const +{ + return type().js_value(const_cast<Cell&>(*this), m_type_metadata); +} + +void Cell::update_data(Badge<Sheet>) +{ + TemporaryChange cell_change { m_sheet->current_evaluated_cell(), this }; + if (!m_dirty) + return; + + m_js_exception = {}; + + if (m_dirty) { + m_dirty = false; + if (m_kind == Formula) { + if (!m_evaluated_externally) { + auto [value, exception] = m_sheet->evaluate(m_data, this); + m_evaluated_data = value; + m_js_exception = move(exception); + } + } + + for (auto& ref : m_referencing_cells) { + if (ref) { + ref->m_dirty = true; + ref->update(); + } + } + } + + m_evaluated_formats.background_color.clear(); + m_evaluated_formats.foreground_color.clear(); + if (!m_js_exception) { + StringBuilder builder; + for (auto& fmt : m_conditional_formats) { + if (!fmt.condition.is_empty()) { + builder.clear(); + builder.append("return ("); + builder.append(fmt.condition); + builder.append(')'); + auto [value, exception] = m_sheet->evaluate(builder.string_view(), this); + if (exception) { + m_js_exception = move(exception); + } else { + if (value.to_boolean()) { + if (fmt.background_color.has_value()) + m_evaluated_formats.background_color = fmt.background_color; + if (fmt.foreground_color.has_value()) + m_evaluated_formats.foreground_color = fmt.foreground_color; + } + } + } + } + } +} + +void Cell::update() +{ + m_sheet->update(*this); +} + +JS::Value Cell::js_data() +{ + if (m_dirty) + update(); + + if (m_kind == Formula) + return m_evaluated_data; + + return JS::js_string(m_sheet->interpreter().heap(), m_data); +} + +String Cell::source() const +{ + StringBuilder builder; + if (m_kind == Formula) + builder.append('='); + builder.append(m_data); + return builder.to_string(); +} + +// FIXME: Find a better way to figure out dependencies +void Cell::reference_from(Cell* other) +{ + if (!other || other == this) + return; + + if (!m_referencing_cells.find_if([other](const auto& ptr) { return ptr.ptr() == other; }).is_end()) + return; + + m_referencing_cells.append(other->make_weak_ptr()); +} + +void Cell::copy_from(const Cell& other) +{ + m_dirty = true; + m_evaluated_externally = other.m_evaluated_externally; + m_data = other.m_data; + m_evaluated_data = other.m_evaluated_data; + m_kind = other.m_kind; + m_type = other.m_type; + m_type_metadata = other.m_type_metadata; + m_conditional_formats = other.m_conditional_formats; + m_evaluated_formats = other.m_evaluated_formats; + if (!other.m_js_exception) + m_js_exception = other.m_js_exception; +} + +} diff --git a/Userland/Applications/Spreadsheet/Cell.h b/Userland/Applications/Spreadsheet/Cell.h new file mode 100644 index 0000000000..fef9031d5e --- /dev/null +++ b/Userland/Applications/Spreadsheet/Cell.h @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "CellType/Type.h" +#include "ConditionalFormatting.h" +#include "Forward.h" +#include "JSIntegration.h" +#include "Position.h" +#include <AK/String.h> +#include <AK/Types.h> +#include <AK/WeakPtr.h> + +namespace Spreadsheet { + +struct Cell : public Weakable<Cell> { + enum Kind { + LiteralString, + Formula, + }; + + Cell(String data, Position position, WeakPtr<Sheet> sheet) + : m_dirty(false) + , m_data(move(data)) + , m_kind(LiteralString) + , m_sheet(sheet) + , m_position(move(position)) + { + } + + Cell(String source, JS::Value&& cell_value, Position position, WeakPtr<Sheet> sheet) + : m_dirty(false) + , m_data(move(source)) + , m_evaluated_data(move(cell_value)) + , m_kind(Formula) + , m_sheet(sheet) + , m_position(move(position)) + { + } + + void reference_from(Cell*); + + void set_data(String new_data); + void set_data(JS::Value new_data); + bool dirty() const { return m_dirty; } + void clear_dirty() { m_dirty = false; } + + void set_exception(JS::Exception* exc) { m_js_exception = exc; } + JS::Exception* exception() const { return m_js_exception; } + + const String& data() const { return m_data; } + const JS::Value& evaluated_data() const { return m_evaluated_data; } + Kind kind() const { return m_kind; } + const Vector<WeakPtr<Cell>>& referencing_cells() const { return m_referencing_cells; } + + void set_type(const StringView& name); + void set_type(const CellType*); + void set_type_metadata(CellTypeMetadata&&); + + const Position& position() const { return m_position; } + void set_position(Position position, Badge<Sheet>) + { + if (position != m_position) { + m_dirty = true; + m_position = move(position); + } + } + + const Format& evaluated_formats() const { return m_evaluated_formats; } + Format& evaluated_formats() { return m_evaluated_formats; } + const Vector<ConditionalFormat>& conditional_formats() const { return m_conditional_formats; } + void set_conditional_formats(Vector<ConditionalFormat>&& fmts) + { + m_dirty = true; + m_conditional_formats = move(fmts); + } + + String typed_display() const; + JS::Value typed_js_data() const; + + const CellType& type() const; + const CellTypeMetadata& type_metadata() const { return m_type_metadata; } + CellTypeMetadata& type_metadata() { return m_type_metadata; } + + String source() const; + + JS::Value js_data(); + + void update(); + void update_data(Badge<Sheet>); + + const Sheet& sheet() const { return *m_sheet; } + Sheet& sheet() { return *m_sheet; } + + void copy_from(const Cell&); + +private: + bool m_dirty { false }; + bool m_evaluated_externally { false }; + String m_data; + JS::Value m_evaluated_data; + JS::Exception* m_js_exception { nullptr }; + Kind m_kind { LiteralString }; + WeakPtr<Sheet> m_sheet; + Vector<WeakPtr<Cell>> m_referencing_cells; + const CellType* m_type { nullptr }; + CellTypeMetadata m_type_metadata; + Position m_position; + + Vector<ConditionalFormat> m_conditional_formats; + Format m_evaluated_formats; +}; + +} diff --git a/Userland/Applications/Spreadsheet/CellSyntaxHighlighter.cpp b/Userland/Applications/Spreadsheet/CellSyntaxHighlighter.cpp new file mode 100644 index 0000000000..c26d2aa238 --- /dev/null +++ b/Userland/Applications/Spreadsheet/CellSyntaxHighlighter.cpp @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "CellSyntaxHighlighter.h" +#include <LibGUI/JSSyntaxHighlighter.h> +#include <LibGUI/TextEditor.h> +#include <LibGfx/Palette.h> +#include <LibJS/Lexer.h> + +namespace Spreadsheet { + +void CellSyntaxHighlighter::rehighlight(Gfx::Palette palette) +{ + ASSERT(m_editor); + auto text = m_editor->text(); + m_editor->document().spans().clear(); + if (!text.starts_with('=')) { + m_editor->update(); + return; + } + + JSSyntaxHighlighter::rehighlight(palette); + + // Highlight the '=' + m_editor->document().spans().empend( + GUI::TextRange { { 0, 0 }, { 0, 1 } }, + Gfx::TextAttributes { + palette.syntax_keyword(), + Optional<Color> {}, + false, + false, + }, + nullptr, + false); + + if (m_cell && m_cell->exception()) { + auto range = m_cell->exception()->source_ranges().first(); + GUI::TextRange text_range { { range.start.line - 1, range.start.column }, { range.end.line - 1, range.end.column - 1 } }; + m_editor->document().spans().prepend( + GUI::TextDocumentSpan { + text_range, + Gfx::TextAttributes { + Color::Black, + Color::Red, + false, + false, + }, + nullptr, + false }); + } + m_editor->update(); +} + +CellSyntaxHighlighter::~CellSyntaxHighlighter() +{ +} + +} diff --git a/Userland/Applications/Spreadsheet/CellSyntaxHighlighter.h b/Userland/Applications/Spreadsheet/CellSyntaxHighlighter.h new file mode 100644 index 0000000000..f02f7e2bbb --- /dev/null +++ b/Userland/Applications/Spreadsheet/CellSyntaxHighlighter.h @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Cell.h" +#include <LibGUI/JSSyntaxHighlighter.h> +#include <LibGUI/SyntaxHighlighter.h> + +namespace Spreadsheet { + +class CellSyntaxHighlighter final : public GUI::JSSyntaxHighlighter { +public: + CellSyntaxHighlighter() { } + virtual ~CellSyntaxHighlighter() override; + + virtual void rehighlight(Gfx::Palette) override; + void set_cell(const Cell* cell) { m_cell = cell; } + +private: + const Cell* m_cell { nullptr }; +}; + +} diff --git a/Userland/Applications/Spreadsheet/CellType/Date.cpp b/Userland/Applications/Spreadsheet/CellType/Date.cpp new file mode 100644 index 0000000000..005620006f --- /dev/null +++ b/Userland/Applications/Spreadsheet/CellType/Date.cpp @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "Date.h" +#include "../Cell.h" +#include "../Spreadsheet.h" +#include <AK/ScopeGuard.h> +#include <LibCore/DateTime.h> + +namespace Spreadsheet { + +DateCell::DateCell() + : CellType("Date") +{ +} + +DateCell::~DateCell() +{ +} + +String DateCell::display(Cell& cell, const CellTypeMetadata& metadata) const +{ + ScopeGuard propagate_exception { [&cell] { + if (auto exc = cell.sheet().interpreter().exception()) { + cell.sheet().interpreter().vm().clear_exception(); + cell.set_exception(exc); + } + } }; + auto timestamp = js_value(cell, metadata); + auto string = Core::DateTime::from_timestamp(timestamp.to_i32(cell.sheet().global_object())).to_string(metadata.format.is_empty() ? "%Y-%m-%d %H:%M:%S" : metadata.format.characters()); + + if (metadata.length >= 0) + return string.substring(0, metadata.length); + + return string; +} + +JS::Value DateCell::js_value(Cell& cell, const CellTypeMetadata&) const +{ + auto js_data = cell.js_data(); + auto value = js_data.to_double(cell.sheet().global_object()); + return JS::Value(value / 1000); // Turn it to seconds +} + +} diff --git a/Userland/Applications/Spreadsheet/CellType/Date.h b/Userland/Applications/Spreadsheet/CellType/Date.h new file mode 100644 index 0000000000..3221fad0f1 --- /dev/null +++ b/Userland/Applications/Spreadsheet/CellType/Date.h @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Type.h" + +namespace Spreadsheet { + +class DateCell : public CellType { + +public: + DateCell(); + virtual ~DateCell() override; + virtual String display(Cell&, const CellTypeMetadata&) const override; + virtual JS::Value js_value(Cell&, const CellTypeMetadata&) const override; +}; + +} diff --git a/Userland/Applications/Spreadsheet/CellType/Format.cpp b/Userland/Applications/Spreadsheet/CellType/Format.cpp new file mode 100644 index 0000000000..d7c590a550 --- /dev/null +++ b/Userland/Applications/Spreadsheet/CellType/Format.cpp @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "Format.h" +#include <AK/PrintfImplementation.h> +#include <AK/String.h> +#include <AK/StringBuilder.h> + +namespace Spreadsheet { + +template<typename T, typename V> +struct SingleEntryListNext { + ALWAYS_INLINE T operator()(V value) const + { + return (T)value; + } +}; + +template<typename PutChFunc, typename ArgumentListRefT, template<typename T, typename U = ArgumentListRefT> typename NextArgument> +struct PrintfImpl : public PrintfImplementation::PrintfImpl<PutChFunc, ArgumentListRefT, NextArgument> { + ALWAYS_INLINE PrintfImpl(PutChFunc& putch, char*& bufptr, const int& nwritten) + : PrintfImplementation::PrintfImpl<PutChFunc, ArgumentListRefT, NextArgument>(putch, bufptr, nwritten) + { + } + + // Disallow pointer formats. + ALWAYS_INLINE int format_n(const PrintfImplementation::ModifierState&, ArgumentListRefT&) const + { + return 0; + } + ALWAYS_INLINE int format_s(const PrintfImplementation::ModifierState&, ArgumentListRefT&) const + { + return 0; + } +}; + +String format_double(const char* format, double value) +{ + StringBuilder builder; + auto putch = [&](auto, auto ch) { builder.append(ch); }; + printf_internal<decltype(putch), PrintfImpl, double, SingleEntryListNext>(putch, nullptr, format, value); + + return builder.build(); +} + +} diff --git a/Userland/Applications/Spreadsheet/CellType/Format.h b/Userland/Applications/Spreadsheet/CellType/Format.h new file mode 100644 index 0000000000..98f3cdc741 --- /dev/null +++ b/Userland/Applications/Spreadsheet/CellType/Format.h @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/Forward.h> + +namespace Spreadsheet { + +String format_double(const char* format, double value); + +} diff --git a/Userland/Applications/Spreadsheet/CellType/Identity.cpp b/Userland/Applications/Spreadsheet/CellType/Identity.cpp new file mode 100644 index 0000000000..eed7cb190c --- /dev/null +++ b/Userland/Applications/Spreadsheet/CellType/Identity.cpp @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "Identity.h" +#include "../Cell.h" +#include "../Spreadsheet.h" + +namespace Spreadsheet { + +IdentityCell::IdentityCell() + : CellType("Identity") +{ +} + +IdentityCell::~IdentityCell() +{ +} + +String IdentityCell::display(Cell& cell, const CellTypeMetadata&) const +{ + return cell.js_data().to_string_without_side_effects(); +} + +JS::Value IdentityCell::js_value(Cell& cell, const CellTypeMetadata&) const +{ + return cell.js_data(); +} + +} diff --git a/Userland/Applications/Spreadsheet/CellType/Identity.h b/Userland/Applications/Spreadsheet/CellType/Identity.h new file mode 100644 index 0000000000..09089153ec --- /dev/null +++ b/Userland/Applications/Spreadsheet/CellType/Identity.h @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Type.h" + +namespace Spreadsheet { + +class IdentityCell : public CellType { + +public: + IdentityCell(); + virtual ~IdentityCell() override; + virtual String display(Cell&, const CellTypeMetadata&) const override; + virtual JS::Value js_value(Cell&, const CellTypeMetadata&) const override; +}; + +} diff --git a/Userland/Applications/Spreadsheet/CellType/Numeric.cpp b/Userland/Applications/Spreadsheet/CellType/Numeric.cpp new file mode 100644 index 0000000000..d58d48cd85 --- /dev/null +++ b/Userland/Applications/Spreadsheet/CellType/Numeric.cpp @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "Numeric.h" +#include "../Cell.h" +#include "../Spreadsheet.h" +#include "Format.h" +#include <AK/ScopeGuard.h> + +namespace Spreadsheet { + +NumericCell::NumericCell() + : CellType("Numeric") +{ +} + +NumericCell::~NumericCell() +{ +} + +String NumericCell::display(Cell& cell, const CellTypeMetadata& metadata) const +{ + ScopeGuard propagate_exception { [&cell] { + if (auto exc = cell.sheet().interpreter().exception()) { + cell.sheet().interpreter().vm().clear_exception(); + cell.set_exception(exc); + } + } }; + auto value = js_value(cell, metadata); + String string; + if (metadata.format.is_empty()) + string = value.to_string_without_side_effects(); + else + string = format_double(metadata.format.characters(), value.to_double(cell.sheet().global_object())); + + if (metadata.length >= 0) + return string.substring(0, metadata.length); + + return string; +} + +JS::Value NumericCell::js_value(Cell& cell, const CellTypeMetadata&) const +{ + ScopeGuard propagate_exception { [&cell] { + if (auto exc = cell.sheet().interpreter().exception()) { + cell.sheet().interpreter().vm().clear_exception(); + cell.set_exception(exc); + } + } }; + return cell.js_data().to_number(cell.sheet().global_object()); +} + +} diff --git a/Userland/Applications/Spreadsheet/CellType/Numeric.h b/Userland/Applications/Spreadsheet/CellType/Numeric.h new file mode 100644 index 0000000000..e0cd4a295a --- /dev/null +++ b/Userland/Applications/Spreadsheet/CellType/Numeric.h @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Type.h" + +namespace Spreadsheet { + +class NumericCell : public CellType { + +public: + NumericCell(); + virtual ~NumericCell() override; + virtual String display(Cell&, const CellTypeMetadata&) const override; + virtual JS::Value js_value(Cell&, const CellTypeMetadata&) const override; +}; + +} diff --git a/Userland/Applications/Spreadsheet/CellType/String.cpp b/Userland/Applications/Spreadsheet/CellType/String.cpp new file mode 100644 index 0000000000..ccda9947fe --- /dev/null +++ b/Userland/Applications/Spreadsheet/CellType/String.cpp @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "String.h" +#include "../Cell.h" +#include "../Spreadsheet.h" + +namespace Spreadsheet { + +StringCell::StringCell() + : CellType("String") +{ +} + +StringCell::~StringCell() +{ +} + +String StringCell::display(Cell& cell, const CellTypeMetadata& metadata) const +{ + auto string = cell.js_data().to_string_without_side_effects(); + if (metadata.length >= 0) + return string.substring(0, metadata.length); + + return string; +} + +JS::Value StringCell::js_value(Cell& cell, const CellTypeMetadata& metadata) const +{ + auto string = display(cell, metadata); + return JS::js_string(cell.sheet().interpreter().heap(), string); +} + +} diff --git a/Userland/Applications/Spreadsheet/CellType/String.h b/Userland/Applications/Spreadsheet/CellType/String.h new file mode 100644 index 0000000000..e4ba28eb24 --- /dev/null +++ b/Userland/Applications/Spreadsheet/CellType/String.h @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Type.h" + +namespace Spreadsheet { + +class StringCell : public CellType { + +public: + StringCell(); + virtual ~StringCell() override; + virtual String display(Cell&, const CellTypeMetadata&) const override; + virtual JS::Value js_value(Cell&, const CellTypeMetadata&) const override; +}; + +} diff --git a/Userland/Applications/Spreadsheet/CellType/Type.cpp b/Userland/Applications/Spreadsheet/CellType/Type.cpp new file mode 100644 index 0000000000..cd64e8f239 --- /dev/null +++ b/Userland/Applications/Spreadsheet/CellType/Type.cpp @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "Type.h" +#include "Date.h" +#include "Identity.h" +#include "Numeric.h" +#include "String.h" +#include <AK/HashMap.h> +#include <AK/OwnPtr.h> + +static HashMap<String, Spreadsheet::CellType*> s_cell_types; +static Spreadsheet::StringCell s_string_cell; +static Spreadsheet::NumericCell s_numeric_cell; +static Spreadsheet::IdentityCell s_identity_cell; +static Spreadsheet::DateCell s_date_cell; + +namespace Spreadsheet { + +const CellType* CellType::get_by_name(const StringView& name) +{ + return s_cell_types.get(name).value_or(nullptr); +} + +Vector<StringView> CellType::names() +{ + Vector<StringView> names; + for (auto& it : s_cell_types) + names.append(it.key); + return names; +} + +CellType::CellType(const StringView& name) + : m_name(name) +{ + ASSERT(!s_cell_types.contains(name)); + s_cell_types.set(name, this); +} + +} diff --git a/Userland/Applications/Spreadsheet/CellType/Type.h b/Userland/Applications/Spreadsheet/CellType/Type.h new file mode 100644 index 0000000000..0145a8fa8d --- /dev/null +++ b/Userland/Applications/Spreadsheet/CellType/Type.h @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "../ConditionalFormatting.h" +#include "../Forward.h" +#include <AK/Forward.h> +#include <AK/String.h> +#include <LibGfx/Color.h> +#include <LibGfx/TextAlignment.h> +#include <LibJS/Forward.h> + +namespace Spreadsheet { + +struct CellTypeMetadata { + int length { -1 }; + String format; + Gfx::TextAlignment alignment { Gfx::TextAlignment::CenterRight }; + Format static_format; +}; + +class CellType { +public: + static const CellType* get_by_name(const StringView&); + static Vector<StringView> names(); + + virtual String display(Cell&, const CellTypeMetadata&) const = 0; + virtual JS::Value js_value(Cell&, const CellTypeMetadata&) const = 0; + virtual ~CellType() { } + + const String& name() const { return m_name; } + +protected: + CellType(const StringView& name); + +private: + String m_name; +}; + +} diff --git a/Userland/Applications/Spreadsheet/CellTypeDialog.cpp b/Userland/Applications/Spreadsheet/CellTypeDialog.cpp new file mode 100644 index 0000000000..b94feab5e5 --- /dev/null +++ b/Userland/Applications/Spreadsheet/CellTypeDialog.cpp @@ -0,0 +1,483 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "CellTypeDialog.h" +#include "Cell.h" +#include "Spreadsheet.h" +#include <AK/StringBuilder.h> +#include <Applications/Spreadsheet/CondFormattingGML.h> +#include <Applications/Spreadsheet/CondFormattingViewGML.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/CheckBox.h> +#include <LibGUI/ColorInput.h> +#include <LibGUI/ComboBox.h> +#include <LibGUI/ItemListModel.h> +#include <LibGUI/JSSyntaxHighlighter.h> +#include <LibGUI/Label.h> +#include <LibGUI/ListView.h> +#include <LibGUI/SpinBox.h> +#include <LibGUI/TabWidget.h> +#include <LibGUI/TextEditor.h> +#include <LibGUI/Widget.h> +#include <LibGfx/FontDatabase.h> + +REGISTER_WIDGET(Spreadsheet, ConditionsView); + +namespace Spreadsheet { + +CellTypeDialog::CellTypeDialog(const Vector<Position>& positions, Sheet& sheet, GUI::Window* parent) + : GUI::Dialog(parent) +{ + ASSERT(!positions.is_empty()); + + StringBuilder builder; + + if (positions.size() == 1) + builder.appendff("Format cell {}{}", positions.first().column, positions.first().row); + else + builder.appendff("Format {} cells", positions.size()); + + set_title(builder.string_view()); + set_icon(parent->icon()); + resize(285, 360); + + auto& main_widget = set_main_widget<GUI::Widget>(); + main_widget.set_layout<GUI::VerticalBoxLayout>().set_margins({ 4, 4, 4, 4 }); + main_widget.set_fill_with_background_color(true); + + auto& tab_widget = main_widget.add<GUI::TabWidget>(); + setup_tabs(tab_widget, positions, sheet); + + auto& buttonbox = main_widget.add<GUI::Widget>(); + buttonbox.set_shrink_to_fit(true); + auto& button_layout = buttonbox.set_layout<GUI::HorizontalBoxLayout>(); + button_layout.set_spacing(10); + button_layout.add_spacer(); + auto& ok_button = buttonbox.add<GUI::Button>("OK"); + ok_button.set_fixed_width(80); + ok_button.on_click = [&](auto) { done(ExecOK); }; +} + +const Vector<String> g_horizontal_alignments { "Left", "Center", "Right" }; +const Vector<String> g_vertical_alignments { "Top", "Center", "Bottom" }; +Vector<String> g_types; + +constexpr static CellTypeDialog::VerticalAlignment vertical_alignment_from(Gfx::TextAlignment alignment) +{ + switch (alignment) { + case Gfx::TextAlignment::CenterRight: + case Gfx::TextAlignment::CenterLeft: + case Gfx::TextAlignment::Center: + return CellTypeDialog::VerticalAlignment::Center; + + case Gfx::TextAlignment::TopRight: + case Gfx::TextAlignment::TopLeft: + return CellTypeDialog::VerticalAlignment::Top; + + case Gfx::TextAlignment::BottomRight: + return CellTypeDialog::VerticalAlignment::Bottom; + } + + return CellTypeDialog::VerticalAlignment::Center; +} + +constexpr static CellTypeDialog::HorizontalAlignment horizontal_alignment_from(Gfx::TextAlignment alignment) +{ + switch (alignment) { + case Gfx::TextAlignment::Center: + return CellTypeDialog::HorizontalAlignment::Center; + + case Gfx::TextAlignment::CenterRight: + case Gfx::TextAlignment::TopRight: + case Gfx::TextAlignment::BottomRight: + return CellTypeDialog::HorizontalAlignment::Right; + + case Gfx::TextAlignment::TopLeft: + case Gfx::TextAlignment::CenterLeft: + return CellTypeDialog::HorizontalAlignment::Left; + } + + return CellTypeDialog::HorizontalAlignment::Right; +} + +void CellTypeDialog::setup_tabs(GUI::TabWidget& tabs, const Vector<Position>& positions, Sheet& sheet) +{ + g_types.clear(); + for (auto& type_name : CellType::names()) + g_types.append(type_name); + + Vector<Cell*> cells; + for (auto& position : positions) { + if (auto cell = sheet.at(position)) + cells.append(cell); + } + + if (cells.size() == 1) { + auto& cell = *cells.first(); + m_format = cell.type_metadata().format; + m_length = cell.type_metadata().length; + m_type = &cell.type(); + m_vertical_alignment = vertical_alignment_from(cell.type_metadata().alignment); + m_horizontal_alignment = horizontal_alignment_from(cell.type_metadata().alignment); + m_static_format = cell.type_metadata().static_format; + m_conditional_formats = cell.conditional_formats(); + } + + auto& type_tab = tabs.add_tab<GUI::Widget>("Type"); + type_tab.set_layout<GUI::HorizontalBoxLayout>().set_margins({ 4, 4, 4, 4 }); + { + auto& left_side = type_tab.add<GUI::Widget>(); + left_side.set_layout<GUI::VerticalBoxLayout>(); + auto& right_side = type_tab.add<GUI::Widget>(); + right_side.set_layout<GUI::VerticalBoxLayout>(); + right_side.set_fixed_width(170); + + auto& type_list = left_side.add<GUI::ListView>(); + type_list.set_model(*GUI::ItemListModel<String>::create(g_types)); + type_list.set_should_hide_unnecessary_scrollbars(true); + type_list.on_selection = [&](auto& index) { + if (!index.is_valid()) { + m_type = nullptr; + return; + } + + m_type = CellType::get_by_name(g_types.at(index.row())); + }; + + { + auto& checkbox = right_side.add<GUI::CheckBox>("Override max length"); + auto& spinbox = right_side.add<GUI::SpinBox>(); + checkbox.set_checked(m_length != -1); + spinbox.set_min(0); + spinbox.set_enabled(m_length != -1); + if (m_length > -1) + spinbox.set_value(m_length); + + checkbox.on_checked = [&](auto checked) { + spinbox.set_enabled(checked); + if (!checked) { + m_length = -1; + spinbox.set_value(0); + } + }; + spinbox.on_change = [&](auto value) { + m_length = value; + }; + } + { + auto& checkbox = right_side.add<GUI::CheckBox>("Override display format"); + auto& editor = right_side.add<GUI::TextEditor>(); + checkbox.set_checked(!m_format.is_empty()); + editor.set_should_hide_unnecessary_scrollbars(true); + editor.set_enabled(!m_format.is_empty()); + editor.set_text(m_format); + + checkbox.on_checked = [&](auto checked) { + editor.set_enabled(checked); + if (!checked) + m_format = String::empty(); + editor.set_text(m_format); + }; + editor.on_change = [&] { + m_format = editor.text(); + }; + } + } + + auto& alignment_tab = tabs.add_tab<GUI::Widget>("Alignment"); + alignment_tab.set_layout<GUI::VerticalBoxLayout>().set_margins({ 4, 4, 4, 4 }); + { + // FIXME: Frame? + // Horizontal alignment + { + auto& horizontal_alignment_selection_container = alignment_tab.add<GUI::Widget>(); + horizontal_alignment_selection_container.set_layout<GUI::HorizontalBoxLayout>(); + horizontal_alignment_selection_container.layout()->set_margins({ 0, 4, 0, 0 }); + horizontal_alignment_selection_container.set_fixed_height(22); + + auto& horizontal_alignment_label = horizontal_alignment_selection_container.add<GUI::Label>(); + horizontal_alignment_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + horizontal_alignment_label.set_text("Horizontal text alignment"); + + auto& horizontal_combobox = alignment_tab.add<GUI::ComboBox>(); + horizontal_combobox.set_only_allow_values_from_model(true); + horizontal_combobox.set_model(*GUI::ItemListModel<String>::create(g_horizontal_alignments)); + horizontal_combobox.set_selected_index((int)m_horizontal_alignment); + horizontal_combobox.on_change = [&](auto&, const GUI::ModelIndex& index) { + switch (index.row()) { + case 0: + m_horizontal_alignment = HorizontalAlignment::Left; + break; + case 1: + m_horizontal_alignment = HorizontalAlignment::Center; + break; + case 2: + m_horizontal_alignment = HorizontalAlignment::Right; + break; + default: + ASSERT_NOT_REACHED(); + } + }; + } + + // Vertical alignment + { + auto& vertical_alignment_container = alignment_tab.add<GUI::Widget>(); + vertical_alignment_container.set_layout<GUI::HorizontalBoxLayout>(); + vertical_alignment_container.layout()->set_margins({ 0, 4, 0, 0 }); + vertical_alignment_container.set_fixed_height(22); + + auto& vertical_alignment_label = vertical_alignment_container.add<GUI::Label>(); + vertical_alignment_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + vertical_alignment_label.set_text("Vertical text alignment"); + + auto& vertical_combobox = alignment_tab.add<GUI::ComboBox>(); + vertical_combobox.set_only_allow_values_from_model(true); + vertical_combobox.set_model(*GUI::ItemListModel<String>::create(g_vertical_alignments)); + vertical_combobox.set_selected_index((int)m_vertical_alignment); + vertical_combobox.on_change = [&](auto&, const GUI::ModelIndex& index) { + switch (index.row()) { + case 0: + m_vertical_alignment = VerticalAlignment::Top; + break; + case 1: + m_vertical_alignment = VerticalAlignment::Center; + break; + case 2: + m_vertical_alignment = VerticalAlignment::Bottom; + break; + default: + ASSERT_NOT_REACHED(); + } + }; + } + } + + auto& colors_tab = tabs.add_tab<GUI::Widget>("Color"); + colors_tab.set_layout<GUI::VerticalBoxLayout>().set_margins({ 4, 4, 4, 4 }); + { + // Static formatting + { + auto& static_formatting_container = colors_tab.add<GUI::Widget>(); + static_formatting_container.set_layout<GUI::VerticalBoxLayout>(); + static_formatting_container.set_shrink_to_fit(true); + + // Foreground + { + // FIXME: Somehow allow unsetting these. + auto& foreground_container = static_formatting_container.add<GUI::Widget>(); + foreground_container.set_layout<GUI::HorizontalBoxLayout>(); + foreground_container.layout()->set_margins({ 0, 4, 0, 0 }); + foreground_container.set_fixed_height(22); + + auto& foreground_label = foreground_container.add<GUI::Label>(); + foreground_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + foreground_label.set_text("Static foreground color"); + + auto& foreground_selector = foreground_container.add<GUI::ColorInput>(); + if (m_static_format.foreground_color.has_value()) + foreground_selector.set_color(m_static_format.foreground_color.value()); + foreground_selector.on_change = [&]() { + m_static_format.foreground_color = foreground_selector.color(); + }; + } + + // Background + { + // FIXME: Somehow allow unsetting these. + auto& background_container = static_formatting_container.add<GUI::Widget>(); + background_container.set_layout<GUI::HorizontalBoxLayout>(); + background_container.layout()->set_margins({ 0, 4, 0, 0 }); + background_container.set_fixed_height(22); + + auto& background_label = background_container.add<GUI::Label>(); + background_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + background_label.set_text("Static background color"); + + auto& background_selector = background_container.add<GUI::ColorInput>(); + if (m_static_format.background_color.has_value()) + background_selector.set_color(m_static_format.background_color.value()); + background_selector.on_change = [&]() { + m_static_format.background_color = background_selector.color(); + }; + } + } + } + + auto& conditional_fmt_tab = tabs.add_tab<GUI::Widget>("Conditional format"); + conditional_fmt_tab.load_from_gml(cond_fmt_gml); + { + auto& view = *conditional_fmt_tab.find_descendant_of_type_named<Spreadsheet::ConditionsView>("conditions_view"); + view.set_formats(&m_conditional_formats); + + auto& add_button = *conditional_fmt_tab.find_descendant_of_type_named<GUI::Button>("add_button"); + add_button.on_click = [&](auto) { + view.add_format(); + }; + + // FIXME: Disable this when empty. + auto& remove_button = *conditional_fmt_tab.find_descendant_of_type_named<GUI::Button>("remove_button"); + remove_button.on_click = [&](auto) { + view.remove_top(); + }; + } +} + +CellTypeMetadata CellTypeDialog::metadata() const +{ + CellTypeMetadata metadata; + metadata.format = m_format; + metadata.length = m_length; + metadata.static_format = m_static_format; + + switch (m_vertical_alignment) { + case VerticalAlignment::Top: + switch (m_horizontal_alignment) { + case HorizontalAlignment::Left: + metadata.alignment = Gfx::TextAlignment::TopLeft; + break; + case HorizontalAlignment::Center: + metadata.alignment = Gfx::TextAlignment::Center; // TopCenter? + break; + case HorizontalAlignment::Right: + metadata.alignment = Gfx::TextAlignment::TopRight; + break; + } + break; + case VerticalAlignment::Center: + switch (m_horizontal_alignment) { + case HorizontalAlignment::Left: + metadata.alignment = Gfx::TextAlignment::CenterLeft; + break; + case HorizontalAlignment::Center: + metadata.alignment = Gfx::TextAlignment::Center; + break; + case HorizontalAlignment::Right: + metadata.alignment = Gfx::TextAlignment::CenterRight; + break; + } + break; + case VerticalAlignment::Bottom: + switch (m_horizontal_alignment) { + case HorizontalAlignment::Left: + metadata.alignment = Gfx::TextAlignment::CenterLeft; // BottomLeft? + break; + case HorizontalAlignment::Center: + metadata.alignment = Gfx::TextAlignment::Center; + break; + case HorizontalAlignment::Right: + metadata.alignment = Gfx::TextAlignment::BottomRight; + break; + } + break; + } + + return metadata; +} + +ConditionView::ConditionView(ConditionalFormat& fmt) + : m_format(fmt) +{ + load_from_gml(cond_fmt_view_gml); + + auto& fg_input = *find_descendant_of_type_named<GUI::ColorInput>("foreground_input"); + auto& bg_input = *find_descendant_of_type_named<GUI::ColorInput>("background_input"); + auto& formula_editor = *find_descendant_of_type_named<GUI::TextEditor>("formula_editor"); + + if (m_format.foreground_color.has_value()) + fg_input.set_color(m_format.foreground_color.value()); + + if (m_format.background_color.has_value()) + bg_input.set_color(m_format.background_color.value()); + + formula_editor.set_text(m_format.condition); + + // FIXME: Allow unsetting these. + fg_input.on_change = [&] { + m_format.foreground_color = fg_input.color(); + }; + + bg_input.on_change = [&] { + m_format.background_color = bg_input.color(); + }; + + formula_editor.set_syntax_highlighter(make<GUI::JSSyntaxHighlighter>()); + formula_editor.set_should_hide_unnecessary_scrollbars(true); + formula_editor.set_font(&Gfx::FontDatabase::default_fixed_width_font()); + formula_editor.on_change = [&] { + m_format.condition = formula_editor.text(); + }; +} + +ConditionView::~ConditionView() +{ +} + +ConditionsView::ConditionsView() +{ + set_layout<GUI::VerticalBoxLayout>().set_spacing(2); +} + +void ConditionsView::set_formats(Vector<ConditionalFormat>* formats) +{ + ASSERT(!m_formats); + + m_formats = formats; + + for (auto& entry : *m_formats) + m_widgets.append(add<ConditionView>(entry)); +} + +void ConditionsView::add_format() +{ + ASSERT(m_formats); + + m_formats->empend(); + auto& last = m_formats->last(); + + m_widgets.append(add<ConditionView>(last)); + + update(); +} + +void ConditionsView::remove_top() +{ + ASSERT(m_formats); + + if (m_formats->is_empty()) + return; + + m_formats->take_last(); + m_widgets.take_last()->remove_from_parent(); + update(); +} + +ConditionsView::~ConditionsView() +{ +} + +} diff --git a/Userland/Applications/Spreadsheet/CellTypeDialog.h b/Userland/Applications/Spreadsheet/CellTypeDialog.h new file mode 100644 index 0000000000..ede19ebd51 --- /dev/null +++ b/Userland/Applications/Spreadsheet/CellTypeDialog.h @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "CellType/Type.h" +#include "ConditionalFormatting.h" +#include "Forward.h" +#include <LibGUI/Dialog.h> + +namespace Spreadsheet { + +class CellTypeDialog : public GUI::Dialog { + C_OBJECT(CellTypeDialog); + +public: + CellTypeMetadata metadata() const; + const CellType* type() const { return m_type; } + Vector<ConditionalFormat> conditional_formats() { return m_conditional_formats; } + + enum class HorizontalAlignment : int { + Left = 0, + Center, + Right, + }; + enum class VerticalAlignment : int { + Top = 0, + Center, + Bottom, + }; + +private: + CellTypeDialog(const Vector<Position>&, Sheet&, GUI::Window* parent = nullptr); + void setup_tabs(GUI::TabWidget&, const Vector<Position>&, Sheet&); + + const CellType* m_type { nullptr }; + + int m_length { -1 }; + String m_format; + HorizontalAlignment m_horizontal_alignment { HorizontalAlignment::Right }; + VerticalAlignment m_vertical_alignment { VerticalAlignment::Center }; + Format m_static_format; + Vector<ConditionalFormat> m_conditional_formats; +}; + +} diff --git a/Userland/Applications/Spreadsheet/CondFormatting.gml b/Userland/Applications/Spreadsheet/CondFormatting.gml new file mode 100644 index 0000000000..437b5a1c3d --- /dev/null +++ b/Userland/Applications/Spreadsheet/CondFormatting.gml @@ -0,0 +1,40 @@ +@GUI::Widget { + name: "main" + fill_with_background_color: true + + layout: @GUI::VerticalBoxLayout { + margins: [4, 4, 4, 4] + spacing: 4 + } + + @Spreadsheet::ConditionsView { + name: "conditions_view" + } + + @GUI::Widget { + shrink_to_fit: true + + layout: @GUI::HorizontalBoxLayout { + spacing: 10 + } + + @GUI::Widget { + } + + @GUI::Button { + name: "add_button" + text: "Add" + fixed_width: 70 + } + + @GUI::Button { + name: "remove_button" + text: "Remove" + fixed_width: 70 + } + + @GUI::Widget { + } + + } +} diff --git a/Userland/Applications/Spreadsheet/CondView.gml b/Userland/Applications/Spreadsheet/CondView.gml new file mode 100644 index 0000000000..d2161da89f --- /dev/null +++ b/Userland/Applications/Spreadsheet/CondView.gml @@ -0,0 +1,54 @@ +@GUI::Widget { + layout: @GUI::VerticalBoxLayout { + } + + @GUI::Widget { + shrink_to_fit: true + + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::Label { + text: "if..." + fixed_width: 40 + } + + @GUI::TextEditor { + name: "formula_editor" + fixed_height: 25 + tooltip: "Use 'value' to refer to the current cell's value" + } + } + + @GUI::Widget { + shrink_to_fit: true + + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::Label { + text: "Foreground..." + fixed_width: 150 + } + + @GUI::ColorInput { + name: "foreground_input" + } + } + + @GUI::Widget { + shrink_to_fit: true + + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::Label { + text: "Background..." + fixed_width: 150 + } + + @GUI::ColorInput { + name: "background_input" + } + } +} diff --git a/Userland/Applications/Spreadsheet/ConditionalFormatting.h b/Userland/Applications/Spreadsheet/ConditionalFormatting.h new file mode 100644 index 0000000000..8e32bc8046 --- /dev/null +++ b/Userland/Applications/Spreadsheet/ConditionalFormatting.h @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Forward.h" +#include <AK/String.h> +#include <LibGUI/ScrollableWidget.h> +#include <LibGfx/Color.h> + +namespace Spreadsheet { + +struct Format { + Optional<Color> foreground_color; + Optional<Color> background_color; +}; + +struct ConditionalFormat : public Format { + String condition; +}; + +class ConditionView : public GUI::Widget { + C_OBJECT(ConditionView) +public: + virtual ~ConditionView() override; + +private: + ConditionView(ConditionalFormat&); + + ConditionalFormat& m_format; +}; + +class ConditionsView : public GUI::Widget { + C_OBJECT(ConditionsView) +public: + virtual ~ConditionsView() override; + + void set_formats(Vector<ConditionalFormat>*); + + void add_format(); + void remove_top(); + +private: + ConditionsView(); + + Vector<ConditionalFormat>* m_formats { nullptr }; + NonnullRefPtrVector<GUI::Widget> m_widgets; +}; + +} diff --git a/Userland/Applications/Spreadsheet/Forward.h b/Userland/Applications/Spreadsheet/Forward.h new file mode 100644 index 0000000000..183c684972 --- /dev/null +++ b/Userland/Applications/Spreadsheet/Forward.h @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +namespace Spreadsheet { + +class ConditionView; +class Sheet; +class SheetGlobalObject; +class Workbook; +class WorkbookObject; +struct Cell; +struct ConditionalFormat; +struct Format; +struct Position; + +} diff --git a/Userland/Applications/Spreadsheet/HelpWindow.cpp b/Userland/Applications/Spreadsheet/HelpWindow.cpp new file mode 100644 index 0000000000..eecd0bbb45 --- /dev/null +++ b/Userland/Applications/Spreadsheet/HelpWindow.cpp @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "HelpWindow.h" +#include "SpreadsheetWidget.h" +#include <AK/LexicalPath.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Frame.h> +#include <LibGUI/ListView.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/Model.h> +#include <LibGUI/Splitter.h> +#include <LibMarkdown/Document.h> +#include <LibWeb/Layout/Node.h> +#include <LibWeb/OutOfProcessWebView.h> + +namespace Spreadsheet { + +class HelpListModel final : public GUI::Model { +public: + static NonnullRefPtr<HelpListModel> create() { return adopt(*new HelpListModel); } + + virtual ~HelpListModel() override { } + + virtual int row_count(const GUI::ModelIndex& = GUI::ModelIndex()) const override { return m_keys.size(); } + virtual int column_count(const GUI::ModelIndex& = GUI::ModelIndex()) const override { return 1; } + virtual void update() override { } + + virtual GUI::Variant data(const GUI::ModelIndex& index, GUI::ModelRole role = GUI::ModelRole::Display) const override + { + if (role == GUI::ModelRole::Display) { + return key(index); + } + + return {}; + } + + String key(const GUI::ModelIndex& index) const { return m_keys[index.row()]; } + + void set_from(const JsonObject& object) + { + m_keys.clear(); + object.for_each_member([this](auto& name, auto&) { + m_keys.append(name); + }); + did_update(); + } + +private: + HelpListModel() + { + } + + Vector<String> m_keys; +}; + +RefPtr<HelpWindow> HelpWindow::s_the { nullptr }; + +HelpWindow::HelpWindow(GUI::Window* parent) + : GUI::Window(parent) +{ + resize(530, 365); + set_title("Spreadsheet Functions Help"); + set_icon(Gfx::Bitmap::load_from_file("/res/icons/16x16/app-help.png")); + + auto& widget = set_main_widget<GUI::Widget>(); + widget.set_layout<GUI::VerticalBoxLayout>(); + widget.set_fill_with_background_color(true); + + auto& splitter = widget.add<GUI::HorizontalSplitter>(); + auto& left_frame = splitter.add<GUI::Frame>(); + left_frame.set_layout<GUI::VerticalBoxLayout>(); + left_frame.set_fixed_width(100); + m_listview = left_frame.add<GUI::ListView>(); + m_listview->set_activates_on_selection(true); + m_listview->set_model(HelpListModel::create()); + + m_webview = splitter.add<Web::OutOfProcessWebView>(); + m_webview->on_link_click = [this](auto& url, auto&, auto&&) { + ASSERT(url.protocol() == "spreadsheet"); + if (url.host() == "example") { + auto entry = LexicalPath(url.path()).basename(); + auto doc_option = m_docs.get(entry); + if (!doc_option.is_object()) { + GUI::MessageBox::show_error(this, String::formatted("No documentation entry found for '{}'", url.path())); + return; + } + auto& doc = doc_option.as_object(); + const auto& name = url.fragment(); + + auto example_data_value = doc.get_or("example_data", JsonObject {}); + if (!example_data_value.is_object()) { + GUI::MessageBox::show_error(this, String::formatted("No example data found for '{}'", url.path())); + return; + } + + auto& example_data = example_data_value.as_object(); + auto value = example_data.get(name); + if (!value.is_object()) { + GUI::MessageBox::show_error(this, String::formatted("Example '{}' not found for '{}'", name, url.path())); + return; + } + + auto window = GUI::Window::construct(this); + window->resize(size()); + window->set_icon(icon()); + window->set_title(String::formatted("Spreadsheet Help - Example {} for {}", name, entry)); + window->on_close = [window = window.ptr()] { window->remove_from_parent(); }; + + auto& widget = window->set_main_widget<SpreadsheetWidget>(NonnullRefPtrVector<Sheet> {}, false); + auto sheet = Sheet::from_json(value.as_object(), widget.workbook()); + if (!sheet) { + GUI::MessageBox::show_error(this, String::formatted("Corrupted example '{}' in '{}'", name, url.path())); + return; + } + + widget.add_sheet(sheet.release_nonnull()); + window->show(); + } else if (url.host() == "doc") { + auto entry = LexicalPath(url.path()).basename(); + m_webview->load(URL::create_with_data("text/html", render(entry))); + } else { + dbgln("Invalid spreadsheet action domain '{}'", url.host()); + } + }; + + m_listview->on_activation = [this](auto& index) { + if (!m_webview) + return; + + auto key = static_cast<HelpListModel*>(m_listview->model())->key(index); + m_webview->load(URL::create_with_data("text/html", render(key))); + }; +} + +String HelpWindow::render(const StringView& key) +{ + auto doc_option = m_docs.get(key); + ASSERT(doc_option.is_object()); + + auto& doc = doc_option.as_object(); + + auto name = doc.get("name").to_string(); + auto argc = doc.get("argc").to_u32(0); + auto argnames_value = doc.get("argnames"); + ASSERT(argnames_value.is_array()); + auto& argnames = argnames_value.as_array(); + + auto docstring = doc.get("doc").to_string(); + auto examples_value = doc.get_or("examples", JsonObject {}); + ASSERT(examples_value.is_object()); + auto& examples = examples_value.as_object(); + + StringBuilder markdown_builder; + + markdown_builder.append("# NAME\n`"); + markdown_builder.append(name); + markdown_builder.append("`\n\n"); + + markdown_builder.append("# ARGUMENTS\n"); + if (argc > 0) + markdown_builder.appendff("{} required argument(s):\n", argc); + else + markdown_builder.appendf("No required arguments.\n"); + + for (size_t i = 0; i < argc; ++i) + markdown_builder.appendff("- `{}`\n", argnames.at(i).to_string()); + + if (argc > 0) + markdown_builder.append("\n"); + + if ((size_t)argnames.size() > argc) { + auto opt_count = argnames.size() - argc; + markdown_builder.appendff("{} optional argument(s):\n", opt_count); + for (size_t i = argc; i < (size_t)argnames.size(); ++i) + markdown_builder.appendff("- `{}`\n", argnames.at(i).to_string()); + markdown_builder.append("\n"); + } + + markdown_builder.append("# DESCRIPTION\n"); + markdown_builder.append(docstring); + markdown_builder.append("\n\n"); + + if (!examples.is_empty()) { + markdown_builder.append("# EXAMPLES\n"); + examples.for_each_member([&](auto& text, auto& description_value) { + dbgln("- {}\n\n```js\n{}\n```\n", description_value.to_string(), text); + markdown_builder.appendff("- {}\n\n```js\n{}\n```\n", description_value.to_string(), text); + }); + } + + auto document = Markdown::Document::parse(markdown_builder.string_view()); + return document->render_to_html(); +} + +void HelpWindow::set_docs(JsonObject&& docs) +{ + m_docs = move(docs); + static_cast<HelpListModel*>(m_listview->model())->set_from(m_docs); + m_listview->update(); +} + +HelpWindow::~HelpWindow() +{ +} +} diff --git a/Userland/Applications/Spreadsheet/HelpWindow.h b/Userland/Applications/Spreadsheet/HelpWindow.h new file mode 100644 index 0000000000..1c02d90eed --- /dev/null +++ b/Userland/Applications/Spreadsheet/HelpWindow.h @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/JsonObject.h> +#include <LibGUI/Dialog.h> +#include <LibGUI/Widget.h> +#include <LibGUI/Window.h> +#include <LibWeb/OutOfProcessWebView.h> + +namespace Spreadsheet { + +class HelpWindow : public GUI::Window { + C_OBJECT(HelpWindow); + +public: + static NonnullRefPtr<HelpWindow> the(GUI::Window* window) + { + if (s_the) + return *s_the; + + return *(s_the = adopt(*new HelpWindow(window))); + } + + virtual ~HelpWindow() override; + + void set_docs(JsonObject&& docs); + +private: + static RefPtr<HelpWindow> s_the; + String render(const StringView& key); + HelpWindow(GUI::Window* parent = nullptr); + + JsonObject m_docs; + RefPtr<Web::OutOfProcessWebView> m_webview; + RefPtr<GUI::ListView> m_listview; +}; + +} diff --git a/Userland/Applications/Spreadsheet/JSIntegration.cpp b/Userland/Applications/Spreadsheet/JSIntegration.cpp new file mode 100644 index 0000000000..12a249593e --- /dev/null +++ b/Userland/Applications/Spreadsheet/JSIntegration.cpp @@ -0,0 +1,444 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "JSIntegration.h" +#include "Spreadsheet.h" +#include "Workbook.h" +#include <LibJS/Lexer.h> +#include <LibJS/Runtime/Error.h> +#include <LibJS/Runtime/GlobalObject.h> +#include <LibJS/Runtime/Object.h> +#include <LibJS/Runtime/Value.h> + +namespace Spreadsheet { + +Optional<FunctionAndArgumentIndex> get_function_and_argument_index(StringView source) +{ + JS::Lexer lexer { source }; + // Track <identifier> <OpenParen>'s, and how many complete expressions are inside the parenthesised expression. + Vector<size_t> state; + StringView last_name; + Vector<StringView> names; + size_t open_parens_since_last_commit = 0; + size_t open_curlies_and_brackets_since_last_commit = 0; + bool previous_was_identifier = false; + auto token = lexer.next(); + while (token.type() != JS::TokenType::Eof) { + switch (token.type()) { + case JS::TokenType::Identifier: + previous_was_identifier = true; + last_name = token.value(); + break; + case JS::TokenType::ParenOpen: + if (!previous_was_identifier) { + open_parens_since_last_commit++; + break; + } + previous_was_identifier = false; + state.append(0); + names.append(last_name); + break; + case JS::TokenType::ParenClose: + previous_was_identifier = false; + if (open_parens_since_last_commit == 0) { + state.take_last(); + names.take_last(); + break; + } + --open_parens_since_last_commit; + break; + case JS::TokenType::Comma: + previous_was_identifier = false; + if (open_parens_since_last_commit == 0 && open_curlies_and_brackets_since_last_commit == 0) { + state.last()++; + break; + } + break; + case JS::TokenType::BracketOpen: + previous_was_identifier = false; + open_curlies_and_brackets_since_last_commit++; + break; + case JS::TokenType::BracketClose: + previous_was_identifier = false; + if (open_curlies_and_brackets_since_last_commit > 0) + open_curlies_and_brackets_since_last_commit--; + break; + case JS::TokenType::CurlyOpen: + previous_was_identifier = false; + open_curlies_and_brackets_since_last_commit++; + break; + case JS::TokenType::CurlyClose: + previous_was_identifier = false; + if (open_curlies_and_brackets_since_last_commit > 0) + open_curlies_and_brackets_since_last_commit--; + break; + default: + previous_was_identifier = false; + break; + } + + token = lexer.next(); + } + if (!names.is_empty() && !state.is_empty()) + return FunctionAndArgumentIndex { names.last(), state.last() }; + return {}; +} + +SheetGlobalObject::SheetGlobalObject(Sheet& sheet) + : m_sheet(sheet) +{ +} + +SheetGlobalObject::~SheetGlobalObject() +{ +} + +JS::Value SheetGlobalObject::get(const JS::PropertyName& name, JS::Value receiver) const +{ + if (name.is_string()) { + if (name.as_string() == "value") { + if (auto cell = m_sheet.current_evaluated_cell()) + return cell->js_data(); + + return JS::js_undefined(); + } + if (auto pos = Sheet::parse_cell_name(name.as_string()); pos.has_value()) { + auto& cell = m_sheet.ensure(pos.value()); + cell.reference_from(m_sheet.current_evaluated_cell()); + return cell.typed_js_data(); + } + } + + return GlobalObject::get(name, receiver); +} + +bool SheetGlobalObject::put(const JS::PropertyName& name, JS::Value value, JS::Value receiver) +{ + if (name.is_string()) { + if (auto pos = Sheet::parse_cell_name(name.as_string()); pos.has_value()) { + auto& cell = m_sheet.ensure(pos.value()); + if (auto current = m_sheet.current_evaluated_cell()) + current->reference_from(&cell); + + cell.set_data(value); // FIXME: This produces un-savable state! + return true; + } + } + + return GlobalObject::put(name, value, receiver); +} + +void SheetGlobalObject::initialize() +{ + GlobalObject::initialize(); + define_native_function("get_real_cell_contents", get_real_cell_contents, 1); + define_native_function("set_real_cell_contents", set_real_cell_contents, 2); + define_native_function("parse_cell_name", parse_cell_name, 1); + define_native_function("current_cell_position", current_cell_position, 0); + define_native_function("column_arithmetic", column_arithmetic, 2); + define_native_function("column_index", column_index, 1); +} + +void SheetGlobalObject::visit_edges(Visitor& visitor) +{ + GlobalObject::visit_edges(visitor); + for (auto& it : m_sheet.cells()) { + if (it.value->exception()) + visitor.visit(it.value->exception()); + visitor.visit(it.value->evaluated_data()); + } +} + +JS_DEFINE_NATIVE_FUNCTION(SheetGlobalObject::get_real_cell_contents) +{ + auto* this_object = vm.this_value(global_object).to_object(global_object); + if (!this_object) + return JS::js_null(); + + if (StringView("SheetGlobalObject") != this_object->class_name()) { + vm.throw_exception<JS::TypeError>(global_object, JS::ErrorType::NotA, "SheetGlobalObject"); + return {}; + } + + auto sheet_object = static_cast<SheetGlobalObject*>(this_object); + + if (vm.argument_count() != 1) { + vm.throw_exception<JS::TypeError>(global_object, "Expected exactly one argument to get_real_cell_contents()"); + return {}; + } + + auto name_value = vm.argument(0); + if (!name_value.is_string()) { + vm.throw_exception<JS::TypeError>(global_object, "Expected a String argument to get_real_cell_contents()"); + return {}; + } + auto position = Sheet::parse_cell_name(name_value.as_string().string()); + if (!position.has_value()) { + vm.throw_exception<JS::TypeError>(global_object, "Invalid cell name"); + return {}; + } + + const auto* cell = sheet_object->m_sheet.at(position.value()); + if (!cell) + return JS::js_undefined(); + + if (cell->kind() == Spreadsheet::Cell::Kind::Formula) + return JS::js_string(vm.heap(), String::formatted("={}", cell->data())); + + return JS::js_string(vm.heap(), cell->data()); +} + +JS_DEFINE_NATIVE_FUNCTION(SheetGlobalObject::set_real_cell_contents) +{ + auto* this_object = vm.this_value(global_object).to_object(global_object); + if (!this_object) + return JS::js_null(); + + if (StringView("SheetGlobalObject") != this_object->class_name()) { + vm.throw_exception<JS::TypeError>(global_object, JS::ErrorType::NotA, "SheetGlobalObject"); + return {}; + } + + auto sheet_object = static_cast<SheetGlobalObject*>(this_object); + + if (vm.argument_count() != 2) { + vm.throw_exception<JS::TypeError>(global_object, "Expected exactly two arguments to set_real_cell_contents()"); + return {}; + } + + auto name_value = vm.argument(0); + if (!name_value.is_string()) { + vm.throw_exception<JS::TypeError>(global_object, "Expected the first argument of set_real_cell_contents() to be a String"); + return {}; + } + auto position = Sheet::parse_cell_name(name_value.as_string().string()); + if (!position.has_value()) { + vm.throw_exception<JS::TypeError>(global_object, "Invalid cell name"); + return {}; + } + + auto new_contents_value = vm.argument(1); + if (!new_contents_value.is_string()) { + vm.throw_exception<JS::TypeError>(global_object, "Expected the second argument of set_real_cell_contents() to be a String"); + return {}; + } + + auto& cell = sheet_object->m_sheet.ensure(position.value()); + auto& new_contents = new_contents_value.as_string().string(); + cell.set_data(new_contents); + return JS::js_null(); +} + +JS_DEFINE_NATIVE_FUNCTION(SheetGlobalObject::parse_cell_name) +{ + if (vm.argument_count() != 1) { + vm.throw_exception<JS::TypeError>(global_object, "Expected exactly one argument to parse_cell_name()"); + return {}; + } + auto name_value = vm.argument(0); + if (!name_value.is_string()) { + vm.throw_exception<JS::TypeError>(global_object, "Expected a String argument to parse_cell_name()"); + return {}; + } + auto position = Sheet::parse_cell_name(name_value.as_string().string()); + if (!position.has_value()) + return JS::js_undefined(); + + auto object = JS::Object::create_empty(global_object); + object->put("column", JS::js_string(vm, position.value().column)); + object->put("row", JS::Value((unsigned)position.value().row)); + + return object; +} + +JS_DEFINE_NATIVE_FUNCTION(SheetGlobalObject::current_cell_position) +{ + if (vm.argument_count() != 0) { + vm.throw_exception<JS::TypeError>(global_object, "Expected no arguments to current_cell_position()"); + return {}; + } + + auto* this_object = vm.this_value(global_object).to_object(global_object); + if (!this_object) + return JS::js_null(); + + if (StringView("SheetGlobalObject") != this_object->class_name()) { + vm.throw_exception<JS::TypeError>(global_object, JS::ErrorType::NotA, "SheetGlobalObject"); + return {}; + } + + auto sheet_object = static_cast<SheetGlobalObject*>(this_object); + auto* current_cell = sheet_object->m_sheet.current_evaluated_cell(); + if (!current_cell) + return JS::js_null(); + + auto position = current_cell->position(); + + auto object = JS::Object::create_empty(global_object); + object->put("column", JS::js_string(vm, position.column)); + object->put("row", JS::Value((unsigned)position.row)); + + return object; +} + +JS_DEFINE_NATIVE_FUNCTION(SheetGlobalObject::column_index) +{ + if (vm.argument_count() != 1) { + vm.throw_exception<JS::TypeError>(global_object, "Expected exactly one argument to column_index()"); + return {}; + } + + auto column_name = vm.argument(0); + if (!column_name.is_string()) { + vm.throw_exception<JS::TypeError>(global_object, JS::ErrorType::NotA, "String"); + return {}; + } + + auto& column_name_str = column_name.as_string().string(); + + auto* this_object = vm.this_value(global_object).to_object(global_object); + if (!this_object) + return JS::js_null(); + + if (StringView("SheetGlobalObject") != this_object->class_name()) { + vm.throw_exception<JS::TypeError>(global_object, JS::ErrorType::NotA, "SheetGlobalObject"); + return {}; + } + + auto sheet_object = static_cast<SheetGlobalObject*>(this_object); + auto& sheet = sheet_object->m_sheet; + auto column_index = sheet.column_index(column_name_str); + if (!column_index.has_value()) { + vm.throw_exception(global_object, JS::TypeError::create(global_object, String::formatted("'{}' is not a valid column", column_name_str))); + return {}; + } + + return JS::Value((i32)column_index.value()); +} + +JS_DEFINE_NATIVE_FUNCTION(SheetGlobalObject::column_arithmetic) +{ + if (vm.argument_count() != 2) { + vm.throw_exception<JS::TypeError>(global_object, "Expected exactly two arguments to column_arithmetic()"); + return {}; + } + + auto column_name = vm.argument(0); + if (!column_name.is_string()) { + vm.throw_exception<JS::TypeError>(global_object, JS::ErrorType::NotA, "String"); + return {}; + } + + auto& column_name_str = column_name.as_string().string(); + + auto offset = vm.argument(1).to_number(global_object); + if (!offset.is_number()) + return {}; + + auto offset_number = offset.as_i32(); + + auto* this_object = vm.this_value(global_object).to_object(global_object); + if (!this_object) + return JS::js_null(); + + if (StringView("SheetGlobalObject") != this_object->class_name()) { + vm.throw_exception<JS::TypeError>(global_object, JS::ErrorType::NotA, "SheetGlobalObject"); + return {}; + } + + auto sheet_object = static_cast<SheetGlobalObject*>(this_object); + auto& sheet = sheet_object->m_sheet; + auto new_column = sheet.column_arithmetic(column_name_str, offset_number); + if (!new_column.has_value()) { + vm.throw_exception(global_object, JS::TypeError::create(global_object, String::formatted("'{}' is not a valid column", column_name_str))); + return {}; + } + + return JS::js_string(vm, new_column.release_value()); +} + +WorkbookObject::WorkbookObject(Workbook& workbook) + : JS::Object(*JS::Object::create_empty(workbook.global_object())) + , m_workbook(workbook) +{ +} + +WorkbookObject::~WorkbookObject() +{ +} + +void WorkbookObject::initialize(JS::GlobalObject& global_object) +{ + Object::initialize(global_object); + define_native_function("sheet", sheet, 1); +} + +void WorkbookObject::visit_edges(Visitor& visitor) +{ + Base::visit_edges(visitor); + for (auto& sheet : m_workbook.sheets()) + visitor.visit(&sheet.global_object()); +} + +JS_DEFINE_NATIVE_FUNCTION(WorkbookObject::sheet) +{ + if (vm.argument_count() != 1) { + vm.throw_exception<JS::TypeError>(global_object, "Expected exactly one argument to sheet()"); + return {}; + } + auto name_value = vm.argument(0); + if (!name_value.is_string() && !name_value.is_number()) { + vm.throw_exception<JS::TypeError>(global_object, "Expected a String or Number argument to sheet()"); + return {}; + } + + auto* this_object = vm.this_value(global_object).to_object(global_object); + if (!this_object) + return {}; + + if (!is<WorkbookObject>(this_object)) { + vm.throw_exception<JS::TypeError>(global_object, JS::ErrorType::NotA, "WorkbookObject"); + return {}; + } + + auto& workbook = static_cast<WorkbookObject*>(this_object)->m_workbook; + + if (name_value.is_string()) { + auto& name = name_value.as_string().string(); + for (auto& sheet : workbook.sheets()) { + if (sheet.name() == name) + return JS::Value(&sheet.global_object()); + } + } else { + auto index = name_value.as_size_t(); + if (index < workbook.sheets().size()) + return JS::Value(&workbook.sheets()[index].global_object()); + } + + return JS::js_undefined(); +} + +} diff --git a/Userland/Applications/Spreadsheet/JSIntegration.h b/Userland/Applications/Spreadsheet/JSIntegration.h new file mode 100644 index 0000000000..88f46d929d --- /dev/null +++ b/Userland/Applications/Spreadsheet/JSIntegration.h @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Forward.h" +#include <LibJS/Forward.h> +#include <LibJS/Runtime/GlobalObject.h> + +namespace Spreadsheet { + +struct FunctionAndArgumentIndex { + String function_name; + size_t argument_index { 0 }; +}; +Optional<FunctionAndArgumentIndex> get_function_and_argument_index(StringView source); + +class SheetGlobalObject final : public JS::GlobalObject { + JS_OBJECT(SheetGlobalObject, JS::GlobalObject); + +public: + SheetGlobalObject(Sheet&); + + virtual ~SheetGlobalObject() override; + + virtual JS::Value get(const JS::PropertyName&, JS::Value receiver = {}) const override; + virtual bool put(const JS::PropertyName&, JS::Value value, JS::Value receiver = {}) override; + virtual void initialize() override; + + JS_DECLARE_NATIVE_FUNCTION(get_real_cell_contents); + JS_DECLARE_NATIVE_FUNCTION(set_real_cell_contents); + JS_DECLARE_NATIVE_FUNCTION(parse_cell_name); + JS_DECLARE_NATIVE_FUNCTION(current_cell_position); + JS_DECLARE_NATIVE_FUNCTION(column_index); + JS_DECLARE_NATIVE_FUNCTION(column_arithmetic); + +private: + virtual void visit_edges(Visitor&) override; + Sheet& m_sheet; +}; + +class WorkbookObject final : public JS::Object { + JS_OBJECT(WorkbookObject, JS::Object); + +public: + WorkbookObject(Workbook&); + + virtual ~WorkbookObject() override; + + virtual void initialize(JS::GlobalObject&) override; + + JS_DECLARE_NATIVE_FUNCTION(sheet); + +private: + virtual void visit_edges(Visitor&) override; + Workbook& m_workbook; +}; + +} diff --git a/Userland/Applications/Spreadsheet/Position.h b/Userland/Applications/Spreadsheet/Position.h new file mode 100644 index 0000000000..af7d7f70a9 --- /dev/null +++ b/Userland/Applications/Spreadsheet/Position.h @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/String.h> +#include <AK/Types.h> +#include <AK/URL.h> + +namespace Spreadsheet { + +struct Position { + String column; + size_t row { 0 }; + + bool operator==(const Position& other) const + { + return row == other.row && column == other.column; + } + + bool operator!=(const Position& other) const + { + return !(other == *this); + } + + URL to_url() const + { + URL url; + url.set_protocol("spreadsheet"); + url.set_host("cell"); + url.set_path(String::formatted("/{}", getpid())); + url.set_fragment(String::formatted("{}{}", column, row)); + return url; + } +}; + +} diff --git a/Userland/Applications/Spreadsheet/Readers/CSV.h b/Userland/Applications/Spreadsheet/Readers/CSV.h new file mode 100644 index 0000000000..866ae67141 --- /dev/null +++ b/Userland/Applications/Spreadsheet/Readers/CSV.h @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "XSV.h" +#include <AK/Forward.h> +#include <AK/StringView.h> + +namespace Reader { + +class CSV : public XSV { +public: + CSV(StringView source, ParserBehaviour behaviours = default_behaviours()) + : XSV(source, { ",", "\"", ParserTraits::Repeat }, behaviours) + { + } +}; + +} diff --git a/Userland/Applications/Spreadsheet/Readers/Test/TestXSV.cpp b/Userland/Applications/Spreadsheet/Readers/Test/TestXSV.cpp new file mode 100644 index 0000000000..b80093d556 --- /dev/null +++ b/Userland/Applications/Spreadsheet/Readers/Test/TestXSV.cpp @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include <AK/TestSuite.h> + +#include "../CSV.h" +#include "../XSV.h" +#include <LibCore/File.h> + +TEST_CASE(should_parse_valid_data) +{ + { + auto data = R"~~~(Foo, Bar, Baz + 1, 2, 3 + 4, 5, 6 + """x", y"z, 9)~~~"; + auto csv = Reader::CSV { data, Reader::default_behaviours() | Reader::ParserBehaviour::ReadHeaders | Reader::ParserBehaviour::TrimLeadingFieldSpaces }; + EXPECT(!csv.has_error()); + + EXPECT_EQ(csv[0]["Foo"], "1"); + EXPECT_EQ(csv[2]["Foo"], "\"x"); + EXPECT_EQ(csv[2]["Bar"], "y\"z"); + } + + { + auto data = R"~~~(Foo, Bar, Baz + 1 , 2, 3 + 4, "5 " , 6 + """x", y"z, 9 )~~~"; + auto csv = Reader::CSV { data, Reader::default_behaviours() | Reader::ParserBehaviour::ReadHeaders | Reader::ParserBehaviour::TrimLeadingFieldSpaces | Reader::ParserBehaviour::TrimTrailingFieldSpaces }; + EXPECT(!csv.has_error()); + + EXPECT_EQ(csv[0]["Foo"], "1"); + EXPECT_EQ(csv[1]["Bar"], "5 "); + EXPECT_EQ(csv[2]["Foo"], "\"x"); + EXPECT_EQ(csv[2]["Baz"], "9"); + } +} + +TEST_CASE(should_fail_nicely) +{ + { + auto data = R"~~~(Foo, Bar, Baz + x, y)~~~"; + auto csv = Reader::CSV { data, Reader::default_behaviours() | Reader::ParserBehaviour::ReadHeaders | Reader::ParserBehaviour::TrimLeadingFieldSpaces }; + EXPECT(csv.has_error()); + EXPECT_EQ(csv.error(), Reader::ReadError::NonConformingColumnCount); + } + + { + auto data = R"~~~(Foo, Bar, Baz + x, y, "z)~~~"; + auto csv = Reader::CSV { data, Reader::default_behaviours() | Reader::ParserBehaviour::ReadHeaders | Reader::ParserBehaviour::TrimLeadingFieldSpaces }; + EXPECT(csv.has_error()); + EXPECT_EQ(csv.error(), Reader::ReadError::QuoteFailure); + } +} + +TEST_CASE(should_iterate_rows) +{ + auto data = R"~~~(Foo, Bar, Baz + 1, 2, 3 + 4, 5, 6 + """x", y"z, 9)~~~"; + auto csv = Reader::CSV { data, Reader::default_behaviours() | Reader::ParserBehaviour::ReadHeaders | Reader::ParserBehaviour::TrimLeadingFieldSpaces }; + EXPECT(!csv.has_error()); + + bool ran = false; + for (auto row : csv) + ran = !row[0].is_empty(); + + EXPECT(ran); +} + +BENCHMARK_CASE(fairly_big_data) +{ + auto file_or_error = Core::File::open(__FILE__ ".data", Core::IODevice::OpenMode::ReadOnly); + EXPECT_EQ_FORCE(file_or_error.is_error(), false); + + auto data = file_or_error.value()->read_all(); + auto csv = Reader::CSV { data, Reader::default_behaviours() | Reader::ParserBehaviour::ReadHeaders }; + + EXPECT(!csv.has_error()); + EXPECT_EQ(csv.size(), 100000u); +} + +TEST_MAIN(XSV) diff --git a/Userland/Applications/Spreadsheet/Readers/XSV.cpp b/Userland/Applications/Spreadsheet/Readers/XSV.cpp new file mode 100644 index 0000000000..99a61b0abc --- /dev/null +++ b/Userland/Applications/Spreadsheet/Readers/XSV.cpp @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "XSV.h" +#include <AK/StringBuilder.h> + +namespace Reader { + +ParserBehaviour operator&(ParserBehaviour left, ParserBehaviour right) +{ + return static_cast<ParserBehaviour>(static_cast<u32>(left) & static_cast<u32>(right)); +} + +ParserBehaviour operator|(ParserBehaviour left, ParserBehaviour right) +{ + return static_cast<ParserBehaviour>(static_cast<u32>(left) | static_cast<u32>(right)); +} + +void XSV::set_error(ReadError error) +{ + if (m_error == ReadError::None) + m_error = error; +} + +Vector<String> XSV::headers() const +{ + Vector<String> headers; + for (auto& field : m_names) + headers.append(field.is_string_view ? field.as_string_view : field.as_string.view()); + + return headers; +} + +void XSV::parse() +{ + if ((m_behaviours & ParserBehaviour::ReadHeaders) != ParserBehaviour::None) + read_headers(); + + while (!has_error() && !m_lexer.is_eof()) + m_rows.append(read_row()); + + if (!m_lexer.is_eof()) + set_error(ReadError::DataPastLogicalEnd); +} + +void XSV::read_headers() +{ + if (!m_names.is_empty()) { + set_error(ReadError::InternalError); + m_names.clear(); + } + + m_names = read_row(true); +} + +Vector<XSV::Field> XSV::read_row(bool header_row) +{ + Vector<Field> row; + bool first = true; + while (!(m_lexer.is_eof() || m_lexer.next_is('\n') || m_lexer.next_is("\r\n")) && (first || m_lexer.consume_specific(m_traits.separator))) { + first = false; + row.append(read_one_field()); + } + + if (!m_lexer.is_eof()) { + auto crlf_ok = m_lexer.consume_specific("\r\n"); + if (!crlf_ok) { + auto lf_ok = m_lexer.consume_specific('\n'); + if (!lf_ok) + set_error(ReadError::DataPastLogicalEnd); + } + } + + if (!header_row && (m_behaviours & ParserBehaviour::ReadHeaders) != ParserBehaviour::None && row.size() != m_names.size()) + set_error(ReadError::NonConformingColumnCount); + + return row; +} + +XSV::Field XSV::read_one_field() +{ + if ((m_behaviours & ParserBehaviour::TrimLeadingFieldSpaces) != ParserBehaviour::None) + m_lexer.consume_while(is_any_of(" \t\v")); + + bool is_quoted = false; + Field field; + if (m_lexer.next_is(m_traits.quote.view())) { + is_quoted = true; + field = read_one_quoted_field(); + } else { + field = read_one_unquoted_field(); + } + + if ((m_behaviours & ParserBehaviour::TrimTrailingFieldSpaces) != ParserBehaviour::None) { + m_lexer.consume_while(is_any_of(" \t\v")); + + if (!is_quoted) { + // Also have to trim trailing spaces from unquoted fields. + StringView view; + if (field.is_string_view) + view = field.as_string_view; + else + view = field.as_string; + + if (!view.is_empty()) { + ssize_t i = view.length() - 1; + for (; i >= 0; --i) { + if (!view.substring_view(i, 1).is_one_of(" ", "\t", "\v")) + break; + } + view = view.substring_view(0, i + 1); + } + + if (field.is_string_view) + field.as_string_view = view; + else + field.as_string = field.as_string.substring(0, view.length()); + } + } + + return field; +} + +XSV::Field XSV::read_one_quoted_field() +{ + if (!m_lexer.consume_specific(m_traits.quote)) + set_error(ReadError::InternalError); + + size_t start = m_lexer.tell(), end = start; + bool is_copy = false; + StringBuilder builder; + auto allow_newlines = (m_behaviours & ParserBehaviour::AllowNewlinesInFields) != ParserBehaviour::None; + + for (; !m_lexer.is_eof();) { + char ch; + switch (m_traits.quote_escape) { + case ParserTraits::Backslash: + if (m_lexer.consume_specific('\\') && m_lexer.consume_specific(m_traits.quote)) { + // If there is an escaped quote, we have no choice but to make a copy. + if (!is_copy) { + is_copy = true; + builder.append(m_source.substring_view(start, end - start)); + } + builder.append(m_traits.quote); + end = m_lexer.tell(); + continue; + } + break; + case ParserTraits::Repeat: + if (m_lexer.consume_specific(m_traits.quote)) { + if (m_lexer.consume_specific(m_traits.quote)) { + // If there is an escaped quote, we have no choice but to make a copy. + if (!is_copy) { + is_copy = true; + builder.append(m_source.substring_view(start, end - start)); + } + builder.append(m_traits.quote); + end = m_lexer.tell(); + continue; + } + for (size_t i = 0; i < m_traits.quote.length(); ++i) + m_lexer.retreat(); + goto end; + } + break; + } + + if (m_lexer.next_is(m_traits.quote.view())) + goto end; + + if (!allow_newlines) { + if (m_lexer.next_is('\n') || m_lexer.next_is("\r\n")) + goto end; + } + + ch = m_lexer.consume(); + if (is_copy) + builder.append(ch); + end = m_lexer.tell(); + continue; + + end: + break; + } + + if (!m_lexer.consume_specific(m_traits.quote)) + set_error(ReadError::QuoteFailure); + + if (is_copy) + return { {}, builder.to_string(), false }; + + return { m_source.substring_view(start, end - start), {}, true }; +} + +XSV::Field XSV::read_one_unquoted_field() +{ + size_t start = m_lexer.tell(), end = start; + bool allow_quote_in_field = (m_behaviours & ParserBehaviour::QuoteOnlyInFieldStart) != ParserBehaviour::None; + + for (; !m_lexer.is_eof();) { + if (m_lexer.next_is(m_traits.separator.view())) + break; + + if (m_lexer.next_is("\r\n") || m_lexer.next_is("\n")) + break; + + if (m_lexer.consume_specific(m_traits.quote)) { + if (!allow_quote_in_field) + set_error(ReadError::QuoteFailure); + end = m_lexer.tell(); + continue; + } + + m_lexer.consume(); + end = m_lexer.tell(); + } + + return { m_source.substring_view(start, end - start), {}, true }; +} + +StringView XSV::Row::operator[](StringView name) const +{ + ASSERT(!m_xsv.m_names.is_empty()); + auto it = m_xsv.m_names.find_if([&](const auto& entry) { return name == entry; }); + ASSERT(!it.is_end()); + + return (*this)[it.index()]; +} + +StringView XSV::Row::operator[](size_t column) const +{ + auto& field = m_xsv.m_rows[m_index][column]; + if (field.is_string_view) + return field.as_string_view; + return field.as_string; +} + +const XSV::Row XSV::operator[](size_t index) const +{ + return const_cast<XSV&>(*this)[index]; +} + +XSV::Row XSV::operator[](size_t index) +{ + ASSERT(m_rows.size() > index); + return Row { *this, index }; +} + +} diff --git a/Userland/Applications/Spreadsheet/Readers/XSV.h b/Userland/Applications/Spreadsheet/Readers/XSV.h new file mode 100644 index 0000000000..0b32ca767d --- /dev/null +++ b/Userland/Applications/Spreadsheet/Readers/XSV.h @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/GenericLexer.h> +#include <AK/String.h> +#include <AK/StringView.h> +#include <AK/Types.h> +#include <AK/Vector.h> + +namespace Reader { + +enum class ParserBehaviour : u32 { + None = 0, + ReadHeaders = 1, + AllowNewlinesInFields = ReadHeaders << 1, + TrimLeadingFieldSpaces = ReadHeaders << 2, + TrimTrailingFieldSpaces = ReadHeaders << 3, + QuoteOnlyInFieldStart = ReadHeaders << 4, +}; + +ParserBehaviour operator&(ParserBehaviour left, ParserBehaviour right); +ParserBehaviour operator|(ParserBehaviour left, ParserBehaviour right); + +struct ParserTraits { + String separator; + String quote { "\"" }; + enum { + Repeat, + Backslash, + } quote_escape { Repeat }; +}; + +#define ENUMERATE_READ_ERRORS() \ + E(None, "No errors") \ + E(NonConformingColumnCount, "Header count does not match given column count") \ + E(QuoteFailure, "Quoting failure") \ + E(InternalError, "Internal error") \ + E(DataPastLogicalEnd, "Exrta data past the logical end of the rows") + +enum class ReadError { +#define E(name, _) name, + ENUMERATE_READ_ERRORS() +#undef E +}; + +inline constexpr ParserBehaviour default_behaviours() +{ + return ParserBehaviour::QuoteOnlyInFieldStart; +} + +class XSV { +public: + XSV(StringView source, const ParserTraits& traits, ParserBehaviour behaviours = default_behaviours()) + : m_source(source) + , m_lexer(m_source) + , m_traits(traits) + , m_behaviours(behaviours) + { + parse(); + } + + virtual ~XSV() { } + + bool has_error() const { return m_error != ReadError::None; } + ReadError error() const { return m_error; } + String error_string() const + { + switch (m_error) { +#define E(x, y) \ + case ReadError::x: \ + return y; + + ENUMERATE_READ_ERRORS(); +#undef E + } + ASSERT_NOT_REACHED(); + } + + size_t size() const { return m_rows.size(); } + Vector<String> headers() const; + + class Row { + public: + explicit Row(XSV& xsv, size_t index) + : m_xsv(xsv) + , m_index(index) + { + } + + StringView operator[](StringView name) const; + StringView operator[](size_t column) const; + + size_t index() const { return m_index; } + + // FIXME: Implement begin() and end(), keeping `Field' out of the API. + + private: + XSV& m_xsv; + size_t m_index { 0 }; + }; + + template<bool const_> + class RowIterator { + public: + explicit RowIterator(const XSV& xsv, size_t init_index = 0) requires(const_) + : m_xsv(const_cast<XSV&>(xsv)) + , m_index(init_index) + { + } + + explicit RowIterator(XSV& xsv, size_t init_index = 0) requires(!const_) + : m_xsv(xsv) + , m_index(init_index) + { + } + + Row operator*() const { return Row { m_xsv, m_index }; } + Row operator*() requires(!const_) { return Row { m_xsv, m_index }; } + + RowIterator& operator++() + { + ++m_index; + return *this; + } + + bool is_end() const { return m_index == m_xsv.m_rows.size(); } + bool operator==(const RowIterator& other) const + { + return m_index == other.m_index && &m_xsv == &other.m_xsv; + } + bool operator==(const RowIterator<!const_>& other) const + { + return m_index == other.m_index && &m_xsv == &other.m_xsv; + } + + private: + XSV& m_xsv; + size_t m_index { 0 }; + }; + + const Row operator[](size_t index) const; + Row operator[](size_t index); + + auto begin() { return RowIterator<false>(*this); } + auto end() { return RowIterator<false>(*this, m_rows.size()); } + + auto begin() const { return RowIterator<true>(*this); } + auto end() const { return RowIterator<true>(*this, m_rows.size()); } + + using ConstIterator = RowIterator<true>; + using Iterator = RowIterator<false>; + +private: + struct Field { + StringView as_string_view; + String as_string; // This member only used if the parser couldn't use the original source verbatim. + bool is_string_view { true }; + + bool operator==(StringView other) const + { + if (is_string_view) + return other == as_string_view; + return as_string == other; + } + }; + void set_error(ReadError error); + void parse(); + void read_headers(); + Vector<Field> read_row(bool header_row = false); + Field read_one_field(); + Field read_one_quoted_field(); + Field read_one_unquoted_field(); + + StringView m_source; + GenericLexer m_lexer; + const ParserTraits& m_traits; + ParserBehaviour m_behaviours; + Vector<Field> m_names; + Vector<Vector<Field>> m_rows; + ReadError m_error { ReadError::None }; +}; + +} diff --git a/Userland/Applications/Spreadsheet/Spreadsheet.cpp b/Userland/Applications/Spreadsheet/Spreadsheet.cpp new file mode 100644 index 0000000000..0d20b22fdd --- /dev/null +++ b/Userland/Applications/Spreadsheet/Spreadsheet.cpp @@ -0,0 +1,734 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "Spreadsheet.h" +#include "JSIntegration.h" +#include "Workbook.h" +#include <AK/ByteBuffer.h> +#include <AK/GenericLexer.h> +#include <AK/JsonArray.h> +#include <AK/JsonObject.h> +#include <AK/JsonParser.h> +#include <AK/ScopeGuard.h> +#include <AK/TemporaryChange.h> +#include <AK/URL.h> +#include <LibCore/File.h> +#include <LibJS/Parser.h> +#include <LibJS/Runtime/Function.h> +#include <ctype.h> + +//#define COPY_DEBUG + +namespace Spreadsheet { + +Sheet::Sheet(const StringView& name, Workbook& workbook) + : Sheet(workbook) +{ + m_name = name; + + for (size_t i = 0; i < default_row_count; ++i) + add_row(); + + for (size_t i = 0; i < default_column_count; ++i) + add_column(); +} + +Sheet::Sheet(Workbook& workbook) + : m_workbook(workbook) +{ + JS::DeferGC defer_gc(m_workbook.interpreter().heap()); + m_global_object = m_workbook.interpreter().heap().allocate_without_global_object<SheetGlobalObject>(*this); + global_object().initialize(); + global_object().put("workbook", m_workbook.workbook_object()); + global_object().put("thisSheet", &global_object()); // Self-reference is unfortunate, but required. + + // Sadly, these have to be evaluated once per sheet. + auto file_or_error = Core::File::open("/res/js/Spreadsheet/runtime.js", Core::IODevice::OpenMode::ReadOnly); + if (!file_or_error.is_error()) { + auto buffer = file_or_error.value()->read_all(); + JS::Parser parser { JS::Lexer(buffer) }; + if (parser.has_errors()) { + warnln("Spreadsheet: Failed to parse runtime code"); + parser.print_errors(); + } else { + interpreter().run(global_object(), parser.parse_program()); + if (auto exc = interpreter().exception()) { + warnln("Spreadsheet: Failed to run runtime code: "); + for (auto& t : exc->trace()) + warnln("{}", t); + interpreter().vm().clear_exception(); + } + } + } +} + +Sheet::~Sheet() +{ +} + +JS::Interpreter& Sheet::interpreter() const +{ + return m_workbook.interpreter(); +} + +size_t Sheet::add_row() +{ + return m_rows++; +} + +static String convert_to_string(size_t value, unsigned base = 26, StringView map = {}) +{ + if (map.is_null()) + map = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + ASSERT(base >= 2 && base <= map.length()); + + // The '8 bits per byte' assumption may need to go? + Array<char, round_up_to_power_of_two(sizeof(size_t) * 8 + 1, 2)> buffer; + size_t i = 0; + do { + buffer[i++] = map[value % base]; + value /= base; + } while (value > 0); + + // NOTE: Weird as this may seem, the thing that comes after 'A' is 'AA', which as a number would be '00' + // to make this work, only the most significant digit has to be in a range of (1..25) as opposed to (0..25), + // but only if it's not the only digit in the string. + if (i > 1) + --buffer[i - 1]; + + for (size_t j = 0; j < i / 2; ++j) + swap(buffer[j], buffer[i - j - 1]); + + return String { ReadonlyBytes(buffer.data(), i) }; +} + +static size_t convert_from_string(StringView str, unsigned base = 26, StringView map = {}) +{ + if (map.is_null()) + map = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + ASSERT(base >= 2 && base <= map.length()); + + size_t value = 0; + for (size_t i = str.length(); i > 0; --i) { + auto digit_value = map.find_first_of(str[i - 1]).value_or(0); + // NOTE: Refer to the note in `convert_to_string()'. + if (i == str.length() && str.length() > 1) + ++digit_value; + value = value * base + digit_value; + } + + return value; +} + +String Sheet::add_column() +{ + auto next_column = convert_to_string(m_columns.size()); + m_columns.append(next_column); + return next_column; +} + +void Sheet::update() +{ + if (m_should_ignore_updates) { + m_update_requested = true; + return; + } + m_visited_cells_in_update.clear(); + Vector<Cell*> cells_copy; + + // Grab a copy as updates might insert cells into the table. + for (auto& it : m_cells) { + if (it.value->dirty()) { + cells_copy.append(it.value); + m_workbook.set_dirty(true); + } + } + + for (auto& cell : cells_copy) + update(*cell); + + m_visited_cells_in_update.clear(); +} + +void Sheet::update(Cell& cell) +{ + if (m_should_ignore_updates) { + m_update_requested = true; + return; + } + if (cell.dirty()) { + if (has_been_visited(&cell)) { + // This may be part of an cyclic reference chain, + // so just ignore it. + cell.clear_dirty(); + return; + } + m_visited_cells_in_update.set(&cell); + cell.update_data({}); + } +} + +Sheet::ValueAndException Sheet::evaluate(const StringView& source, Cell* on_behalf_of) +{ + TemporaryChange cell_change { m_current_cell_being_evaluated, on_behalf_of }; + ScopeGuard clear_exception { [&] { interpreter().vm().clear_exception(); } }; + + auto parser = JS::Parser(JS::Lexer(source)); + if (parser.has_errors() || interpreter().exception()) + return { JS::js_undefined(), interpreter().exception() }; + + auto program = parser.parse_program(); + interpreter().run(global_object(), program); + if (interpreter().exception()) { + auto exc = interpreter().exception(); + return { JS::js_undefined(), exc }; + } + + auto value = interpreter().vm().last_value(); + if (value.is_empty()) + return { JS::js_undefined(), {} }; + return { value, {} }; +} + +Cell* Sheet::at(const StringView& name) +{ + auto pos = parse_cell_name(name); + if (pos.has_value()) + return at(pos.value()); + + return nullptr; +} + +Cell* Sheet::at(const Position& position) +{ + auto it = m_cells.find(position); + + if (it == m_cells.end()) + return nullptr; + + return it->value; +} + +Optional<Position> Sheet::parse_cell_name(const StringView& name) +{ + GenericLexer lexer(name); + auto col = lexer.consume_while(isalpha); + auto row = lexer.consume_while(isdigit); + + if (!lexer.is_eof() || row.is_empty() || col.is_empty()) + return {}; + + return Position { col, row.to_uint().value() }; +} + +Optional<size_t> Sheet::column_index(const StringView& column_name) const +{ + auto index = convert_from_string(column_name); + if (m_columns.size() <= index || m_columns[index] != column_name) + return {}; + + return index; +} + +Optional<String> Sheet::column_arithmetic(const StringView& column_name, int offset) +{ + auto maybe_index = column_index(column_name); + if (!maybe_index.has_value()) + return {}; + + if (offset < 0 && maybe_index.value() < (size_t)(0 - offset)) + return m_columns.first(); + + auto index = maybe_index.value() + offset; + if (m_columns.size() > index) + return m_columns[index]; + + for (size_t i = m_columns.size(); i <= index; ++i) + add_column(); + + return m_columns.last(); +} + +Cell* Sheet::from_url(const URL& url) +{ + auto maybe_position = position_from_url(url); + if (!maybe_position.has_value()) + return nullptr; + + return at(maybe_position.value()); +} + +Optional<Position> Sheet::position_from_url(const URL& url) const +{ + if (!url.is_valid()) { + dbgln("Invalid url: {}", url.to_string()); + return {}; + } + + if (url.protocol() != "spreadsheet" || url.host() != "cell") { + dbgln("Bad url: {}", url.to_string()); + return {}; + } + + // FIXME: Figure out a way to do this cross-process. + ASSERT(url.path() == String::formatted("/{}", getpid())); + + return parse_cell_name(url.fragment()); +} + +Position Sheet::offset_relative_to(const Position& base, const Position& offset, const Position& offset_base) const +{ + auto offset_column_it = m_columns.find(offset.column); + auto offset_base_column_it = m_columns.find(offset_base.column); + auto base_column_it = m_columns.find(base.column); + + if (offset_column_it.is_end()) { + dbgln("Column '{}' does not exist!", offset.column); + return base; + } + if (offset_base_column_it.is_end()) { + dbgln("Column '{}' does not exist!", offset.column); + return base; + } + if (base_column_it.is_end()) { + dbgln("Column '{}' does not exist!", offset.column); + return offset; + } + + auto new_column = column(offset_column_it.index() + base_column_it.index() - offset_base_column_it.index()); + auto new_row = offset.row + base.row - offset_base.row; + + return { move(new_column), new_row }; +} + +void Sheet::copy_cells(Vector<Position> from, Vector<Position> to, Optional<Position> resolve_relative_to) +{ + auto copy_to = [&](auto& source_position, Position target_position) { + auto& target_cell = ensure(target_position); + auto* source_cell = at(source_position); + + if (!source_cell) { + target_cell.set_data(""); + return; + } + + target_cell.copy_from(*source_cell); + }; + + if (from.size() == to.size()) { + auto from_it = from.begin(); + // FIXME: Ordering. + for (auto& position : to) + copy_to(*from_it++, position); + + return; + } + + if (to.size() == 1) { + // Resolve each index as relative to the first index offset from the selection. + auto& target = to.first(); + + for (auto& position : from) { +#ifdef COPY_DEBUG + dbg() << "Paste from '" << position.to_url() << "' to '" << target.to_url() << "'"; +#endif + copy_to(position, resolve_relative_to.has_value() ? offset_relative_to(target, position, resolve_relative_to.value()) : target); + } + + return; + } + + if (from.size() == 1) { + // Fill the target selection with the single cell. + auto& source = from.first(); + for (auto& position : to) { +#ifdef COPY_DEBUG + dbg() << "Paste from '" << source.to_url() << "' to '" << position.to_url() << "'"; +#endif + copy_to(source, resolve_relative_to.has_value() ? offset_relative_to(position, source, resolve_relative_to.value()) : position); + } + return; + } + + // Just disallow misaligned copies. + dbgln("Cannot copy {} cells to {} cells", from.size(), to.size()); +} + +RefPtr<Sheet> Sheet::from_json(const JsonObject& object, Workbook& workbook) +{ + auto sheet = adopt(*new Sheet(workbook)); + auto rows = object.get("rows").to_u32(default_row_count); + auto columns = object.get("columns"); + auto name = object.get("name").as_string_or("Sheet"); + + sheet->set_name(name); + + for (size_t i = 0; i < max(rows, (unsigned)Sheet::default_row_count); ++i) + sheet->add_row(); + + // FIXME: Better error checking. + if (columns.is_array()) { + columns.as_array().for_each([&](auto& value) { + sheet->m_columns.append(value.as_string()); + return IterationDecision::Continue; + }); + } + + if (sheet->m_columns.size() < default_column_count && sheet->columns_are_standard()) { + for (size_t i = sheet->m_columns.size(); i < default_column_count; ++i) + sheet->add_column(); + } + + auto cells = object.get("cells").as_object(); + auto json = sheet->interpreter().global_object().get("JSON"); + auto& parse_function = json.as_object().get("parse").as_function(); + + auto read_format = [](auto& format, const auto& obj) { + if (auto value = obj.get("foreground_color"); value.is_string()) + format.foreground_color = Color::from_string(value.as_string()); + if (auto value = obj.get("background_color"); value.is_string()) + format.background_color = Color::from_string(value.as_string()); + }; + + cells.for_each_member([&](auto& name, JsonValue& value) { + auto position_option = parse_cell_name(name); + if (!position_option.has_value()) + return IterationDecision::Continue; + + auto position = position_option.value(); + auto& obj = value.as_object(); + auto kind = obj.get("kind").as_string_or("LiteralString") == "LiteralString" ? Cell::LiteralString : Cell::Formula; + + OwnPtr<Cell> cell; + switch (kind) { + case Cell::LiteralString: + cell = make<Cell>(obj.get("value").to_string(), position, *sheet); + break; + case Cell::Formula: { + auto& interpreter = sheet->interpreter(); + auto value = interpreter.vm().call(parse_function, json, JS::js_string(interpreter.heap(), obj.get("value").as_string())); + cell = make<Cell>(obj.get("source").to_string(), move(value), position, *sheet); + break; + } + } + + auto type_name = obj.get_or("type", "Numeric").to_string(); + cell->set_type(type_name); + + auto type_meta = obj.get("type_metadata"); + if (type_meta.is_object()) { + auto& meta_obj = type_meta.as_object(); + auto meta = cell->type_metadata(); + if (auto value = meta_obj.get("length"); value.is_number()) + meta.length = value.to_i32(); + if (auto value = meta_obj.get("format"); value.is_string()) + meta.format = value.as_string(); + read_format(meta.static_format, meta_obj); + + cell->set_type_metadata(move(meta)); + } + + auto conditional_formats = obj.get("conditional_formats"); + auto cformats = cell->conditional_formats(); + if (conditional_formats.is_array()) { + conditional_formats.as_array().for_each([&](const auto& fmt_val) { + if (!fmt_val.is_object()) + return IterationDecision::Continue; + + auto& fmt_obj = fmt_val.as_object(); + auto fmt_cond = fmt_obj.get("condition").to_string(); + if (fmt_cond.is_empty()) + return IterationDecision::Continue; + + ConditionalFormat fmt; + fmt.condition = move(fmt_cond); + read_format(fmt, fmt_obj); + cformats.append(move(fmt)); + + return IterationDecision::Continue; + }); + cell->set_conditional_formats(move(cformats)); + } + + auto evaluated_format = obj.get("evaluated_formats"); + if (evaluated_format.is_object()) { + auto& evaluated_format_obj = evaluated_format.as_object(); + auto& evaluated_fmts = cell->evaluated_formats(); + + read_format(evaluated_fmts, evaluated_format_obj); + } + + sheet->m_cells.set(position, cell.release_nonnull()); + return IterationDecision::Continue; + }); + + return sheet; +} + +Position Sheet::written_data_bounds() const +{ + Position bound; + for (auto& entry : m_cells) { + if (entry.key.row >= bound.row) + bound.row = entry.key.row; + if (entry.key.column >= bound.column) + bound.column = entry.key.column; + } + + return bound; +} + +/// The sheet is allowed to have nonstandard column names +/// this checks whether all existing columns are 'standard' +/// (i.e. as generated by 'convert_to_string()' +bool Sheet::columns_are_standard() const +{ + for (size_t i = 0; i < m_columns.size(); ++i) { + if (m_columns[i] != convert_to_string(i)) + return false; + } + + return true; +} + +JsonObject Sheet::to_json() const +{ + JsonObject object; + object.set("name", m_name); + + auto save_format = [](const auto& format, auto& obj) { + if (format.foreground_color.has_value()) + obj.set("foreground_color", format.foreground_color.value().to_string()); + if (format.background_color.has_value()) + obj.set("background_color", format.background_color.value().to_string()); + }; + + auto bottom_right = written_data_bounds(); + + if (!columns_are_standard()) { + auto columns = JsonArray(); + for (auto& column : m_columns) + columns.append(column); + object.set("columns", move(columns)); + } + object.set("rows", bottom_right.row + 1); + + JsonObject cells; + for (auto& it : m_cells) { + StringBuilder builder; + builder.append(it.key.column); + builder.appendff("{}", it.key.row); + auto key = builder.to_string(); + + JsonObject data; + data.set("kind", it.value->kind() == Cell::Kind::Formula ? "Formula" : "LiteralString"); + if (it.value->kind() == Cell::Formula) { + data.set("source", it.value->data()); + auto json = interpreter().global_object().get("JSON"); + auto stringified = interpreter().vm().call(json.as_object().get("stringify").as_function(), json, it.value->evaluated_data()); + data.set("value", stringified.to_string_without_side_effects()); + } else { + data.set("value", it.value->data()); + } + + // Set type & meta + auto& type = it.value->type(); + auto& meta = it.value->type_metadata(); + data.set("type", type.name()); + + JsonObject metadata_object; + metadata_object.set("length", meta.length); + metadata_object.set("format", meta.format); +#if 0 + metadata_object.set("alignment", alignment_to_string(meta.alignment)); +#endif + save_format(meta.static_format, metadata_object); + + data.set("type_metadata", move(metadata_object)); + + // Set conditional formats + JsonArray conditional_formats; + for (auto& fmt : it.value->conditional_formats()) { + JsonObject fmt_object; + fmt_object.set("condition", fmt.condition); + save_format(fmt, fmt_object); + + conditional_formats.append(move(fmt_object)); + } + + data.set("conditional_formats", move(conditional_formats)); + + auto& evaluated_formats = it.value->evaluated_formats(); + JsonObject evaluated_formats_obj; + + save_format(evaluated_formats, evaluated_formats_obj); + data.set("evaluated_formats", move(evaluated_formats_obj)); + + cells.set(key, move(data)); + } + object.set("cells", move(cells)); + + return object; +} + +Vector<Vector<String>> Sheet::to_xsv() const +{ + Vector<Vector<String>> data; + + auto bottom_right = written_data_bounds(); + + // First row = headers. + size_t column_count = m_columns.size(); + if (columns_are_standard()) { + column_count = convert_from_string(bottom_right.column) + 1; + Vector<String> cols; + for (size_t i = 0; i < column_count; ++i) + cols.append(m_columns[i]); + data.append(move(cols)); + } else { + data.append(m_columns); + } + + for (size_t i = 0; i <= bottom_right.row; ++i) { + Vector<String> row; + row.resize(column_count); + for (size_t j = 0; j < column_count; ++j) { + auto cell = at({ m_columns[j], i }); + if (cell) + row[j] = cell->typed_display(); + } + + data.append(move(row)); + } + + return data; +} + +RefPtr<Sheet> Sheet::from_xsv(const Reader::XSV& xsv, Workbook& workbook) +{ + auto cols = xsv.headers(); + auto rows = xsv.size(); + + auto sheet = adopt(*new Sheet(workbook)); + sheet->m_columns = cols; + for (size_t i = 0; i < max(rows, Sheet::default_row_count); ++i) + sheet->add_row(); + if (sheet->columns_are_standard()) { + for (size_t i = sheet->m_columns.size(); i < Sheet::default_column_count; ++i) + sheet->add_column(); + } + + for (auto row : xsv) { + for (size_t i = 0; i < cols.size(); ++i) { + auto str = row[i]; + if (str.is_empty()) + continue; + Position position { cols[i], row.index() }; + auto cell = make<Cell>(str, position, *sheet); + sheet->m_cells.set(position, move(cell)); + } + } + + return sheet; +} + +JsonObject Sheet::gather_documentation() const +{ + JsonObject object; + const JS::PropertyName doc_name { "__documentation" }; + + auto add_docs_from = [&](auto& it, auto& global_object) { + auto value = global_object.get(it.key); + if (!value.is_function() && !value.is_object()) + return; + + auto& value_object = value.is_object() ? value.as_object() : value.as_function(); + if (!value_object.has_own_property(doc_name)) + return; + + dbgln("Found '{}'", it.key.to_display_string()); + auto doc = value_object.get(doc_name); + if (!doc.is_string()) + return; + + JsonParser parser(doc.to_string_without_side_effects()); + auto doc_object = parser.parse(); + + if (doc_object.has_value()) + object.set(it.key.to_display_string(), doc_object.value()); + else + dbgln("Sheet::gather_documentation(): Failed to parse the documentation for '{}'!", it.key.to_display_string()); + }; + + for (auto& it : interpreter().global_object().shape().property_table()) + add_docs_from(it, interpreter().global_object()); + + for (auto& it : global_object().shape().property_table()) + add_docs_from(it, global_object()); + + m_cached_documentation = move(object); + return m_cached_documentation.value(); +} + +String Sheet::generate_inline_documentation_for(StringView function, size_t argument_index) +{ + if (!m_cached_documentation.has_value()) + gather_documentation(); + + auto& docs = m_cached_documentation.value(); + auto entry = docs.get(function); + if (entry.is_null() || !entry.is_object()) + return String::formatted("{}(...???{})", function, argument_index); + + auto& entry_object = entry.as_object(); + size_t argc = entry_object.get("argc").to_int(0); + auto argnames_value = entry_object.get("argnames"); + if (!argnames_value.is_array()) + return String::formatted("{}(...{}???{})", function, argc, argument_index); + auto& argnames = argnames_value.as_array(); + StringBuilder builder; + builder.appendff("{}(", function); + for (size_t i = 0; i < (size_t)argnames.size(); ++i) { + if (i != 0 && i < (size_t)argnames.size()) + builder.append(", "); + if (i == argument_index) + builder.append('<'); + else if (i >= argc) + builder.append('['); + builder.append(argnames[i].to_string()); + if (i == argument_index) + builder.append('>'); + else if (i >= argc) + builder.append(']'); + } + + builder.append(')'); + return builder.build(); +} + +} diff --git a/Userland/Applications/Spreadsheet/Spreadsheet.h b/Userland/Applications/Spreadsheet/Spreadsheet.h new file mode 100644 index 0000000000..bbef6c8116 --- /dev/null +++ b/Userland/Applications/Spreadsheet/Spreadsheet.h @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Cell.h" +#include "Forward.h" +#include "Readers/XSV.h" +#include <AK/HashMap.h> +#include <AK/HashTable.h> +#include <AK/String.h> +#include <AK/StringBuilder.h> +#include <AK/Traits.h> +#include <AK/Types.h> +#include <AK/WeakPtr.h> +#include <AK/Weakable.h> +#include <LibCore/Object.h> +#include <LibJS/Interpreter.h> + +namespace Spreadsheet { + +class Sheet : public Core::Object { + C_OBJECT(Sheet); + +public: + constexpr static size_t default_row_count = 100; + constexpr static size_t default_column_count = 26; + + ~Sheet(); + + static Optional<Position> parse_cell_name(const StringView&); + Optional<size_t> column_index(const StringView& column_name) const; + Optional<String> column_arithmetic(const StringView& column_name, int offset); + + Cell* from_url(const URL&); + const Cell* from_url(const URL& url) const { return const_cast<Sheet*>(this)->from_url(url); } + Optional<Position> position_from_url(const URL& url) const; + + /// Resolve 'offset' to an absolute position assuming 'base' is at 'offset_base'. + /// Effectively, "Walk the distance between 'offset' and 'offset_base' away from 'base'". + Position offset_relative_to(const Position& base, const Position& offset, const Position& offset_base) const; + + JsonObject to_json() const; + static RefPtr<Sheet> from_json(const JsonObject&, Workbook&); + + Vector<Vector<String>> to_xsv() const; + static RefPtr<Sheet> from_xsv(const Reader::XSV&, Workbook&); + + const String& name() const { return m_name; } + void set_name(const StringView& name) { m_name = name; } + + JsonObject gather_documentation() const; + + const HashTable<Position>& selected_cells() const { return m_selected_cells; } + HashTable<Position>& selected_cells() { return m_selected_cells; } + const HashMap<Position, NonnullOwnPtr<Cell>>& cells() const { return m_cells; } + HashMap<Position, NonnullOwnPtr<Cell>>& cells() { return m_cells; } + + Cell* at(const Position& position); + const Cell* at(const Position& position) const { return const_cast<Sheet*>(this)->at(position); } + + const Cell* at(const StringView& name) const { return const_cast<Sheet*>(this)->at(name); } + Cell* at(const StringView&); + + const Cell& ensure(const Position& position) const { return const_cast<Sheet*>(this)->ensure(position); } + Cell& ensure(const Position& position) + { + if (auto cell = at(position)) + return *cell; + + m_cells.set(position, make<Cell>(String::empty(), position, *this)); + return *at(position); + } + + size_t add_row(); + String add_column(); + + size_t row_count() const { return m_rows; } + size_t column_count() const { return m_columns.size(); } + const Vector<String>& columns() const { return m_columns; } + const String& column(size_t index) + { + for (size_t i = column_count(); i < index; ++i) + add_column(); + + ASSERT(column_count() > index); + return m_columns[index]; + } + const String& column(size_t index) const + { + ASSERT(column_count() > index); + return m_columns[index]; + } + + void update(); + void update(Cell&); + void disable_updates() { m_should_ignore_updates = true; } + void enable_updates() + { + m_should_ignore_updates = false; + if (m_update_requested) { + m_update_requested = false; + update(); + } + } + + struct ValueAndException { + JS::Value value; + JS::Exception* exception { nullptr }; + }; + ValueAndException evaluate(const StringView&, Cell* = nullptr); + JS::Interpreter& interpreter() const; + SheetGlobalObject& global_object() const { return *m_global_object; } + + Cell*& current_evaluated_cell() { return m_current_cell_being_evaluated; } + bool has_been_visited(Cell* cell) const { return m_visited_cells_in_update.contains(cell); } + + const Workbook& workbook() const { return m_workbook; } + + void copy_cells(Vector<Position> from, Vector<Position> to, Optional<Position> resolve_relative_to = {}); + + /// Gives the bottom-right corner of the smallest bounding box containing all the written data. + Position written_data_bounds() const; + + bool columns_are_standard() const; + + String generate_inline_documentation_for(StringView function, size_t argument_index); + +private: + explicit Sheet(Workbook&); + explicit Sheet(const StringView& name, Workbook&); + + String m_name; + Vector<String> m_columns; + size_t m_rows { 0 }; + HashMap<Position, NonnullOwnPtr<Cell>> m_cells; + HashTable<Position> m_selected_cells; + + Workbook& m_workbook; + mutable SheetGlobalObject* m_global_object; + + Cell* m_current_cell_being_evaluated { nullptr }; + + HashTable<Cell*> m_visited_cells_in_update; + bool m_should_ignore_updates { false }; + bool m_update_requested { false }; + mutable Optional<JsonObject> m_cached_documentation; +}; + +} + +namespace AK { + +template<> +struct Traits<Spreadsheet::Position> : public GenericTraits<Spreadsheet::Position> { + static constexpr bool is_trivial() { return false; } + static unsigned hash(const Spreadsheet::Position& p) + { + return pair_int_hash( + string_hash(p.column.characters(), p.column.length()), + u64_hash(p.row)); + } +}; + +} diff --git a/Userland/Applications/Spreadsheet/SpreadsheetModel.cpp b/Userland/Applications/Spreadsheet/SpreadsheetModel.cpp new file mode 100644 index 0000000000..70193e4b63 --- /dev/null +++ b/Userland/Applications/Spreadsheet/SpreadsheetModel.cpp @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "SpreadsheetModel.h" +#include "ConditionalFormatting.h" +#include <AK/URL.h> +#include <LibGUI/AbstractView.h> +#include <LibJS/Runtime/Error.h> +#include <LibJS/Runtime/Object.h> + +namespace Spreadsheet { + +SheetModel::~SheetModel() +{ +} + +GUI::Variant SheetModel::data(const GUI::ModelIndex& index, GUI::ModelRole role) const +{ + if (!index.is_valid()) + return {}; + + if (role == GUI::ModelRole::Display) { + const auto* cell = m_sheet->at({ m_sheet->column(index.column()), (size_t)index.row() }); + if (!cell) + return String::empty(); + + if (cell->kind() == Spreadsheet::Cell::Formula) { + if (auto exception = cell->exception()) { + StringBuilder builder; + builder.append("Error: "); + auto value = exception->value(); + if (value.is_object()) { + auto& object = value.as_object(); + if (is<JS::Error>(object)) { + auto error = object.get("message").to_string_without_side_effects(); + builder.append(error); + return builder.to_string(); + } + } + auto error = value.to_string(cell->sheet().global_object()); + // This is annoying, but whatever. + cell->sheet().interpreter().vm().clear_exception(); + + builder.append(error); + return builder.to_string(); + } + } + + return cell->typed_display(); + } + + if (role == GUI::ModelRole::MimeData) + return Position { m_sheet->column(index.column()), (size_t)index.row() }.to_url().to_string(); + + if (role == GUI::ModelRole::TextAlignment) { + const auto* cell = m_sheet->at({ m_sheet->column(index.column()), (size_t)index.row() }); + if (!cell) + return {}; + + return cell->type_metadata().alignment; + } + + if (role == GUI::ModelRole::ForegroundColor) { + const auto* cell = m_sheet->at({ m_sheet->column(index.column()), (size_t)index.row() }); + if (!cell) + return {}; + + if (cell->kind() == Spreadsheet::Cell::Formula) { + if (cell->exception()) + return Color(Color::Red); + } + + if (cell->evaluated_formats().foreground_color.has_value()) + return cell->evaluated_formats().foreground_color.value(); + + if (auto color = cell->type_metadata().static_format.foreground_color; color.has_value()) + return color.value(); + + return {}; + } + + if (role == GUI::ModelRole::BackgroundColor) { + const auto* cell = m_sheet->at({ m_sheet->column(index.column()), (size_t)index.row() }); + if (!cell) + return {}; + + if (cell->evaluated_formats().background_color.has_value()) + return cell->evaluated_formats().background_color.value(); + + if (auto color = cell->type_metadata().static_format.background_color; color.has_value()) + return color.value(); + + return {}; + } + + return {}; +} + +RefPtr<Core::MimeData> SheetModel::mime_data(const GUI::ModelSelection& selection) const +{ + auto mime_data = GUI::Model::mime_data(selection); + + bool first = true; + const GUI::ModelIndex* cursor = nullptr; + const_cast<SheetModel*>(this)->for_each_view([&](const GUI::AbstractView& view) { + if (!first) + return; + cursor = &view.cursor_index(); + first = false; + }); + + ASSERT(cursor); + + Position cursor_position { m_sheet->column(cursor->column()), (size_t)cursor->row() }; + auto new_data = String::formatted("{}\n{}", + cursor_position.to_url().to_string(), + StringView(mime_data->data("text/x-spreadsheet-data"))); + mime_data->set_data("text/x-spreadsheet-data", new_data.to_byte_buffer()); + + return mime_data; +} + +String SheetModel::column_name(int index) const +{ + if (index < 0) + return {}; + + return m_sheet->column(index); +} + +bool SheetModel::is_editable(const GUI::ModelIndex& index) const +{ + if (!index.is_valid()) + return false; + + return true; +} + +void SheetModel::set_data(const GUI::ModelIndex& index, const GUI::Variant& value) +{ + if (!index.is_valid()) + return; + + auto& cell = m_sheet->ensure({ m_sheet->column(index.column()), (size_t)index.row() }); + cell.set_data(value.to_string()); + update(); +} + +void SheetModel::update() +{ + m_sheet->update(); + did_update(UpdateFlag::DontInvalidateIndexes); +} + +} diff --git a/Userland/Applications/Spreadsheet/SpreadsheetModel.h b/Userland/Applications/Spreadsheet/SpreadsheetModel.h new file mode 100644 index 0000000000..14527d4c37 --- /dev/null +++ b/Userland/Applications/Spreadsheet/SpreadsheetModel.h @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Spreadsheet.h" +#include <LibGUI/Model.h> + +namespace Spreadsheet { + +class SheetModel final : public GUI::Model { +public: + static NonnullRefPtr<SheetModel> create(Sheet& sheet) { return adopt(*new SheetModel(sheet)); } + virtual ~SheetModel() override; + + virtual int row_count(const GUI::ModelIndex& = GUI::ModelIndex()) const override { return m_sheet->row_count(); } + virtual int column_count(const GUI::ModelIndex& = GUI::ModelIndex()) const override { return m_sheet->column_count(); } + virtual String column_name(int) const override; + virtual GUI::Variant data(const GUI::ModelIndex&, GUI::ModelRole) const override; + virtual RefPtr<Core::MimeData> mime_data(const GUI::ModelSelection&) const override; + virtual bool is_editable(const GUI::ModelIndex&) const override; + virtual void set_data(const GUI::ModelIndex&, const GUI::Variant&) override; + virtual void update() override; + virtual bool is_column_sortable(int) const override { return false; } + virtual StringView drag_data_type() const override { return "text/x-spreadsheet-data"; } + Sheet& sheet() { return *m_sheet; } + +private: + explicit SheetModel(Sheet& sheet) + : m_sheet(sheet) + { + } + + NonnullRefPtr<Sheet> m_sheet; +}; + +} diff --git a/Userland/Applications/Spreadsheet/SpreadsheetView.cpp b/Userland/Applications/Spreadsheet/SpreadsheetView.cpp new file mode 100644 index 0000000000..9762f77578 --- /dev/null +++ b/Userland/Applications/Spreadsheet/SpreadsheetView.cpp @@ -0,0 +1,334 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "SpreadsheetView.h" +#include "CellTypeDialog.h" +#include "SpreadsheetModel.h" +#include <AK/ScopeGuard.h> +#include <AK/URL.h> +#include <LibCore/MimeData.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/HeaderView.h> +#include <LibGUI/Menu.h> +#include <LibGUI/ModelEditingDelegate.h> +#include <LibGUI/Painter.h> +#include <LibGUI/ScrollBar.h> +#include <LibGUI/TableView.h> +#include <LibGfx/Palette.h> + +namespace Spreadsheet { + +SpreadsheetView::~SpreadsheetView() +{ +} + +void SpreadsheetView::EditingDelegate::set_value(const GUI::Variant& value) +{ + if (value.as_string().is_null()) { + StringModelEditingDelegate::set_value(""); + commit(); + return; + } + + if (m_has_set_initial_value) + return StringModelEditingDelegate::set_value(value); + + m_has_set_initial_value = true; + const auto option = m_sheet.at({ m_sheet.column(index().column()), (size_t)index().row() }); + if (option) + return StringModelEditingDelegate::set_value(option->source()); + + StringModelEditingDelegate::set_value(""); +} + +void InfinitelyScrollableTableView::did_scroll() +{ + TableView::did_scroll(); + auto& vscrollbar = vertical_scrollbar(); + auto& hscrollbar = horizontal_scrollbar(); + if (vscrollbar.is_visible() && vscrollbar.value() == vscrollbar.max()) { + if (on_reaching_vertical_end) + on_reaching_vertical_end(); + } + if (hscrollbar.is_visible() && hscrollbar.value() == hscrollbar.max()) { + if (on_reaching_horizontal_end) + on_reaching_horizontal_end(); + } +} + +void InfinitelyScrollableTableView::mousemove_event(GUI::MouseEvent& event) +{ + if (auto model = this->model()) { + auto index = index_at_event_position(event.position()); + if (!index.is_valid()) + return TableView::mousemove_event(event); + + auto& sheet = static_cast<SheetModel&>(*model).sheet(); + sheet.disable_updates(); + ScopeGuard sheet_update_enabler { [&] { sheet.enable_updates(); } }; + + auto holding_left_button = !!(event.buttons() & GUI::MouseButton::Left); + auto rect = content_rect(index); + auto distance = rect.center().absolute_relative_distance_to(event.position()); + if (distance.x() >= rect.width() / 2 - 5 && distance.y() >= rect.height() / 2 - 5) { + set_override_cursor(Gfx::StandardCursor::Crosshair); + m_should_intercept_drag = false; + if (holding_left_button) { + m_has_committed_to_dragging = true; + // Force a drag to happen by moving the mousedown position to the center of the cell. + m_left_mousedown_position = rect.center(); + } + } else if (!m_should_intercept_drag) { + set_override_cursor(Gfx::StandardCursor::Arrow); + if (!holding_left_button) { + m_starting_selection_index = index; + } else { + m_should_intercept_drag = true; + m_might_drag = false; + } + } + + if (holding_left_button && m_should_intercept_drag && !m_has_committed_to_dragging) { + if (!m_starting_selection_index.is_valid()) + m_starting_selection_index = index; + + Vector<GUI::ModelIndex> new_selection; + for (auto i = min(m_starting_selection_index.row(), index.row()), imax = max(m_starting_selection_index.row(), index.row()); i <= imax; ++i) { + for (auto j = min(m_starting_selection_index.column(), index.column()), jmax = max(m_starting_selection_index.column(), index.column()); j <= jmax; ++j) { + auto index = model->index(i, j); + if (index.is_valid()) + new_selection.append(move(index)); + } + } + + if (!event.ctrl()) + selection().clear(); + selection().add_all(new_selection); + } + } + + TableView::mousemove_event(event); +} + +void InfinitelyScrollableTableView::mouseup_event(GUI::MouseEvent& event) +{ + m_should_intercept_drag = false; + m_has_committed_to_dragging = false; + TableView::mouseup_event(event); +} + +void SpreadsheetView::update_with_model() +{ + m_table_view->model()->update(); + m_table_view->update(); +} + +SpreadsheetView::SpreadsheetView(Sheet& sheet) + : m_sheet(sheet) +{ + set_layout<GUI::VerticalBoxLayout>().set_margins({ 2, 2, 2, 2 }); + m_table_view = add<InfinitelyScrollableTableView>(); + m_table_view->set_grid_style(GUI::TableView::GridStyle::Both); + m_table_view->set_selection_behavior(GUI::AbstractView::SelectionBehavior::SelectItems); + m_table_view->set_edit_triggers(GUI::AbstractView::EditTrigger::EditKeyPressed | GUI::AbstractView::AnyKeyPressed | GUI::AbstractView::DoubleClicked); + m_table_view->set_tab_key_navigation_enabled(true); + m_table_view->row_header().set_visible(true); + m_table_view->set_model(SheetModel::create(*m_sheet)); + m_table_view->on_reaching_vertical_end = [&]() { + for (size_t i = 0; i < 100; ++i) { + auto index = m_sheet->add_row(); + m_table_view->set_column_painting_delegate(index, make<TableCellPainter>(*m_table_view)); + }; + update_with_model(); + }; + m_table_view->on_reaching_horizontal_end = [&]() { + for (size_t i = 0; i < 10; ++i) { + m_sheet->add_column(); + auto last_column_index = m_sheet->column_count() - 1; + m_table_view->set_column_width(last_column_index, 50); + m_table_view->set_column_header_alignment(last_column_index, Gfx::TextAlignment::Center); + } + update_with_model(); + }; + + set_focus_proxy(m_table_view); + + // FIXME: This is dumb. + for (size_t i = 0; i < m_sheet->column_count(); ++i) { + m_table_view->set_column_painting_delegate(i, make<TableCellPainter>(*m_table_view)); + m_table_view->set_column_width(i, 50); + m_table_view->set_column_header_alignment(i, Gfx::TextAlignment::Center); + } + + m_table_view->set_alternating_row_colors(false); + m_table_view->set_highlight_selected_rows(false); + m_table_view->set_editable(true); + m_table_view->aid_create_editing_delegate = [this](auto&) { + auto delegate = make<EditingDelegate>(*m_sheet); + delegate->on_cursor_key_pressed = [this](auto& event) { + m_table_view->stop_editing(); + m_table_view->event(event); + }; + return delegate; + }; + + m_table_view->on_selection_change = [&] { + m_sheet->selected_cells().clear(); + for (auto& index : m_table_view->selection().indexes()) { + Position position { m_sheet->column(index.column()), (size_t)index.row() }; + m_sheet->selected_cells().set(position); + } + + if (m_table_view->selection().is_empty() && on_selection_dropped) + return on_selection_dropped(); + + Vector<Position> selected_positions; + selected_positions.ensure_capacity(m_table_view->selection().size()); + for (auto& selection : m_table_view->selection().indexes()) + selected_positions.empend(m_sheet->column(selection.column()), (size_t)selection.row()); + + if (on_selection_changed) { + on_selection_changed(move(selected_positions)); + update_with_model(); + }; + }; + + m_table_view->on_activation = [this](auto&) { + m_table_view->move_cursor(GUI::AbstractView::CursorMovement::Down, GUI::AbstractView::SelectionUpdate::Set); + }; + + m_table_view->on_context_menu_request = [&](const GUI::ModelIndex&, const GUI::ContextMenuEvent& event) { + // NOTE: We ignore the specific cell for now. + m_cell_range_context_menu->popup(event.screen_position()); + }; + + m_cell_range_context_menu = GUI::Menu::construct(); + m_cell_range_context_menu->add_action(GUI::Action::create("Type and Formatting...", [this](auto&) { + Vector<Position> positions; + for (auto& index : m_table_view->selection().indexes()) { + Position position { m_sheet->column(index.column()), (size_t)index.row() }; + positions.append(move(position)); + } + + if (positions.is_empty()) { + auto& index = m_table_view->cursor_index(); + Position position { m_sheet->column(index.column()), (size_t)index.row() }; + positions.append(move(position)); + } + + auto dialog = CellTypeDialog::construct(positions, *m_sheet, window()); + if (dialog->exec() == GUI::Dialog::ExecOK) { + for (auto& position : positions) { + auto& cell = m_sheet->ensure(position); + cell.set_type(dialog->type()); + cell.set_type_metadata(dialog->metadata()); + cell.set_conditional_formats(dialog->conditional_formats()); + } + + m_table_view->update(); + } + })); + + m_table_view->on_drop = [&](const GUI::ModelIndex& index, const GUI::DropEvent& event) { + if (!index.is_valid()) + return; + + ScopeGuard update_after_drop { [this] { update(); } }; + + if (event.mime_data().has_format("text/x-spreadsheet-data")) { + auto data = event.mime_data().data("text/x-spreadsheet-data"); + StringView urls { data.data(), data.size() }; + Vector<Position> source_positions, target_positions; + + for (auto& line : urls.lines(false)) { + auto position = m_sheet->position_from_url(line); + if (position.has_value()) + source_positions.append(position.release_value()); + } + + // Drop always has a single target. + Position target { m_sheet->column(index.column()), (size_t)index.row() }; + target_positions.append(move(target)); + + if (source_positions.is_empty()) + return; + + auto first_position = source_positions.take_first(); + m_sheet->copy_cells(move(source_positions), move(target_positions), first_position); + + return; + } + + if (event.mime_data().has_text()) { + auto* target_cell = m_sheet->at({ m_sheet->column(index.column()), (size_t)index.row() }); + ASSERT(target_cell); + + target_cell->set_data(event.text()); + return; + } + }; +} + +void SpreadsheetView::hide_event(GUI::HideEvent&) +{ + if (on_selection_dropped) + on_selection_dropped(); +} + +void SpreadsheetView::show_event(GUI::ShowEvent&) +{ + if (on_selection_changed && !m_table_view->selection().is_empty()) { + Vector<Position> selected_positions; + selected_positions.ensure_capacity(m_table_view->selection().size()); + for (auto& selection : m_table_view->selection().indexes()) + selected_positions.empend(m_sheet->column(selection.column()), (size_t)selection.row()); + + on_selection_changed(move(selected_positions)); + } +} + +void SpreadsheetView::TableCellPainter::paint(GUI::Painter& painter, const Gfx::IntRect& rect, const Gfx::Palette& palette, const GUI::ModelIndex& index) +{ + // Draw a border. + // Undo the horizontal padding done by the table view... + auto cell_rect = rect.inflated(m_table_view.horizontal_padding() * 2, 0); + + if (auto bg = index.data(GUI::ModelRole::BackgroundColor); bg.is_color()) + painter.fill_rect(cell_rect, bg.as_color()); + + if (m_table_view.selection().contains(index)) { + Color fill_color = palette.selection(); + fill_color.set_alpha(80); + painter.fill_rect(cell_rect, fill_color); + } + + auto text_color = index.data(GUI::ModelRole::ForegroundColor).to_color(palette.color(m_table_view.foreground_role())); + auto data = index.data(); + auto text_alignment = index.data(GUI::ModelRole::TextAlignment).to_text_alignment(Gfx::TextAlignment::CenterRight); + painter.draw_text(rect, data.to_string(), m_table_view.font_for_index(index), text_alignment, text_color, Gfx::TextElision::Right); +} + +} diff --git a/Userland/Applications/Spreadsheet/SpreadsheetView.h b/Userland/Applications/Spreadsheet/SpreadsheetView.h new file mode 100644 index 0000000000..bc07d31bc3 --- /dev/null +++ b/Userland/Applications/Spreadsheet/SpreadsheetView.h @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Spreadsheet.h" +#include <LibGUI/AbstractTableView.h> +#include <LibGUI/ModelEditingDelegate.h> +#include <LibGUI/TableView.h> +#include <LibGUI/Widget.h> +#include <string.h> + +namespace Spreadsheet { + +class CellEditor final : public GUI::TextEditor { + C_OBJECT(CellEditor); + +public: + virtual ~CellEditor() { } + + Function<void(GUI::KeyEvent&)> on_cursor_key_pressed; + +private: + CellEditor() + : TextEditor(TextEditor::Type::SingleLine) + { + } + + static bool is_navigation(const GUI::KeyEvent& event) + { + if (event.modifiers() == KeyModifier::Mod_Shift && event.key() == KeyCode::Key_Tab) + return true; + + if (event.modifiers()) + return false; + + switch (event.key()) { + case KeyCode::Key_Tab: + case KeyCode::Key_Left: + case KeyCode::Key_Right: + case KeyCode::Key_Up: + case KeyCode::Key_Down: + case KeyCode::Key_Return: + return true; + default: + return false; + } + } + + virtual void keydown_event(GUI::KeyEvent& event) override + { + if (is_navigation(event)) + on_cursor_key_pressed(event); + else + TextEditor::keydown_event(event); + } +}; + +class InfinitelyScrollableTableView : public GUI::TableView { + C_OBJECT(InfinitelyScrollableTableView) +public: + Function<void()> on_reaching_vertical_end; + Function<void()> on_reaching_horizontal_end; + +private: + virtual void did_scroll() override; + virtual void mousemove_event(GUI::MouseEvent&) override; + virtual void mouseup_event(GUI::MouseEvent&) override; + + bool m_should_intercept_drag { false }; + bool m_has_committed_to_dragging { false }; + GUI::ModelIndex m_starting_selection_index; +}; + +class SpreadsheetView final : public GUI::Widget { + C_OBJECT(SpreadsheetView); + +public: + ~SpreadsheetView(); + + const Sheet& sheet() const { return *m_sheet; } + Sheet& sheet() { return *m_sheet; } + + const GUI::ModelIndex* cursor() const + { + return &m_table_view->cursor_index(); + } + + Function<void(Vector<Position>&&)> on_selection_changed; + Function<void()> on_selection_dropped; + +private: + virtual void hide_event(GUI::HideEvent&) override; + virtual void show_event(GUI::ShowEvent&) override; + + void update_with_model(); + + SpreadsheetView(Sheet&); + + class EditingDelegate final : public GUI::StringModelEditingDelegate { + public: + EditingDelegate(const Sheet& sheet) + : m_sheet(sheet) + { + } + virtual void set_value(const GUI::Variant& value) override; + + virtual RefPtr<Widget> create_widget() override + { + auto textbox = CellEditor::construct(); + textbox->on_escape_pressed = [this] { + rollback(); + }; + textbox->on_cursor_key_pressed = [this](auto& event) { + commit(); + on_cursor_key_pressed(event); + }; + return textbox; + } + + Function<void(GUI::KeyEvent&)> on_cursor_key_pressed; + + private: + bool m_has_set_initial_value { false }; + const Sheet& m_sheet; + }; + + class TableCellPainter final : public GUI::TableCellPaintingDelegate { + public: + TableCellPainter(const GUI::TableView& view) + : m_table_view(view) + { + } + void paint(GUI::Painter&, const Gfx::IntRect&, const Gfx::Palette&, const GUI::ModelIndex&) override; + + private: + const GUI::TableView& m_table_view; + }; + + NonnullRefPtr<Sheet> m_sheet; + RefPtr<InfinitelyScrollableTableView> m_table_view; + RefPtr<GUI::Menu> m_cell_range_context_menu; +}; + +} diff --git a/Userland/Applications/Spreadsheet/SpreadsheetWidget.cpp b/Userland/Applications/Spreadsheet/SpreadsheetWidget.cpp new file mode 100644 index 0000000000..65e48e3f12 --- /dev/null +++ b/Userland/Applications/Spreadsheet/SpreadsheetWidget.cpp @@ -0,0 +1,347 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "SpreadsheetWidget.h" +#include "CellSyntaxHighlighter.h" +#include "HelpWindow.h" +#include "LibGUI/InputBox.h" +#include <LibCore/File.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/FilePicker.h> +#include <LibGUI/Label.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/Splitter.h> +#include <LibGUI/TabWidget.h> +#include <LibGUI/TextEditor.h> +#include <LibGfx/FontDatabase.h> +#include <string.h> + +namespace Spreadsheet { + +SpreadsheetWidget::SpreadsheetWidget(NonnullRefPtrVector<Sheet>&& sheets, bool should_add_sheet_if_empty) + : m_workbook(make<Workbook>(move(sheets))) +{ + set_fill_with_background_color(true); + set_layout<GUI::VerticalBoxLayout>().set_margins({ 2, 2, 2, 2 }); + auto& container = add<GUI::VerticalSplitter>(); + + auto& top_bar = container.add<GUI::Frame>(); + top_bar.set_layout<GUI::HorizontalBoxLayout>().set_spacing(1); + top_bar.set_fixed_height(26); + auto& current_cell_label = top_bar.add<GUI::Label>(""); + current_cell_label.set_fixed_width(50); + + auto& help_button = top_bar.add<GUI::Button>("🛈"); + help_button.set_fixed_size(20, 20); + help_button.on_click = [&](auto) { + auto docs = m_selected_view->sheet().gather_documentation(); + auto help_window = HelpWindow::the(window()); + help_window->set_docs(move(docs)); + help_window->show(); + }; + + auto& cell_value_editor = top_bar.add<GUI::TextEditor>(GUI::TextEditor::Type::SingleLine); + cell_value_editor.set_font(Gfx::FontDatabase::default_fixed_width_font()); + cell_value_editor.set_scrollbars_enabled(false); + + cell_value_editor.set_syntax_highlighter(make<CellSyntaxHighlighter>()); + cell_value_editor.set_enabled(false); + current_cell_label.set_enabled(false); + + m_tab_widget = container.add<GUI::TabWidget>(); + m_tab_widget->set_tab_position(GUI::TabWidget::TabPosition::Bottom); + + m_cell_value_editor = cell_value_editor; + m_current_cell_label = current_cell_label; + m_inline_documentation_window = GUI::Window::construct(window()); + m_inline_documentation_window->set_rect(m_cell_value_editor->rect().translated(0, m_cell_value_editor->height() + 7).inflated(6, 6)); + m_inline_documentation_window->set_window_type(GUI::WindowType::Tooltip); + m_inline_documentation_window->set_resizable(false); + auto& inline_widget = m_inline_documentation_window->set_main_widget<GUI::Frame>(); + inline_widget.set_fill_with_background_color(true); + inline_widget.set_layout<GUI::VerticalBoxLayout>().set_margins({ 4, 4, 4, 4 }); + inline_widget.set_frame_shape(Gfx::FrameShape::Box); + m_inline_documentation_label = inline_widget.add<GUI::Label>(); + m_inline_documentation_label->set_fill_with_background_color(true); + m_inline_documentation_label->set_autosize(false); + m_inline_documentation_label->set_text_alignment(Gfx::TextAlignment::CenterLeft); + + if (!m_workbook->has_sheets() && should_add_sheet_if_empty) + m_workbook->add_sheet("Sheet 1"); + + m_tab_context_menu = GUI::Menu::construct(); + auto rename_action = GUI::Action::create("Rename...", [this](auto&) { + ASSERT(m_tab_context_menu_sheet_view); + + auto& sheet = m_tab_context_menu_sheet_view->sheet(); + String new_name; + if (GUI::InputBox::show(new_name, window(), String::formatted("New name for '{}'", sheet.name()), "Rename sheet") == GUI::Dialog::ExecOK) { + sheet.set_name(new_name); + sheet.update(); + m_tab_widget->set_tab_title(static_cast<GUI::Widget&>(*m_tab_context_menu_sheet_view), new_name); + } + }); + m_tab_context_menu->add_action(rename_action); + m_tab_context_menu->add_action(GUI::Action::create("Add new sheet...", [this](auto&) { + String name; + if (GUI::InputBox::show(name, window(), "Name for new sheet", "Create sheet") == GUI::Dialog::ExecOK) { + NonnullRefPtrVector<Sheet> new_sheets; + new_sheets.append(m_workbook->add_sheet(name)); + setup_tabs(move(new_sheets)); + } + })); + + setup_tabs(m_workbook->sheets()); +} + +void SpreadsheetWidget::resize_event(GUI::ResizeEvent& event) +{ + GUI::Widget::resize_event(event); + if (m_inline_documentation_window && m_cell_value_editor && window()) + m_inline_documentation_window->set_rect(m_cell_value_editor->screen_relative_rect().translated(0, m_cell_value_editor->height() + 7).inflated(6, 6)); +} + +void SpreadsheetWidget::setup_tabs(NonnullRefPtrVector<Sheet> new_sheets) +{ + RefPtr<GUI::Widget> first_tab_widget; + for (auto& sheet : new_sheets) { + auto& tab = m_tab_widget->add_tab<SpreadsheetView>(sheet.name(), sheet); + if (!first_tab_widget) + first_tab_widget = &tab; + } + + auto change = [&](auto& selected_widget) { + if (m_selected_view) { + m_selected_view->on_selection_changed = nullptr; + m_selected_view->on_selection_dropped = nullptr; + }; + m_selected_view = &static_cast<SpreadsheetView&>(selected_widget); + m_selected_view->on_selection_changed = [&](Vector<Position>&& selection) { + if (selection.is_empty()) { + m_current_cell_label->set_enabled(false); + m_current_cell_label->set_text({}); + m_cell_value_editor->on_change = nullptr; + m_cell_value_editor->on_focusin = nullptr; + m_cell_value_editor->on_focusout = nullptr; + m_cell_value_editor->set_text(""); + m_cell_value_editor->set_enabled(false); + return; + } + + if (selection.size() == 1) { + auto& position = selection.first(); + StringBuilder builder; + builder.append(position.column); + builder.appendff("{}", position.row); + m_current_cell_label->set_enabled(true); + m_current_cell_label->set_text(builder.string_view()); + + auto& cell = m_selected_view->sheet().ensure(position); + m_cell_value_editor->on_change = nullptr; + m_cell_value_editor->set_text(cell.source()); + m_cell_value_editor->on_change = [&] { + auto text = m_cell_value_editor->text(); + // FIXME: Lines? + auto offset = m_cell_value_editor->cursor().column(); + try_generate_tip_for_input_expression(text, offset); + cell.set_data(move(text)); + m_selected_view->sheet().update(); + update(); + }; + m_cell_value_editor->set_enabled(true); + static_cast<CellSyntaxHighlighter*>(const_cast<GUI::SyntaxHighlighter*>(m_cell_value_editor->syntax_highlighter()))->set_cell(&cell); + return; + } + + // There are many cells selected, change all of them. + StringBuilder builder; + builder.appendff("<{}>", selection.size()); + m_current_cell_label->set_enabled(true); + m_current_cell_label->set_text(builder.string_view()); + + Vector<Cell*> cells; + for (auto& position : selection) + cells.append(&m_selected_view->sheet().ensure(position)); + + auto first_cell = cells.first(); + m_cell_value_editor->on_change = nullptr; + m_cell_value_editor->set_text(""); + m_should_change_selected_cells = false; + m_cell_value_editor->on_focusin = [this] { m_should_change_selected_cells = true; }; + m_cell_value_editor->on_focusout = [this] { m_should_change_selected_cells = false; }; + m_cell_value_editor->on_change = [cells = move(cells), this] { + if (m_should_change_selected_cells) { + auto text = m_cell_value_editor->text(); + // FIXME: Lines? + auto offset = m_cell_value_editor->cursor().column(); + try_generate_tip_for_input_expression(text, offset); + for (auto* cell : cells) + cell->set_data(text); + m_selected_view->sheet().update(); + update(); + } + }; + m_cell_value_editor->set_enabled(true); + static_cast<CellSyntaxHighlighter*>(const_cast<GUI::SyntaxHighlighter*>(m_cell_value_editor->syntax_highlighter()))->set_cell(first_cell); + }; + m_selected_view->on_selection_dropped = [&]() { + m_cell_value_editor->set_enabled(false); + static_cast<CellSyntaxHighlighter*>(const_cast<GUI::SyntaxHighlighter*>(m_cell_value_editor->syntax_highlighter()))->set_cell(nullptr); + m_cell_value_editor->set_text(""); + m_current_cell_label->set_enabled(false); + m_current_cell_label->set_text(""); + }; + }; + + if (first_tab_widget) + change(*first_tab_widget); + + m_tab_widget->on_change = [change = move(change)](auto& selected_widget) { + change(selected_widget); + }; + + m_tab_widget->on_context_menu_request = [&](auto& widget, auto& event) { + m_tab_context_menu_sheet_view = widget; + m_tab_context_menu->popup(event.screen_position()); + }; +} + +void SpreadsheetWidget::try_generate_tip_for_input_expression(StringView source, size_t cursor_offset) +{ + m_inline_documentation_window->set_rect(m_cell_value_editor->screen_relative_rect().translated(0, m_cell_value_editor->height() + 7).inflated(6, 6)); + if (!m_selected_view || !source.starts_with('=')) { + m_inline_documentation_window->hide(); + return; + } + auto maybe_function_and_argument = get_function_and_argument_index(source.substring_view(0, cursor_offset)); + if (!maybe_function_and_argument.has_value()) { + m_inline_documentation_window->hide(); + return; + } + + auto& [name, index] = maybe_function_and_argument.value(); + auto& sheet = m_selected_view->sheet(); + auto text = sheet.generate_inline_documentation_for(name, index); + if (text.is_empty()) { + m_inline_documentation_window->hide(); + } else { + m_inline_documentation_label->set_text(move(text)); + m_inline_documentation_window->show(); + } +} + +void SpreadsheetWidget::save(const StringView& filename) +{ + auto result = m_workbook->save(filename); + if (result.is_error()) + GUI::MessageBox::show_error(window(), result.error()); +} + +void SpreadsheetWidget::load(const StringView& filename) +{ + auto result = m_workbook->load(filename); + if (result.is_error()) { + GUI::MessageBox::show_error(window(), result.error()); + return; + } + + m_tab_widget->on_change = nullptr; + m_cell_value_editor->on_change = nullptr; + m_current_cell_label->set_text(""); + m_should_change_selected_cells = false; + while (auto* widget = m_tab_widget->active_widget()) { + m_tab_widget->remove_tab(*widget); + } + + setup_tabs(m_workbook->sheets()); +} + +bool SpreadsheetWidget::request_close() +{ + if (!m_workbook->dirty()) + return true; + + auto result = GUI::MessageBox::show(window(), "The spreadsheet has been modified. Would you like to save?", "Unsaved changes", GUI::MessageBox::Type::Warning, GUI::MessageBox::InputType::YesNoCancel); + + if (result == GUI::MessageBox::ExecYes) { + if (current_filename().is_empty()) { + String name = "workbook"; + Optional<String> save_path = GUI::FilePicker::get_save_filepath(window(), name, "sheets"); + if (!save_path.has_value()) + return false; + + save(save_path.value()); + } else { + save(current_filename()); + } + return true; + } + + if (result == GUI::MessageBox::ExecNo) + return true; + + return false; +} + +void SpreadsheetWidget::add_sheet() +{ + StringBuilder name; + name.append("Sheet"); + name.appendff(" {}", m_workbook->sheets().size() + 1); + + NonnullRefPtrVector<Sheet> new_sheets; + new_sheets.append(m_workbook->add_sheet(name.string_view())); + setup_tabs(move(new_sheets)); +} + +void SpreadsheetWidget::add_sheet(NonnullRefPtr<Sheet>&& sheet) +{ + ASSERT(m_workbook == &sheet->workbook()); + + NonnullRefPtrVector<Sheet> new_sheets; + new_sheets.append(move(sheet)); + m_workbook->sheets().append(new_sheets); + setup_tabs(new_sheets); +} + +void SpreadsheetWidget::set_filename(const String& filename) +{ + if (m_workbook->set_filename(filename)) { + StringBuilder builder; + builder.append("Spreadsheet - "); + builder.append(current_filename()); + + window()->set_title(builder.string_view()); + window()->update(); + } +} + +SpreadsheetWidget::~SpreadsheetWidget() +{ +} +} diff --git a/Userland/Applications/Spreadsheet/SpreadsheetWidget.h b/Userland/Applications/Spreadsheet/SpreadsheetWidget.h new file mode 100644 index 0000000000..b29fc557d6 --- /dev/null +++ b/Userland/Applications/Spreadsheet/SpreadsheetWidget.h @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "SpreadsheetView.h" +#include "Workbook.h" +#include <AK/NonnullRefPtrVector.h> +#include <LibGUI/Widget.h> + +namespace Spreadsheet { + +class SpreadsheetWidget final : public GUI::Widget { + C_OBJECT(SpreadsheetWidget); + +public: + ~SpreadsheetWidget(); + + void save(const StringView& filename); + void load(const StringView& filename); + bool request_close(); + void add_sheet(); + void add_sheet(NonnullRefPtr<Sheet>&&); + + const String& current_filename() const { return m_workbook->current_filename(); } + const Sheet& current_worksheet() const { return m_selected_view->sheet(); } + Sheet& current_worksheet() { return m_selected_view->sheet(); } + void set_filename(const String& filename); + + Workbook& workbook() { return *m_workbook; } + const Workbook& workbook() const { return *m_workbook; } + + const GUI::ModelIndex* current_selection_cursor() const + { + if (!m_selected_view) + return nullptr; + + return m_selected_view->cursor(); + } + +private: + virtual void resize_event(GUI::ResizeEvent&) override; + + explicit SpreadsheetWidget(NonnullRefPtrVector<Sheet>&& sheets = {}, bool should_add_sheet_if_empty = true); + + void setup_tabs(NonnullRefPtrVector<Sheet> new_sheets); + + void try_generate_tip_for_input_expression(StringView source, size_t offset); + + SpreadsheetView* m_selected_view { nullptr }; + RefPtr<GUI::Label> m_current_cell_label; + RefPtr<GUI::TextEditor> m_cell_value_editor; + RefPtr<GUI::Window> m_inline_documentation_window; + RefPtr<GUI::Label> m_inline_documentation_label; + RefPtr<GUI::TabWidget> m_tab_widget; + RefPtr<GUI::Menu> m_tab_context_menu; + RefPtr<SpreadsheetView> m_tab_context_menu_sheet_view; + bool m_should_change_selected_cells { false }; + + OwnPtr<Workbook> m_workbook; +}; + +} diff --git a/Userland/Applications/Spreadsheet/Workbook.cpp b/Userland/Applications/Spreadsheet/Workbook.cpp new file mode 100644 index 0000000000..db5bf16cb5 --- /dev/null +++ b/Userland/Applications/Spreadsheet/Workbook.cpp @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "Workbook.h" +#include "JSIntegration.h" +#include "Readers/CSV.h" +#include "Writers/CSV.h" +#include <AK/ByteBuffer.h> +#include <AK/JsonArray.h> +#include <AK/JsonObject.h> +#include <AK/JsonObjectSerializer.h> +#include <AK/JsonParser.h> +#include <AK/Stream.h> +#include <LibCore/File.h> +#include <LibCore/FileStream.h> +#include <LibCore/MimeData.h> +#include <LibJS/Parser.h> +#include <LibJS/Runtime/GlobalObject.h> +#include <string.h> + +namespace Spreadsheet { + +static JS::VM& global_vm() +{ + static RefPtr<JS::VM> vm; + if (!vm) + vm = JS::VM::create(); + return *vm; +} + +Workbook::Workbook(NonnullRefPtrVector<Sheet>&& sheets) + : m_sheets(move(sheets)) + , m_interpreter(JS::Interpreter::create<JS::GlobalObject>(global_vm())) + , m_interpreter_scope(JS::VM::InterpreterExecutionScope(interpreter())) +{ + m_workbook_object = interpreter().heap().allocate<WorkbookObject>(global_object(), *this); + global_object().put("workbook", workbook_object()); +} + +bool Workbook::set_filename(const String& filename) +{ + if (m_current_filename == filename) + return false; + + m_current_filename = filename; + return true; +} + +Result<bool, String> Workbook::load(const StringView& filename) +{ + auto file_or_error = Core::File::open(filename, Core::IODevice::OpenMode::ReadOnly); + if (file_or_error.is_error()) { + StringBuilder sb; + sb.append("Failed to open "); + sb.append(filename); + sb.append(" for reading. Error: "); + sb.append(file_or_error.error()); + + return sb.to_string(); + } + + auto mime = Core::guess_mime_type_based_on_filename(filename); + + if (mime == "text/csv") { + // FIXME: Prompt the user for settings. + NonnullRefPtrVector<Sheet> sheets; + + auto sheet = Sheet::from_xsv(Reader::CSV(file_or_error.value()->read_all(), Reader::default_behaviours() | Reader::ParserBehaviour::ReadHeaders), *this); + if (sheet) + sheets.append(sheet.release_nonnull()); + + m_sheets.clear(); + m_sheets = move(sheets); + } else { + // Assume JSON. + auto json_value_option = JsonParser(file_or_error.value()->read_all()).parse(); + if (!json_value_option.has_value()) { + StringBuilder sb; + sb.append("Failed to parse "); + sb.append(filename); + + return sb.to_string(); + } + + auto& json_value = json_value_option.value(); + if (!json_value.is_array()) { + StringBuilder sb; + sb.append("Did not find a spreadsheet in "); + sb.append(filename); + + return sb.to_string(); + } + + NonnullRefPtrVector<Sheet> sheets; + + auto& json_array = json_value.as_array(); + json_array.for_each([&](auto& sheet_json) { + if (!sheet_json.is_object()) + return IterationDecision::Continue; + + auto sheet = Sheet::from_json(sheet_json.as_object(), *this); + if (!sheet) + return IterationDecision::Continue; + + sheets.append(sheet.release_nonnull()); + + return IterationDecision::Continue; + }); + + m_sheets.clear(); + m_sheets = move(sheets); + } + + set_filename(filename); + + return true; +} + +Result<bool, String> Workbook::save(const StringView& filename) +{ + auto mime = Core::guess_mime_type_based_on_filename(filename); + auto file = Core::File::construct(filename); + file->open(Core::IODevice::WriteOnly); + if (!file->is_open()) { + StringBuilder sb; + sb.append("Failed to open "); + sb.append(filename); + sb.append(" for write. Error: "); + sb.append(file->error_string()); + + return sb.to_string(); + } + + if (mime == "text/csv") { + // FIXME: Prompt the user for settings and which sheet to export. + Core::OutputFileStream stream { file }; + auto data = m_sheets[0].to_xsv(); + auto header_string = data.take_first(); + Vector<StringView> headers; + for (auto& str : header_string) + headers.append(str); + Writer::CSV csv { stream, data, headers }; + if (csv.has_error()) + return String::formatted("Unable to save file, CSV writer error: {}", csv.error_string()); + } else { + JsonArray array; + for (auto& sheet : m_sheets) + array.append(sheet.to_json()); + + auto file_content = array.to_string(); + bool result = file->write(file_content); + if (!result) { + int error_number = errno; + StringBuilder sb; + sb.append("Unable to save file. Error: "); + sb.append(strerror(error_number)); + + return sb.to_string(); + } + } + + set_filename(filename); + set_dirty(false); + return true; +} + +} diff --git a/Userland/Applications/Spreadsheet/Workbook.h b/Userland/Applications/Spreadsheet/Workbook.h new file mode 100644 index 0000000000..63c016d501 --- /dev/null +++ b/Userland/Applications/Spreadsheet/Workbook.h @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "Forward.h" +#include "Spreadsheet.h" +#include <AK/NonnullOwnPtrVector.h> +#include <AK/Result.h> + +namespace Spreadsheet { + +class Workbook { +public: + Workbook(NonnullRefPtrVector<Sheet>&& sheets); + + Result<bool, String> save(const StringView& filename); + Result<bool, String> load(const StringView& filename); + + const String& current_filename() const { return m_current_filename; } + bool set_filename(const String& filename); + bool dirty() { return m_dirty; } + void set_dirty(bool dirty) { m_dirty = dirty; } + + bool has_sheets() const { return !m_sheets.is_empty(); } + + const NonnullRefPtrVector<Sheet>& sheets() const { return m_sheets; } + NonnullRefPtrVector<Sheet> sheets() { return m_sheets; } + + Sheet& add_sheet(const StringView& name) + { + auto sheet = Sheet::construct(name, *this); + m_sheets.append(sheet); + return *sheet; + } + + JS::Interpreter& interpreter() { return *m_interpreter; } + const JS::Interpreter& interpreter() const { return *m_interpreter; } + + JS::GlobalObject& global_object() { return m_interpreter->global_object(); } + const JS::GlobalObject& global_object() const { return m_interpreter->global_object(); } + + WorkbookObject* workbook_object() { return m_workbook_object; } + +private: + NonnullRefPtrVector<Sheet> m_sheets; + NonnullOwnPtr<JS::Interpreter> m_interpreter; + JS::VM::InterpreterExecutionScope m_interpreter_scope; + WorkbookObject* m_workbook_object { nullptr }; + + String m_current_filename; + bool m_dirty { false }; +}; + +} diff --git a/Userland/Applications/Spreadsheet/Writers/CSV.h b/Userland/Applications/Spreadsheet/Writers/CSV.h new file mode 100644 index 0000000000..49940fbdf1 --- /dev/null +++ b/Userland/Applications/Spreadsheet/Writers/CSV.h @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include "XSV.h" +#include <AK/Forward.h> +#include <AK/StringView.h> + +namespace Writer { + +template<typename ContainerType> +class CSV : public XSV<ContainerType> { +public: + CSV(OutputStream& output, const ContainerType& data, const Vector<StringView>& headers = {}, WriterBehaviour behaviours = default_behaviours()) + : XSV<ContainerType>(output, data, { ",", "\"", WriterTraits::Repeat }, headers, behaviours) + { + } +}; + +} diff --git a/Userland/Applications/Spreadsheet/Writers/Test/TestXSVWriter.cpp b/Userland/Applications/Spreadsheet/Writers/Test/TestXSVWriter.cpp new file mode 100644 index 0000000000..4971658431 --- /dev/null +++ b/Userland/Applications/Spreadsheet/Writers/Test/TestXSVWriter.cpp @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include <AK/TestSuite.h> + +#include "../CSV.h" +#include "../XSV.h" +#include <AK/MemoryStream.h> + +TEST_CASE(can_write) +{ + Vector<Vector<int>> data = { + { 1, 2, 3 }, + { 4, 5, 6 }, + { 7, 8, 9 }, + }; + + auto buffer = ByteBuffer::create_uninitialized(1024); + OutputMemoryStream stream { buffer }; + + Writer::CSV csv(stream, data); + + auto expected_output = R"~(1,2,3 +4,5,6 +7,8,9 +)~"; + + EXPECT_EQ(StringView { stream.bytes() }, expected_output); +} + +TEST_CASE(can_write_with_header) +{ + Vector<Vector<int>> data = { + { 1, 2, 3 }, + { 4, 5, 6 }, + { 7, 8, 9 }, + }; + + auto buffer = ByteBuffer::create_uninitialized(1024); + OutputMemoryStream stream { buffer }; + + Writer::CSV csv(stream, data, { "A", "B\"", "C" }); + + auto expected_output = R"~(A,"B""",C +1,2,3 +4,5,6 +7,8,9 +)~"; + + EXPECT_EQ(StringView { stream.bytes() }, expected_output); +} + +TEST_CASE(can_write_with_different_behaviours) +{ + Vector<Vector<String>> data = { + { "Well", "Hello\"", "Friends" }, + { "We\"ll", "Hello,", " Friends" }, + }; + + auto buffer = ByteBuffer::create_uninitialized(1024); + OutputMemoryStream stream { buffer }; + + Writer::CSV csv(stream, data, { "A", "B\"", "C" }, Writer::WriterBehaviour::QuoteOnlyInFieldStart | Writer::WriterBehaviour::WriteHeaders); + + auto expected_output = R"~(A,B",C +Well,Hello",Friends +We"ll,"Hello,", Friends +)~"; + + EXPECT_EQ(StringView { stream.bytes() }, expected_output); +} + +TEST_MAIN(XSV) diff --git a/Userland/Applications/Spreadsheet/Writers/XSV.h b/Userland/Applications/Spreadsheet/Writers/XSV.h new file mode 100644 index 0000000000..7a065f87d1 --- /dev/null +++ b/Userland/Applications/Spreadsheet/Writers/XSV.h @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/GenericLexer.h> +#include <AK/OwnPtr.h> +#include <AK/Stream.h> +#include <AK/String.h> +#include <AK/StringView.h> +#include <AK/Types.h> +#include <AK/Vector.h> + +namespace Writer { + +enum class WriterBehaviour : u32 { + None = 0, + WriteHeaders = 1, + AllowNewlinesInFields = WriteHeaders << 1, + QuoteOnlyInFieldStart = WriteHeaders << 2, + QuoteAll = WriteHeaders << 3, +}; + +inline WriterBehaviour operator&(WriterBehaviour left, WriterBehaviour right) +{ + return static_cast<WriterBehaviour>(static_cast<u32>(left) & static_cast<u32>(right)); +} + +inline WriterBehaviour operator|(WriterBehaviour left, WriterBehaviour right) +{ + return static_cast<WriterBehaviour>(static_cast<u32>(left) | static_cast<u32>(right)); +} + +struct WriterTraits { + String separator; + String quote { "\"" }; + enum { + Repeat, + Backslash, + } quote_escape { Repeat }; +}; + +#define ENUMERATE_WRITE_ERRORS() \ + E(None, "No errors") \ + E(NonConformingColumnCount, "Header count does not match given column count") \ + E(InternalError, "Internal error") + +enum class WriteError { +#define E(name, _) name, + ENUMERATE_WRITE_ERRORS() +#undef E +}; + +inline constexpr WriterBehaviour default_behaviours() +{ + return WriterBehaviour::None; +} + +template<typename ContainerType> +class XSV { +public: + XSV(OutputStream& output, const ContainerType& data, const WriterTraits& traits, const Vector<StringView>& headers = {}, WriterBehaviour behaviours = default_behaviours()) + : m_data(data) + , m_traits(traits) + , m_behaviours(behaviours) + , m_names(headers) + , m_output(output) + { + if (!headers.is_empty()) + m_behaviours = m_behaviours | WriterBehaviour::WriteHeaders; + + generate(); + } + + virtual ~XSV() { } + + bool has_error() const { return m_error != WriteError::None; } + WriteError error() const { return m_error; } + String error_string() const + { + switch (m_error) { +#define E(x, y) \ + case WriteError::x: \ + return y; + + ENUMERATE_WRITE_ERRORS(); +#undef E + } + ASSERT_NOT_REACHED(); + } + +private: + void set_error(WriteError error) + { + if (m_error == WriteError::None) + m_error = error; + } + + void generate() + { + auto with_headers = (m_behaviours & WriterBehaviour::WriteHeaders) != WriterBehaviour::None; + if (with_headers) { + write_row(m_names); + if (m_output.write({ "\n", 1 }) != 1) + set_error(WriteError::InternalError); + } + + for (auto&& row : m_data) { + if (with_headers) { + if (row.size() != m_names.size()) + set_error(WriteError::NonConformingColumnCount); + } + + write_row(row); + if (m_output.write({ "\n", 1 }) != 1) + set_error(WriteError::InternalError); + } + } + + template<typename T> + void write_row(T&& row) + { + bool first = true; + for (auto&& entry : row) { + if (!first) { + if (m_output.write(m_traits.separator.bytes()) != m_traits.separator.length()) + set_error(WriteError::InternalError); + } + first = false; + write_entry(entry); + } + } + + template<typename T> + void write_entry(T&& entry) + { + auto string = String::formatted("{}", FormatIfSupported(entry)); + + auto safe_to_write_normally = !string.contains("\n") && !string.contains(m_traits.separator); + if (safe_to_write_normally) { + if ((m_behaviours & WriterBehaviour::QuoteOnlyInFieldStart) == WriterBehaviour::None) + safe_to_write_normally = !string.contains(m_traits.quote); + else + safe_to_write_normally = !string.starts_with(m_traits.quote); + } + if (safe_to_write_normally) { + if (m_output.write(string.bytes()) != string.length()) + set_error(WriteError::InternalError); + return; + } + + if (m_output.write(m_traits.quote.bytes()) != m_traits.quote.length()) + set_error(WriteError::InternalError); + + GenericLexer lexer(string); + while (!lexer.is_eof()) { + if (lexer.consume_specific(m_traits.quote)) { + switch (m_traits.quote_escape) { + case WriterTraits::Repeat: + if (m_output.write(m_traits.quote.bytes()) != m_traits.quote.length()) + set_error(WriteError::InternalError); + if (m_output.write(m_traits.quote.bytes()) != m_traits.quote.length()) + set_error(WriteError::InternalError); + break; + case WriterTraits::Backslash: + if (m_output.write({ "\\", 1 }) != 1) + set_error(WriteError::InternalError); + if (m_output.write(m_traits.quote.bytes()) != m_traits.quote.length()) + set_error(WriteError::InternalError); + break; + } + continue; + } + + auto ch = lexer.consume(); + if (m_output.write({ &ch, 1 }) != 1) + set_error(WriteError::InternalError); + } + + if (m_output.write(m_traits.quote.bytes()) != m_traits.quote.length()) + set_error(WriteError::InternalError); + } + + const ContainerType& m_data; + const WriterTraits& m_traits; + WriterBehaviour m_behaviours; + const Vector<StringView>& m_names; + WriteError m_error { WriteError::None }; + OutputStream& m_output; +}; + +} diff --git a/Userland/Applications/Spreadsheet/main.cpp b/Userland/Applications/Spreadsheet/main.cpp new file mode 100644 index 0000000000..e13a89cdac --- /dev/null +++ b/Userland/Applications/Spreadsheet/main.cpp @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "HelpWindow.h" +#include "Spreadsheet.h" +#include "SpreadsheetWidget.h" +#include <AK/ScopeGuard.h> +#include <LibCore/ArgsParser.h> +#include <LibCore/File.h> +#include <LibGUI/Application.h> +#include <LibGUI/Clipboard.h> +#include <LibGUI/FilePicker.h> +#include <LibGUI/Forward.h> +#include <LibGUI/Icon.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/Window.h> + +int main(int argc, char* argv[]) +{ + if (pledge("stdio shared_buffer accept rpath unix cpath wpath fattr thread", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio thread rpath accept cpath wpath shared_buffer unix", nullptr) < 0) { + perror("pledge"); + return 1; + } + + const char* filename = nullptr; + + Core::ArgsParser args_parser; + args_parser.add_positional_argument(filename, "File to read from", "file", Core::ArgsParser::Required::No); + + args_parser.parse(argc, argv); + + if (filename) { + if (!Core::File::exists(filename) || Core::File::is_directory(filename)) { + warnln("File does not exist or is a directory: {}", filename); + return 1; + } + } + + if (unveil("/tmp/portal/webcontent", "rw") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/etc", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil(Core::StandardPaths::home_directory().characters(), "rwc") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/res", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil(nullptr, nullptr) < 0) { + perror("unveil"); + return 1; + } + + auto app_icon = GUI::Icon::default_icon("app-spreadsheet"); + auto window = GUI::Window::construct(); + window->set_title("Spreadsheet"); + window->resize(640, 480); + window->set_icon(app_icon.bitmap_for_size(16)); + + auto& spreadsheet_widget = window->set_main_widget<Spreadsheet::SpreadsheetWidget>(NonnullRefPtrVector<Spreadsheet::Sheet> {}, filename == nullptr); + + if (filename) + spreadsheet_widget.load(filename); + + auto menubar = GUI::MenuBar::construct(); + auto& app_menu = menubar->add_menu("Spreadsheet"); + + app_menu.add_action(GUI::Action::create("Add New Sheet", Gfx::Bitmap::load_from_file("/res/icons/16x16/new-tab.png"), [&](auto&) { + spreadsheet_widget.add_sheet(); + })); + + app_menu.add_separator(); + + app_menu.add_action(GUI::CommonActions::make_quit_action([&](auto&) { + if (!spreadsheet_widget.request_close()) + return; + app->quit(0); + })); + + window->on_close_request = [&]() -> GUI::Window::CloseRequestDecision { + if (spreadsheet_widget.request_close()) + return GUI::Window::CloseRequestDecision::Close; + return GUI::Window::CloseRequestDecision::StayOpen; + }; + + auto& file_menu = menubar->add_menu("File"); + file_menu.add_action(GUI::CommonActions::make_open_action([&](auto&) { + Optional<String> load_path = GUI::FilePicker::get_open_filepath(window); + if (!load_path.has_value()) + return; + + spreadsheet_widget.load(load_path.value()); + })); + + file_menu.add_action(GUI::CommonActions::make_save_action([&](auto&) { + if (spreadsheet_widget.current_filename().is_empty()) { + String name = "workbook"; + Optional<String> save_path = GUI::FilePicker::get_save_filepath(window, name, "sheets"); + if (!save_path.has_value()) + return; + + spreadsheet_widget.save(save_path.value()); + } else { + spreadsheet_widget.save(spreadsheet_widget.current_filename()); + } + })); + + file_menu.add_action(GUI::CommonActions::make_save_as_action([&](auto&) { + auto current_filename = spreadsheet_widget.current_filename(); + String name = "workbook"; + Optional<String> save_path = GUI::FilePicker::get_save_filepath(window, name, "sheets"); + if (!save_path.has_value()) + return; + + spreadsheet_widget.save(save_path.value()); + + if (!current_filename.is_empty()) + spreadsheet_widget.set_filename(current_filename); + })); + + auto& edit_menu = menubar->add_menu("Edit"); + edit_menu.add_action(GUI::CommonActions::make_copy_action([&](auto&) { + /// text/x-spreadsheet-data: + /// - currently selected cell + /// - selected cell+ + auto& cells = spreadsheet_widget.current_worksheet().selected_cells(); + ASSERT(!cells.is_empty()); + StringBuilder text_builder, url_builder; + bool first = true; + auto cursor = spreadsheet_widget.current_selection_cursor(); + if (cursor) { + Spreadsheet::Position position { spreadsheet_widget.current_worksheet().column(cursor->column()), (size_t)cursor->row() }; + url_builder.append(position.to_url().to_string()); + url_builder.append('\n'); + } + + for (auto& cell : cells) { + if (first && !cursor) { + url_builder.append(cell.to_url().to_string()); + url_builder.append('\n'); + } + + url_builder.append(cell.to_url().to_string()); + url_builder.append('\n'); + + auto cell_data = spreadsheet_widget.current_worksheet().at(cell); + if (!first) + text_builder.append('\t'); + if (cell_data) + text_builder.append(cell_data->data()); + first = false; + } + HashMap<String, String> metadata; + metadata.set("text/x-spreadsheet-data", url_builder.to_string()); + + GUI::Clipboard::the().set_data(text_builder.string_view().bytes(), "text/plain", move(metadata)); + }, + window)); + edit_menu.add_action(GUI::CommonActions::make_paste_action([&](auto&) { + ScopeGuard update_after_paste { [&] { spreadsheet_widget.update(); } }; + + auto& cells = spreadsheet_widget.current_worksheet().selected_cells(); + ASSERT(!cells.is_empty()); + const auto& data = GUI::Clipboard::the().data_and_type(); + if (auto spreadsheet_data = data.metadata.get("text/x-spreadsheet-data"); spreadsheet_data.has_value()) { + Vector<Spreadsheet::Position> source_positions, target_positions; + auto& sheet = spreadsheet_widget.current_worksheet(); + + for (auto& line : spreadsheet_data.value().split_view('\n')) { + dbgln("Paste line '{}'", line); + auto position = sheet.position_from_url(line); + if (position.has_value()) + source_positions.append(position.release_value()); + } + + for (auto& position : spreadsheet_widget.current_worksheet().selected_cells()) + target_positions.append(position); + + if (source_positions.is_empty()) + return; + + auto first_position = source_positions.take_first(); + sheet.copy_cells(move(source_positions), move(target_positions), first_position); + } else { + for (auto& cell : spreadsheet_widget.current_worksheet().selected_cells()) + spreadsheet_widget.current_worksheet().ensure(cell).set_data(StringView { data.data.data(), data.data.size() }); + spreadsheet_widget.update(); + } + }, + window)); + + auto& help_menu = menubar->add_menu("Help"); + + help_menu.add_action(GUI::Action::create( + "Functions Help", [&](auto&) { + auto docs = spreadsheet_widget.current_worksheet().gather_documentation(); + auto help_window = Spreadsheet::HelpWindow::the(window); + help_window->set_docs(move(docs)); + help_window->show(); + }, + window)); + + app_menu.add_separator(); + + help_menu.add_action(GUI::CommonActions::make_about_action("Spreadsheet", app_icon, window)); + + app->set_menubar(move(menubar)); + + window->show(); + + return app->exec(); +} diff --git a/Userland/Applications/SystemMonitor/CMakeLists.txt b/Userland/Applications/SystemMonitor/CMakeLists.txt new file mode 100644 index 0000000000..ee2e4d2323 --- /dev/null +++ b/Userland/Applications/SystemMonitor/CMakeLists.txt @@ -0,0 +1,16 @@ +set(SOURCES + DevicesModel.cpp + GraphWidget.cpp + InterruptsWidget.cpp + main.cpp + MemoryStatsWidget.cpp + NetworkStatisticsWidget.cpp + ProcessFileDescriptorMapWidget.cpp + ProcessMemoryMapWidget.cpp + ProcessModel.cpp + ProcessUnveiledPathsWidget.cpp + ThreadStackWidget.cpp +) + +serenity_app(SystemMonitor ICON app-system-monitor) +target_link_libraries(SystemMonitor LibGUI LibPCIDB) diff --git a/Userland/Applications/SystemMonitor/DevicesModel.cpp b/Userland/Applications/SystemMonitor/DevicesModel.cpp new file mode 100644 index 0000000000..f0692cdd24 --- /dev/null +++ b/Userland/Applications/SystemMonitor/DevicesModel.cpp @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2019-2020, Sergey Bugaev <bugaevc@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "DevicesModel.h" +#include <AK/JsonArray.h> +#include <AK/JsonObject.h> +#include <AK/JsonValue.h> +#include <LibCore/DirIterator.h> +#include <LibCore/File.h> +#include <sys/stat.h> + +NonnullRefPtr<DevicesModel> DevicesModel::create() +{ + return adopt(*new DevicesModel); +} + +DevicesModel::DevicesModel() +{ +} + +DevicesModel::~DevicesModel() +{ +} + +int DevicesModel::row_count(const GUI::ModelIndex&) const +{ + return m_devices.size(); +} + +int DevicesModel::column_count(const GUI::ModelIndex&) const +{ + return Column::__Count; +} + +String DevicesModel::column_name(int column) const +{ + switch (column) { + case Column::Device: + return "Device"; + case Column::Major: + return "Major"; + case Column::Minor: + return "Minor"; + case Column::ClassName: + return "Class"; + case Column::Type: + return "Type"; + default: + ASSERT_NOT_REACHED(); + } +} + +GUI::Variant DevicesModel::data(const GUI::ModelIndex& index, GUI::ModelRole role) const +{ + ASSERT(is_valid(index)); + + if (role == GUI::ModelRole::TextAlignment) { + switch (index.column()) { + case Column::Device: + return Gfx::TextAlignment::CenterLeft; + case Column::Major: + return Gfx::TextAlignment::CenterRight; + case Column::Minor: + return Gfx::TextAlignment::CenterRight; + case Column::ClassName: + return Gfx::TextAlignment::CenterLeft; + case Column::Type: + return Gfx::TextAlignment::CenterLeft; + } + return {}; + } + + if (role == GUI::ModelRole::Sort) { + const DeviceInfo& device = m_devices[index.row()]; + switch (index.column()) { + case Column::Device: + return device.path; + case Column::Major: + return device.major; + case Column::Minor: + return device.minor; + case Column::ClassName: + return device.class_name; + case Column::Type: + return device.type; + default: + ASSERT_NOT_REACHED(); + } + } + + if (role == GUI::ModelRole::Display) { + const DeviceInfo& device = m_devices[index.row()]; + switch (index.column()) { + case Column::Device: + return device.path; + case Column::Major: + return device.major; + case Column::Minor: + return device.minor; + case Column::ClassName: + return device.class_name; + case Column::Type: + switch (device.type) { + case DeviceInfo::Type::Block: + return "Block"; + case DeviceInfo::Type::Character: + return "Character"; + default: + ASSERT_NOT_REACHED(); + } + default: + ASSERT_NOT_REACHED(); + } + } + + return {}; +} + +void DevicesModel::update() +{ + auto proc_devices = Core::File::construct("/proc/devices"); + if (!proc_devices->open(Core::IODevice::OpenMode::ReadOnly)) + ASSERT_NOT_REACHED(); + + auto json = JsonValue::from_string(proc_devices->read_all()); + ASSERT(json.has_value()); + + m_devices.clear(); + json.value().as_array().for_each([this](auto& value) { + JsonObject device = value.as_object(); + DeviceInfo device_info; + + device_info.major = device.get("major").to_uint(); + device_info.minor = device.get("minor").to_uint(); + device_info.class_name = device.get("class_name").to_string(); + + String type_str = device.get("type").to_string(); + if (type_str == "block") + device_info.type = DeviceInfo::Type::Block; + else if (type_str == "character") + device_info.type = DeviceInfo::Type::Character; + else + ASSERT_NOT_REACHED(); + + m_devices.append(move(device_info)); + }); + + auto fill_in_paths_from_dir = [this](const String& dir) { + Core::DirIterator dir_iter { dir, Core::DirIterator::Flags::SkipDots }; + while (dir_iter.has_next()) { + auto name = dir_iter.next_path(); + auto path = String::format("%s/%s", dir.characters(), name.characters()); + struct stat statbuf; + if (lstat(path.characters(), &statbuf) != 0) { + ASSERT_NOT_REACHED(); + } + if (!S_ISBLK(statbuf.st_mode) && !S_ISCHR(statbuf.st_mode)) + continue; + unsigned _major = major(statbuf.st_rdev); + unsigned _minor = minor(statbuf.st_rdev); + + auto it = m_devices.find_if([_major, _minor](const auto& device_info) { + return device_info.major == _major && device_info.minor == _minor; + }); + if (it != m_devices.end()) { + (*it).path = move(path); + } + } + }; + + fill_in_paths_from_dir("/dev"); + fill_in_paths_from_dir("/dev/pts"); + + did_update(); +} diff --git a/Userland/Applications/SystemMonitor/DevicesModel.h b/Userland/Applications/SystemMonitor/DevicesModel.h new file mode 100644 index 0000000000..dd8cacc7e0 --- /dev/null +++ b/Userland/Applications/SystemMonitor/DevicesModel.h @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2019-2020, Sergey Bugaev <bugaevc@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/String.h> +#include <AK/Vector.h> +#include <LibGUI/Model.h> + +class DevicesModel final : public GUI::Model { +public: + enum Column { + Device = 0, + Major, + Minor, + ClassName, + Type, + __Count + }; + + virtual ~DevicesModel() override; + static NonnullRefPtr<DevicesModel> create(); + + virtual int row_count(const GUI::ModelIndex&) const override; + virtual int column_count(const GUI::ModelIndex&) const override; + virtual String column_name(int column) const override; + virtual GUI::Variant data(const GUI::ModelIndex&, GUI::ModelRole) const override; + virtual void update() override; + +private: + DevicesModel(); + + struct DeviceInfo { + String path; + unsigned major; + unsigned minor; + String class_name; + enum Type { + Block, + Character + }; + Type type; + }; + + Vector<DeviceInfo> m_devices; +}; diff --git a/Userland/Applications/SystemMonitor/GraphWidget.cpp b/Userland/Applications/SystemMonitor/GraphWidget.cpp new file mode 100644 index 0000000000..5c1d9982d4 --- /dev/null +++ b/Userland/Applications/SystemMonitor/GraphWidget.cpp @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "GraphWidget.h" +#include <LibGUI/Painter.h> +#include <LibGfx/Font.h> +#include <LibGfx/Path.h> + +GraphWidget::GraphWidget() +{ +} + +GraphWidget::~GraphWidget() +{ +} + +void GraphWidget::add_value(Vector<int, 1>&& value) +{ + m_values.enqueue(move(value)); + update(); +} + +void GraphWidget::paint_event(GUI::PaintEvent& event) +{ + GUI::Frame::paint_event(event); + GUI::Painter painter(*this); + painter.add_clip_rect(event.rect()); + painter.add_clip_rect(frame_inner_rect()); + painter.fill_rect(event.rect(), m_background_color); + + auto inner_rect = frame_inner_rect(); + float scale = (float)inner_rect.height() / (float)m_max; + + if (!m_values.is_empty()) { + // Draw one set of values at a time + for (size_t k = 0; k < m_value_format.size(); k++) { + const auto& format = m_value_format[k]; + if (format.line_color == Color::Transparent && format.background_color == Color::Transparent) + continue; + m_calculated_points.clear_with_capacity(); + for (size_t i = 0; i < m_values.size(); i++) { + int x = inner_rect.right() - (i * 2) + 1; + if (x < 0) + break; + const auto& current_values = m_values.at(m_values.size() - i - 1); + if (current_values.size() <= k) { + // Don't have a data point + m_calculated_points.append({ -1, -1 }); + continue; + } + float value = current_values[k]; + if (m_stack_values) { + for (size_t l = k + 1; l < current_values.size(); l++) + value += current_values[l]; + } + float scaled_value = value * scale; + Gfx::IntPoint current_point { x, inner_rect.bottom() - (int)scaled_value }; + m_calculated_points.append(current_point); + } + ASSERT(m_calculated_points.size() <= m_values.size()); + if (format.background_color != Color::Transparent) { + // Fill the background for the area we have values for + Gfx::Path path; + size_t points_in_path = 0; + bool started_path = false; + const Gfx::IntPoint* current_point = nullptr; + const Gfx::IntPoint* first_point = nullptr; + auto check_fill_area = [&]() { + if (!started_path) + return; + if (points_in_path > 1) { + ASSERT(current_point); + ASSERT(first_point); + path.line_to({ current_point->x() - 1, inner_rect.bottom() + 1 }); + path.line_to({ first_point->x() + 1, inner_rect.bottom() + 1 }); + path.close(); + painter.fill_path(path, format.background_color, Gfx::Painter::WindingRule::EvenOdd); + } else if (points_in_path == 1 && current_point) { + // Can't fill any area, we only have one data point. + // Just draw a vertical line as a "fill"... + painter.draw_line(*current_point, { current_point->x(), inner_rect.bottom() }, format.background_color); + } + path = {}; + points_in_path = 0; + first_point = nullptr; + started_path = false; + }; + for (size_t i = 0; i < m_calculated_points.size(); i++) { + current_point = &m_calculated_points[i]; + if (current_point->x() < 0) { + check_fill_area(); + continue; + } + if (!started_path) { + path.move_to({ current_point->x() + 1, current_point->y() }); + points_in_path = 1; + first_point = current_point; + started_path = true; + } else { + path.line_to({ current_point->x(), current_point->y() }); + points_in_path++; + } + } + check_fill_area(); + } + if (format.line_color != Color::Transparent) { + // Draw the line for the data points we have + const Gfx::IntPoint* previous_point = nullptr; + for (size_t i = 0; i < m_calculated_points.size(); i++) { + const auto& current_point = m_calculated_points[i]; + if (current_point.x() < 0) { + previous_point = nullptr; + continue; + } + if (previous_point) + painter.draw_line(*previous_point, current_point, format.line_color); + previous_point = ¤t_point; + } + } + } + } + + if (!m_values.is_empty() && !m_value_format.is_empty()) { + const auto& current_values = m_values.last(); + int y = 0; + for (size_t i = 0; i < min(m_value_format.size(), current_values.size()); i++) { + const auto& format = m_value_format[i]; + if (!format.text_formatter) + continue; + auto constrain_rect = inner_rect.shrunken(8, 8); + auto text_rect = constrain_rect.translated(0, y).intersected(constrain_rect); + text_rect.set_height(font().glyph_height()); + auto text = format.text_formatter(current_values[i]); + if (format.text_shadow_color != Color::Transparent) + painter.draw_text(text_rect.translated(1, 1), text.characters(), Gfx::TextAlignment::CenterRight, format.text_shadow_color); + painter.draw_text(text_rect, text.characters(), Gfx::TextAlignment::CenterRight, format.line_color); + y += text_rect.height() + 4; + } + } +} diff --git a/Userland/Applications/SystemMonitor/GraphWidget.h b/Userland/Applications/SystemMonitor/GraphWidget.h new file mode 100644 index 0000000000..fc40f59bbd --- /dev/null +++ b/Userland/Applications/SystemMonitor/GraphWidget.h @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/CircularQueue.h> +#include <LibGUI/Frame.h> + +class GraphWidget final : public GUI::Frame { + C_OBJECT(GraphWidget) +public: + virtual ~GraphWidget() override; + + void set_max(int max) { m_max = max; } + int max() const { return m_max; } + + void add_value(Vector<int, 1>&&); + + void set_background_color(Color color) { m_background_color = color; } + + struct ValueFormat { + Color line_color { Color::Transparent }; + Color background_color { Color::Transparent }; + Color text_shadow_color { Color::Transparent }; + Function<String(int)> text_formatter; + }; + void set_value_format(size_t index, ValueFormat&& format) + { + if (m_value_format.size() <= index) + m_value_format.resize(index + 1); + m_value_format[index] = move(format); + } + void set_stack_values(bool stack_values) { m_stack_values = stack_values; } + +private: + explicit GraphWidget(); + + virtual void paint_event(GUI::PaintEvent&) override; + + int m_max { 100 }; + Vector<ValueFormat, 1> m_value_format; + CircularQueue<Vector<int, 1>, 4000> m_values; + Color m_background_color { Color::Black }; + bool m_stack_values { false }; + + Vector<Gfx::IntPoint, 1> m_calculated_points; +}; diff --git a/Userland/Applications/SystemMonitor/InterruptsWidget.cpp b/Userland/Applications/SystemMonitor/InterruptsWidget.cpp new file mode 100644 index 0000000000..3e56f29ec4 --- /dev/null +++ b/Userland/Applications/SystemMonitor/InterruptsWidget.cpp @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "InterruptsWidget.h" +#include <LibGUI/BoxLayout.h> +#include <LibGUI/GroupBox.h> +#include <LibGUI/JsonArrayModel.h> +#include <LibGUI/SortingProxyModel.h> +#include <LibGUI/TableView.h> + +InterruptsWidget::InterruptsWidget() +{ + on_first_show = [this](auto&) { + set_layout<GUI::VerticalBoxLayout>(); + layout()->set_margins({ 4, 4, 4, 4 }); + + Vector<GUI::JsonArrayModel::FieldSpec> interrupts_field; + interrupts_field.empend("interrupt_line", "Line", Gfx::TextAlignment::CenterRight); + interrupts_field.empend("purpose", "Purpose", Gfx::TextAlignment::CenterLeft); + interrupts_field.empend("controller", "Controller", Gfx::TextAlignment::CenterLeft); + interrupts_field.empend("cpu_handler", "CPU Handler", Gfx::TextAlignment::CenterRight); + interrupts_field.empend("device_sharing", "# Devices Sharing", Gfx::TextAlignment::CenterRight); + interrupts_field.empend("call_count", "Call Count", Gfx::TextAlignment::CenterRight); + + m_interrupt_table_view = add<GUI::TableView>(); + m_interrupt_model = GUI::JsonArrayModel::create("/proc/interrupts", move(interrupts_field)); + m_interrupt_table_view->set_model(GUI::SortingProxyModel::create(*m_interrupt_model)); + + m_update_timer = add<Core::Timer>( + 1000, [this] { + update_model(); + }); + + update_model(); + }; +} + +InterruptsWidget::~InterruptsWidget() +{ +} + +void InterruptsWidget::update_model() +{ + m_interrupt_table_view->model()->update(); +} diff --git a/Userland/Applications/SystemMonitor/InterruptsWidget.h b/Userland/Applications/SystemMonitor/InterruptsWidget.h new file mode 100644 index 0000000000..bc11215217 --- /dev/null +++ b/Userland/Applications/SystemMonitor/InterruptsWidget.h @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibCore/Timer.h> +#include <LibGUI/LazyWidget.h> + +class InterruptsWidget final : public GUI::LazyWidget { + C_OBJECT(InterruptsWidget) +public: + virtual ~InterruptsWidget() override; + +private: + InterruptsWidget(); + void update_model(); + + RefPtr<GUI::TableView> m_interrupt_table_view; + RefPtr<GUI::JsonArrayModel> m_interrupt_model; + RefPtr<Core::Timer> m_update_timer; +}; diff --git a/Userland/Applications/SystemMonitor/MemoryStatsWidget.cpp b/Userland/Applications/SystemMonitor/MemoryStatsWidget.cpp new file mode 100644 index 0000000000..6675ea8c4a --- /dev/null +++ b/Userland/Applications/SystemMonitor/MemoryStatsWidget.cpp @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "MemoryStatsWidget.h" +#include "GraphWidget.h" +#include <AK/ByteBuffer.h> +#include <AK/JsonObject.h> +#include <LibCore/File.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Label.h> +#include <LibGUI/Painter.h> +#include <LibGfx/Font.h> +#include <LibGfx/FontDatabase.h> +#include <LibGfx/StylePainter.h> +#include <stdio.h> +#include <stdlib.h> + +static MemoryStatsWidget* s_the; + +MemoryStatsWidget* MemoryStatsWidget::the() +{ + return s_the; +} + +MemoryStatsWidget::MemoryStatsWidget(GraphWidget& graph) + : m_graph(graph) +{ + ASSERT(!s_the); + s_the = this; + + set_fixed_height(110); + + set_layout<GUI::VerticalBoxLayout>(); + layout()->set_margins({ 0, 8, 0, 0 }); + layout()->set_spacing(3); + + auto build_widgets_for_label = [this](const String& description) -> RefPtr<GUI::Label> { + auto& container = add<GUI::Widget>(); + container.set_layout<GUI::HorizontalBoxLayout>(); + container.set_fixed_size(275, 12); + auto& description_label = container.add<GUI::Label>(description); + description_label.set_font(Gfx::FontDatabase::default_bold_font()); + description_label.set_text_alignment(Gfx::TextAlignment::CenterLeft); + auto& label = container.add<GUI::Label>(); + label.set_text_alignment(Gfx::TextAlignment::CenterRight); + return label; + }; + + m_user_physical_pages_label = build_widgets_for_label("Physical memory:"); + m_user_physical_pages_committed_label = build_widgets_for_label("Committed memory:"); + m_supervisor_physical_pages_label = build_widgets_for_label("Supervisor physical:"); + m_kmalloc_space_label = build_widgets_for_label("Kernel heap:"); + m_kmalloc_count_label = build_widgets_for_label("Calls kmalloc:"); + m_kfree_count_label = build_widgets_for_label("Calls kfree:"); + m_kmalloc_difference_label = build_widgets_for_label("Difference:"); + + refresh(); +} + +MemoryStatsWidget::~MemoryStatsWidget() +{ +} + +static inline size_t page_count_to_kb(size_t kb) +{ + return (kb * 4096) / 1024; +} + +static inline size_t bytes_to_kb(size_t bytes) +{ + return bytes / 1024; +} + +void MemoryStatsWidget::refresh() +{ + auto proc_memstat = Core::File::construct("/proc/memstat"); + if (!proc_memstat->open(Core::IODevice::OpenMode::ReadOnly)) + ASSERT_NOT_REACHED(); + + auto file_contents = proc_memstat->read_all(); + auto json_result = JsonValue::from_string(file_contents); + ASSERT(json_result.has_value()); + auto json = json_result.value().as_object(); + + [[maybe_unused]] unsigned kmalloc_eternal_allocated = json.get("kmalloc_eternal_allocated").to_u32(); + unsigned kmalloc_allocated = json.get("kmalloc_allocated").to_u32(); + unsigned kmalloc_available = json.get("kmalloc_available").to_u32(); + unsigned user_physical_allocated = json.get("user_physical_allocated").to_u32(); + unsigned user_physical_available = json.get("user_physical_available").to_u32(); + unsigned user_physical_committed = json.get("user_physical_committed").to_u32(); + unsigned user_physical_uncommitted = json.get("user_physical_uncommitted").to_u32(); + unsigned super_physical_alloc = json.get("super_physical_allocated").to_u32(); + unsigned super_physical_free = json.get("super_physical_available").to_u32(); + unsigned kmalloc_call_count = json.get("kmalloc_call_count").to_u32(); + unsigned kfree_call_count = json.get("kfree_call_count").to_u32(); + + size_t kmalloc_bytes_total = kmalloc_allocated + kmalloc_available; + size_t user_physical_pages_total = user_physical_allocated + user_physical_available; + size_t supervisor_pages_total = super_physical_alloc + super_physical_free; + + size_t physical_pages_total = user_physical_pages_total + supervisor_pages_total; + size_t physical_pages_in_use = user_physical_allocated + super_physical_alloc; + size_t total_userphysical_and_swappable_pages = user_physical_allocated + user_physical_committed + user_physical_uncommitted; + + m_kmalloc_space_label->set_text(String::formatted("{}K/{}K", bytes_to_kb(kmalloc_allocated), bytes_to_kb(kmalloc_bytes_total))); + m_user_physical_pages_label->set_text(String::formatted("{}K/{}K", page_count_to_kb(physical_pages_in_use), page_count_to_kb(physical_pages_total))); + m_user_physical_pages_committed_label->set_text(String::formatted("{}K", page_count_to_kb(user_physical_committed))); + m_supervisor_physical_pages_label->set_text(String::formatted("{}K/{}K", page_count_to_kb(super_physical_alloc), page_count_to_kb(supervisor_pages_total))); + m_kmalloc_count_label->set_text(String::formatted("{}", kmalloc_call_count)); + m_kfree_count_label->set_text(String::formatted("{}", kfree_call_count)); + m_kmalloc_difference_label->set_text(String::formatted("{:+}", kmalloc_call_count - kfree_call_count)); + + m_graph.set_max(page_count_to_kb(total_userphysical_and_swappable_pages) + bytes_to_kb(kmalloc_bytes_total)); + m_graph.add_value({ (int)page_count_to_kb(user_physical_committed), (int)page_count_to_kb(user_physical_allocated), (int)bytes_to_kb(kmalloc_bytes_total) }); +} diff --git a/Userland/Applications/SystemMonitor/MemoryStatsWidget.h b/Userland/Applications/SystemMonitor/MemoryStatsWidget.h new file mode 100644 index 0000000000..e915730257 --- /dev/null +++ b/Userland/Applications/SystemMonitor/MemoryStatsWidget.h @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Widget.h> + +class GraphWidget; + +class MemoryStatsWidget final : public GUI::Widget { + C_OBJECT(MemoryStatsWidget) +public: + static MemoryStatsWidget* the(); + + virtual ~MemoryStatsWidget() override; + + void refresh(); + +private: + MemoryStatsWidget(GraphWidget& graph); + + GraphWidget& m_graph; + RefPtr<GUI::Label> m_user_physical_pages_label; + RefPtr<GUI::Label> m_user_physical_pages_committed_label; + RefPtr<GUI::Label> m_supervisor_physical_pages_label; + RefPtr<GUI::Label> m_kmalloc_space_label; + RefPtr<GUI::Label> m_kmalloc_count_label; + RefPtr<GUI::Label> m_kfree_count_label; + RefPtr<GUI::Label> m_kmalloc_difference_label; +}; diff --git a/Userland/Applications/SystemMonitor/NetworkStatisticsWidget.cpp b/Userland/Applications/SystemMonitor/NetworkStatisticsWidget.cpp new file mode 100644 index 0000000000..7ee2450ee1 --- /dev/null +++ b/Userland/Applications/SystemMonitor/NetworkStatisticsWidget.cpp @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "NetworkStatisticsWidget.h" +#include <LibGUI/BoxLayout.h> +#include <LibGUI/GroupBox.h> +#include <LibGUI/JsonArrayModel.h> +#include <LibGUI/SortingProxyModel.h> +#include <LibGUI/TableView.h> + +NetworkStatisticsWidget::NetworkStatisticsWidget() +{ + on_first_show = [this](auto&) { + set_layout<GUI::VerticalBoxLayout>(); + layout()->set_margins({ 4, 4, 4, 4 }); + set_fill_with_background_color(true); + + auto& adapters_group_box = add<GUI::GroupBox>("Adapters"); + adapters_group_box.set_layout<GUI::VerticalBoxLayout>(); + adapters_group_box.layout()->set_margins({ 6, 16, 6, 6 }); + adapters_group_box.set_fixed_height(120); + + m_adapter_table_view = adapters_group_box.add<GUI::TableView>(); + + Vector<GUI::JsonArrayModel::FieldSpec> net_adapters_fields; + net_adapters_fields.empend("name", "Name", Gfx::TextAlignment::CenterLeft); + net_adapters_fields.empend("class_name", "Class", Gfx::TextAlignment::CenterLeft); + net_adapters_fields.empend("mac_address", "MAC", Gfx::TextAlignment::CenterLeft); + net_adapters_fields.empend("ipv4_address", "IPv4", Gfx::TextAlignment::CenterLeft); + net_adapters_fields.empend("packets_in", "Pkt In", Gfx::TextAlignment::CenterRight); + net_adapters_fields.empend("packets_out", "Pkt Out", Gfx::TextAlignment::CenterRight); + net_adapters_fields.empend("bytes_in", "Bytes In", Gfx::TextAlignment::CenterRight); + net_adapters_fields.empend("bytes_out", "Bytes Out", Gfx::TextAlignment::CenterRight); + m_adapter_model = GUI::JsonArrayModel::create("/proc/net/adapters", move(net_adapters_fields)); + m_adapter_table_view->set_model(GUI::SortingProxyModel::create(*m_adapter_model)); + + auto& sockets_group_box = add<GUI::GroupBox>("Sockets"); + sockets_group_box.set_layout<GUI::VerticalBoxLayout>(); + sockets_group_box.layout()->set_margins({ 6, 16, 6, 6 }); + + m_socket_table_view = sockets_group_box.add<GUI::TableView>(); + + Vector<GUI::JsonArrayModel::FieldSpec> net_tcp_fields; + net_tcp_fields.empend("peer_address", "Peer", Gfx::TextAlignment::CenterLeft); + net_tcp_fields.empend("peer_port", "Port", Gfx::TextAlignment::CenterRight); + net_tcp_fields.empend("local_address", "Local", Gfx::TextAlignment::CenterLeft); + net_tcp_fields.empend("local_port", "Port", Gfx::TextAlignment::CenterRight); + net_tcp_fields.empend("state", "State", Gfx::TextAlignment::CenterLeft); + net_tcp_fields.empend("ack_number", "Ack#", Gfx::TextAlignment::CenterRight); + net_tcp_fields.empend("sequence_number", "Seq#", Gfx::TextAlignment::CenterRight); + net_tcp_fields.empend("packets_in", "Pkt In", Gfx::TextAlignment::CenterRight); + net_tcp_fields.empend("packets_out", "Pkt Out", Gfx::TextAlignment::CenterRight); + net_tcp_fields.empend("bytes_in", "Bytes In", Gfx::TextAlignment::CenterRight); + net_tcp_fields.empend("bytes_out", "Bytes Out", Gfx::TextAlignment::CenterRight); + m_socket_model = GUI::JsonArrayModel::create("/proc/net/tcp", move(net_tcp_fields)); + m_socket_table_view->set_model(GUI::SortingProxyModel::create(*m_socket_model)); + + m_update_timer = add<Core::Timer>( + 1000, [this] { + update_models(); + }); + + update_models(); + }; +} + +NetworkStatisticsWidget::~NetworkStatisticsWidget() +{ +} + +void NetworkStatisticsWidget::update_models() +{ + m_adapter_table_view->model()->update(); + m_socket_table_view->model()->update(); +} diff --git a/Userland/Applications/SystemMonitor/NetworkStatisticsWidget.h b/Userland/Applications/SystemMonitor/NetworkStatisticsWidget.h new file mode 100644 index 0000000000..d49a9641c8 --- /dev/null +++ b/Userland/Applications/SystemMonitor/NetworkStatisticsWidget.h @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibCore/Timer.h> +#include <LibGUI/LazyWidget.h> + +class NetworkStatisticsWidget final : public GUI::LazyWidget { + C_OBJECT(NetworkStatisticsWidget) +public: + virtual ~NetworkStatisticsWidget() override; + +private: + NetworkStatisticsWidget(); + void update_models(); + + RefPtr<GUI::TableView> m_adapter_table_view; + RefPtr<GUI::TableView> m_socket_table_view; + RefPtr<GUI::JsonArrayModel> m_adapter_model; + RefPtr<GUI::JsonArrayModel> m_socket_model; + RefPtr<Core::Timer> m_update_timer; +}; diff --git a/Userland/Applications/SystemMonitor/ProcessFileDescriptorMapWidget.cpp b/Userland/Applications/SystemMonitor/ProcessFileDescriptorMapWidget.cpp new file mode 100644 index 0000000000..48dfae098d --- /dev/null +++ b/Userland/Applications/SystemMonitor/ProcessFileDescriptorMapWidget.cpp @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "ProcessFileDescriptorMapWidget.h" +#include <LibGUI/BoxLayout.h> +#include <LibGUI/JsonArrayModel.h> +#include <LibGUI/SortingProxyModel.h> +#include <LibGUI/TableView.h> + +ProcessFileDescriptorMapWidget::ProcessFileDescriptorMapWidget() +{ + set_layout<GUI::VerticalBoxLayout>(); + layout()->set_margins({ 4, 4, 4, 4 }); + m_table_view = add<GUI::TableView>(); + + Vector<GUI::JsonArrayModel::FieldSpec> pid_fds_fields; + pid_fds_fields.empend("fd", "FD", Gfx::TextAlignment::CenterRight); + pid_fds_fields.empend("class", "Class", Gfx::TextAlignment::CenterLeft); + pid_fds_fields.empend("offset", "Offset", Gfx::TextAlignment::CenterRight); + pid_fds_fields.empend("absolute_path", "Path", Gfx::TextAlignment::CenterLeft); + pid_fds_fields.empend("Access", Gfx::TextAlignment::CenterLeft, [](auto& object) { + return object.get("seekable").to_bool() ? "Seekable" : "Sequential"; + }); + pid_fds_fields.empend("Blocking", Gfx::TextAlignment::CenterLeft, [](auto& object) { + return object.get("blocking").to_bool() ? "Blocking" : "Nonblocking"; + }); + pid_fds_fields.empend("On exec", Gfx::TextAlignment::CenterLeft, [](auto& object) { + return object.get("cloexec").to_bool() ? "Close" : "Keep"; + }); + pid_fds_fields.empend("Can read", Gfx::TextAlignment::CenterLeft, [](auto& object) { + return object.get("can_read").to_bool() ? "Yes" : "No"; + }); + pid_fds_fields.empend("Can write", Gfx::TextAlignment::CenterLeft, [](auto& object) { + return object.get("can_write").to_bool() ? "Yes" : "No"; + }); + + m_model = GUI::JsonArrayModel::create({}, move(pid_fds_fields)); + m_table_view->set_model(GUI::SortingProxyModel::create(*m_model)); +} + +ProcessFileDescriptorMapWidget::~ProcessFileDescriptorMapWidget() +{ +} + +void ProcessFileDescriptorMapWidget::set_pid(pid_t pid) +{ + if (m_pid == pid) + return; + m_pid = pid; + m_model->set_json_path(String::formatted("/proc/{}/fds", m_pid)); +} diff --git a/Userland/Applications/SystemMonitor/ProcessFileDescriptorMapWidget.h b/Userland/Applications/SystemMonitor/ProcessFileDescriptorMapWidget.h new file mode 100644 index 0000000000..2c45577525 --- /dev/null +++ b/Userland/Applications/SystemMonitor/ProcessFileDescriptorMapWidget.h @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Widget.h> + +class ProcessFileDescriptorMapWidget final : public GUI::Widget { + C_OBJECT(ProcessFileDescriptorMapWidget); + +public: + virtual ~ProcessFileDescriptorMapWidget() override; + + void set_pid(pid_t); + +private: + ProcessFileDescriptorMapWidget(); + + RefPtr<GUI::TableView> m_table_view; + RefPtr<GUI::JsonArrayModel> m_model; + pid_t m_pid { -1 }; +}; diff --git a/Userland/Applications/SystemMonitor/ProcessMemoryMapWidget.cpp b/Userland/Applications/SystemMonitor/ProcessMemoryMapWidget.cpp new file mode 100644 index 0000000000..d36a7f57ab --- /dev/null +++ b/Userland/Applications/SystemMonitor/ProcessMemoryMapWidget.cpp @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "ProcessMemoryMapWidget.h" +#include <LibCore/Timer.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/JsonArrayModel.h> +#include <LibGUI/Painter.h> +#include <LibGUI/SortingProxyModel.h> +#include <LibGUI/TableView.h> +#include <LibGfx/Palette.h> + +class PagemapPaintingDelegate final : public GUI::TableCellPaintingDelegate { +public: + virtual ~PagemapPaintingDelegate() override { } + + virtual void paint(GUI::Painter& painter, const Gfx::IntRect& a_rect, const Gfx::Palette&, const GUI::ModelIndex& index) override + { + auto rect = a_rect.shrunken(2, 2); + auto pagemap = index.data(GUI::ModelRole::Custom).to_string(); + + float scale_factor = (float)pagemap.length() / (float)rect.width(); + + for (int i = 0; i < rect.width(); ++i) { + int x = rect.x() + i; + char c = pagemap[(float)i * scale_factor]; + Color color; + if (c == 'N') // Null (no page at all, typically an inode-backed page that hasn't been paged in.) + color = Color::White; + else if (c == 'Z') // Zero (globally shared zero page, typically an untouched anonymous page.) + color = Color::from_rgb(0xc0c0ff); + else if (c == 'P') // Physical (a resident page) + color = Color::Black; + else + ASSERT_NOT_REACHED(); + + painter.draw_line({ x, rect.top() }, { x, rect.bottom() }, color); + } + + painter.draw_rect(rect, Color::Black); + } +}; + +ProcessMemoryMapWidget::ProcessMemoryMapWidget() +{ + set_layout<GUI::VerticalBoxLayout>(); + layout()->set_margins({ 4, 4, 4, 4 }); + m_table_view = add<GUI::TableView>(); + Vector<GUI::JsonArrayModel::FieldSpec> pid_vm_fields; + pid_vm_fields.empend( + "Address", Gfx::TextAlignment::CenterLeft, + [](auto& object) { return String::formatted("{:#x}", object.get("address").to_u32()); }, + [](auto& object) { return object.get("address").to_u32(); }); + pid_vm_fields.empend("size", "Size", Gfx::TextAlignment::CenterRight); + pid_vm_fields.empend("amount_resident", "Resident", Gfx::TextAlignment::CenterRight); + pid_vm_fields.empend("amount_dirty", "Dirty", Gfx::TextAlignment::CenterRight); + pid_vm_fields.empend("Access", Gfx::TextAlignment::CenterLeft, [](auto& object) { + StringBuilder builder; + if (!object.get("user_accessible").to_bool()) + builder.append('K'); + if (object.get("readable").to_bool()) + builder.append('R'); + if (object.get("writable").to_bool()) + builder.append('W'); + if (object.get("executable").to_bool()) + builder.append('X'); + if (object.get("shared").to_bool()) + builder.append('S'); + if (object.get("stack").to_bool()) + builder.append('T'); + return builder.to_string(); + }); + pid_vm_fields.empend("vmobject", "VMObject type", Gfx::TextAlignment::CenterLeft); + pid_vm_fields.empend("Purgeable", Gfx::TextAlignment::CenterLeft, [](auto& object) { + if (object.get("volatile").to_bool()) + return "Volatile"; + return "Non-volatile"; + }); + pid_vm_fields.empend( + "Page map", Gfx::TextAlignment::CenterLeft, + [](auto&) { + return GUI::Variant(); + }, + [](auto&) { + return GUI::Variant(0); + }, + [](const JsonObject& object) { + auto pagemap = object.get("pagemap").as_string_or({}); + return pagemap; + }); + pid_vm_fields.empend("cow_pages", "# CoW", Gfx::TextAlignment::CenterRight); + pid_vm_fields.empend("name", "Name", Gfx::TextAlignment::CenterLeft); + m_json_model = GUI::JsonArrayModel::create({}, move(pid_vm_fields)); + m_table_view->set_model(GUI::SortingProxyModel::create(*m_json_model)); + + m_table_view->set_column_painting_delegate(7, make<PagemapPaintingDelegate>()); + + m_table_view->set_key_column_and_sort_order(0, GUI::SortOrder::Ascending); + m_timer = add<Core::Timer>(1000, [this] { refresh(); }); +} + +ProcessMemoryMapWidget::~ProcessMemoryMapWidget() +{ +} + +void ProcessMemoryMapWidget::set_pid(pid_t pid) +{ + if (m_pid == pid) + return; + m_pid = pid; + m_json_model->set_json_path(String::formatted("/proc/{}/vm", pid)); +} + +void ProcessMemoryMapWidget::refresh() +{ + if (m_pid != -1) + m_json_model->update(); +} diff --git a/Userland/Applications/SystemMonitor/ProcessMemoryMapWidget.h b/Userland/Applications/SystemMonitor/ProcessMemoryMapWidget.h new file mode 100644 index 0000000000..71a554fe51 --- /dev/null +++ b/Userland/Applications/SystemMonitor/ProcessMemoryMapWidget.h @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Widget.h> + +class ProcessMemoryMapWidget final : public GUI::Widget { + C_OBJECT(ProcessMemoryMapWidget); + +public: + virtual ~ProcessMemoryMapWidget() override; + + void set_pid(pid_t); + void refresh(); + +private: + ProcessMemoryMapWidget(); + RefPtr<GUI::TableView> m_table_view; + RefPtr<GUI::JsonArrayModel> m_json_model; + pid_t m_pid { -1 }; + RefPtr<Core::Timer> m_timer; +}; diff --git a/Userland/Applications/SystemMonitor/ProcessModel.cpp b/Userland/Applications/SystemMonitor/ProcessModel.cpp new file mode 100644 index 0000000000..c12a5503ab --- /dev/null +++ b/Userland/Applications/SystemMonitor/ProcessModel.cpp @@ -0,0 +1,454 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "ProcessModel.h" +#include "GraphWidget.h" +#include <AK/JsonArray.h> +#include <AK/JsonObject.h> +#include <AK/JsonValue.h> +#include <AK/SharedBuffer.h> +#include <LibCore/File.h> +#include <LibCore/ProcessStatisticsReader.h> +#include <LibGUI/FileIconProvider.h> +#include <fcntl.h> +#include <stdio.h> + +static ProcessModel* s_the; + +ProcessModel& ProcessModel::the() +{ + ASSERT(s_the); + return *s_the; +} + +ProcessModel::ProcessModel() +{ + ASSERT(!s_the); + s_the = this; + m_generic_process_icon = Gfx::Bitmap::load_from_file("/res/icons/16x16/gear.png"); + m_high_priority_icon = Gfx::Bitmap::load_from_file("/res/icons/16x16/highpriority.png"); + m_low_priority_icon = Gfx::Bitmap::load_from_file("/res/icons/16x16/lowpriority.png"); + m_normal_priority_icon = Gfx::Bitmap::load_from_file("/res/icons/16x16/normalpriority.png"); + + auto file = Core::File::construct("/proc/cpuinfo"); + if (file->open(Core::IODevice::ReadOnly)) { + auto json = JsonValue::from_string({ file->read_all() }); + auto cpuinfo_array = json.value().as_array(); + cpuinfo_array.for_each([&](auto& value) { + auto& cpu_object = value.as_object(); + auto cpu_id = cpu_object.get("processor").as_u32(); + m_cpus.append(make<CpuInfo>(cpu_id)); + }); + } + + if (m_cpus.is_empty()) + m_cpus.append(make<CpuInfo>(0)); +} + +ProcessModel::~ProcessModel() +{ +} + +int ProcessModel::row_count(const GUI::ModelIndex&) const +{ + return m_pids.size(); +} + +int ProcessModel::column_count(const GUI::ModelIndex&) const +{ + return Column::__Count; +} + +String ProcessModel::column_name(int column) const +{ + switch (column) { + case Column::Icon: + return ""; + case Column::PID: + return "PID"; + case Column::TID: + return "TID"; + case Column::PPID: + return "PPID"; + case Column::PGID: + return "PGID"; + case Column::SID: + return "SID"; + case Column::State: + return "State"; + case Column::User: + return "User"; + case Column::Priority: + return "Pr"; + case Column::EffectivePriority: + return "EPr"; + case Column::Virtual: + return "Virtual"; + case Column::Physical: + return "Physical"; + case Column::DirtyPrivate: + return "DirtyP"; + case Column::CleanInode: + return "CleanI"; + case Column::PurgeableVolatile: + return "Purg:V"; + case Column::PurgeableNonvolatile: + return "Purg:N"; + case Column::CPU: + return "CPU"; + case Column::Processor: + return "Processor"; + case Column::Name: + return "Name"; + case Column::Syscalls: + return "Syscalls"; + case Column::InodeFaults: + return "F:Inode"; + case Column::ZeroFaults: + return "F:Zero"; + case Column::CowFaults: + return "F:CoW"; + case Column::IPv4SocketReadBytes: + return "IPv4 In"; + case Column::IPv4SocketWriteBytes: + return "IPv4 Out"; + case Column::UnixSocketReadBytes: + return "Unix In"; + case Column::UnixSocketWriteBytes: + return "Unix Out"; + case Column::FileReadBytes: + return "File In"; + case Column::FileWriteBytes: + return "File Out"; + case Column::Pledge: + return "Pledge"; + case Column::Veil: + return "Veil"; + default: + ASSERT_NOT_REACHED(); + } +} + +static String pretty_byte_size(size_t size) +{ + return String::formatted("{}K", size / 1024); +} + +GUI::Variant ProcessModel::data(const GUI::ModelIndex& index, GUI::ModelRole role) const +{ + ASSERT(is_valid(index)); + + if (role == GUI::ModelRole::TextAlignment) { + switch (index.column()) { + case Column::Icon: + case Column::Name: + case Column::State: + case Column::User: + case Column::Pledge: + case Column::Veil: + return Gfx::TextAlignment::CenterLeft; + case Column::PID: + case Column::TID: + case Column::PPID: + case Column::PGID: + case Column::SID: + case Column::Priority: + case Column::EffectivePriority: + case Column::Virtual: + case Column::Physical: + case Column::DirtyPrivate: + case Column::CleanInode: + case Column::PurgeableVolatile: + case Column::PurgeableNonvolatile: + case Column::CPU: + case Column::Processor: + case Column::Syscalls: + case Column::InodeFaults: + case Column::ZeroFaults: + case Column::CowFaults: + case Column::FileReadBytes: + case Column::FileWriteBytes: + case Column::UnixSocketReadBytes: + case Column::UnixSocketWriteBytes: + case Column::IPv4SocketReadBytes: + case Column::IPv4SocketWriteBytes: + return Gfx::TextAlignment::CenterRight; + default: + ASSERT_NOT_REACHED(); + } + } + + auto it = m_threads.find(m_pids[index.row()]); + auto& thread = *(*it).value; + + if (role == GUI::ModelRole::Sort) { + switch (index.column()) { + case Column::Icon: + return 0; + case Column::PID: + return thread.current_state.pid; + case Column::TID: + return thread.current_state.tid; + case Column::PPID: + return thread.current_state.ppid; + case Column::PGID: + return thread.current_state.pgid; + case Column::SID: + return thread.current_state.sid; + case Column::State: + return thread.current_state.state; + case Column::User: + return thread.current_state.user; + case Column::Priority: + return thread.current_state.priority; + case Column::EffectivePriority: + return thread.current_state.effective_priority; + case Column::Virtual: + return (int)thread.current_state.amount_virtual; + case Column::Physical: + return (int)thread.current_state.amount_resident; + case Column::DirtyPrivate: + return (int)thread.current_state.amount_dirty_private; + case Column::CleanInode: + return (int)thread.current_state.amount_clean_inode; + case Column::PurgeableVolatile: + return (int)thread.current_state.amount_purgeable_volatile; + case Column::PurgeableNonvolatile: + return (int)thread.current_state.amount_purgeable_nonvolatile; + case Column::CPU: + return thread.current_state.cpu_percent; + case Column::Processor: + return thread.current_state.cpu; + case Column::Name: + return thread.current_state.name; + case Column::Syscalls: + return thread.current_state.syscall_count; + case Column::InodeFaults: + return thread.current_state.inode_faults; + case Column::ZeroFaults: + return thread.current_state.zero_faults; + case Column::CowFaults: + return thread.current_state.cow_faults; + case Column::IPv4SocketReadBytes: + return thread.current_state.ipv4_socket_read_bytes; + case Column::IPv4SocketWriteBytes: + return thread.current_state.ipv4_socket_write_bytes; + case Column::UnixSocketReadBytes: + return thread.current_state.unix_socket_read_bytes; + case Column::UnixSocketWriteBytes: + return thread.current_state.unix_socket_write_bytes; + case Column::FileReadBytes: + return thread.current_state.file_read_bytes; + case Column::FileWriteBytes: + return thread.current_state.file_write_bytes; + case Column::Pledge: + return thread.current_state.pledge; + case Column::Veil: + return thread.current_state.veil; + } + ASSERT_NOT_REACHED(); + return {}; + } + + if (role == GUI::ModelRole::Display) { + switch (index.column()) { + case Column::Icon: { + auto icon = GUI::FileIconProvider::icon_for_executable(thread.current_state.executable); + if (auto* bitmap = icon.bitmap_for_size(16)) + return *bitmap; + return *m_generic_process_icon; + } + case Column::PID: + return thread.current_state.pid; + case Column::TID: + return thread.current_state.tid; + case Column::PPID: + return thread.current_state.ppid; + case Column::PGID: + return thread.current_state.pgid; + case Column::SID: + return thread.current_state.sid; + case Column::State: + return thread.current_state.state; + case Column::User: + return thread.current_state.user; + case Column::Priority: + return thread.current_state.priority; + case Column::EffectivePriority: + return thread.current_state.effective_priority; + case Column::Virtual: + return pretty_byte_size(thread.current_state.amount_virtual); + case Column::Physical: + return pretty_byte_size(thread.current_state.amount_resident); + case Column::DirtyPrivate: + return pretty_byte_size(thread.current_state.amount_dirty_private); + case Column::CleanInode: + return pretty_byte_size(thread.current_state.amount_clean_inode); + case Column::PurgeableVolatile: + return pretty_byte_size(thread.current_state.amount_purgeable_volatile); + case Column::PurgeableNonvolatile: + return pretty_byte_size(thread.current_state.amount_purgeable_nonvolatile); + case Column::CPU: + return thread.current_state.cpu_percent; + case Column::Processor: + return thread.current_state.cpu; + case Column::Name: + return thread.current_state.name; + case Column::Syscalls: + return thread.current_state.syscall_count; + case Column::InodeFaults: + return thread.current_state.inode_faults; + case Column::ZeroFaults: + return thread.current_state.zero_faults; + case Column::CowFaults: + return thread.current_state.cow_faults; + case Column::IPv4SocketReadBytes: + return thread.current_state.ipv4_socket_read_bytes; + case Column::IPv4SocketWriteBytes: + return thread.current_state.ipv4_socket_write_bytes; + case Column::UnixSocketReadBytes: + return thread.current_state.unix_socket_read_bytes; + case Column::UnixSocketWriteBytes: + return thread.current_state.unix_socket_write_bytes; + case Column::FileReadBytes: + return thread.current_state.file_read_bytes; + case Column::FileWriteBytes: + return thread.current_state.file_write_bytes; + case Column::Pledge: + return thread.current_state.pledge; + case Column::Veil: + return thread.current_state.veil; + } + } + + return {}; +} + +void ProcessModel::update() +{ + auto previous_pid_count = m_pids.size(); + auto all_processes = Core::ProcessStatisticsReader::get_all(m_proc_all); + + u64 last_sum_ticks_scheduled = 0, last_sum_ticks_scheduled_kernel = 0; + for (auto& it : m_threads) { + auto& current_state = it.value->current_state; + last_sum_ticks_scheduled += current_state.ticks_user + current_state.ticks_kernel; + last_sum_ticks_scheduled_kernel += current_state.ticks_kernel; + } + + HashTable<PidAndTid> live_pids; + u64 sum_ticks_scheduled = 0, sum_ticks_scheduled_kernel = 0; + if (all_processes.has_value()) { + for (auto& it : all_processes.value()) { + for (auto& thread : it.value.threads) { + ThreadState state; + state.pid = it.value.pid; + state.user = it.value.username; + state.pledge = it.value.pledge; + state.veil = it.value.veil; + state.syscall_count = thread.syscall_count; + state.inode_faults = thread.inode_faults; + state.zero_faults = thread.zero_faults; + state.cow_faults = thread.cow_faults; + state.unix_socket_read_bytes = thread.unix_socket_read_bytes; + state.unix_socket_write_bytes = thread.unix_socket_write_bytes; + state.ipv4_socket_read_bytes = thread.ipv4_socket_read_bytes; + state.ipv4_socket_write_bytes = thread.ipv4_socket_write_bytes; + state.file_read_bytes = thread.file_read_bytes; + state.file_write_bytes = thread.file_write_bytes; + state.amount_virtual = it.value.amount_virtual; + state.amount_resident = it.value.amount_resident; + state.amount_dirty_private = it.value.amount_dirty_private; + state.amount_clean_inode = it.value.amount_clean_inode; + state.amount_purgeable_volatile = it.value.amount_purgeable_volatile; + state.amount_purgeable_nonvolatile = it.value.amount_purgeable_nonvolatile; + + state.name = thread.name; + state.executable = it.value.executable; + + state.ppid = it.value.ppid; + state.tid = thread.tid; + state.pgid = it.value.pgid; + state.sid = it.value.sid; + state.times_scheduled = thread.times_scheduled; + state.ticks_user = thread.ticks_user; + state.ticks_kernel = thread.ticks_kernel; + state.cpu = thread.cpu; + state.cpu_percent = 0; + state.priority = thread.priority; + state.effective_priority = thread.effective_priority; + state.state = thread.state; + sum_ticks_scheduled += thread.ticks_user + thread.ticks_kernel; + sum_ticks_scheduled_kernel += thread.ticks_kernel; + { + auto pit = m_threads.find({ it.value.pid, thread.tid }); + if (pit == m_threads.end()) + m_threads.set({ it.value.pid, thread.tid }, make<Thread>()); + } + auto pit = m_threads.find({ it.value.pid, thread.tid }); + ASSERT(pit != m_threads.end()); + (*pit).value->previous_state = (*pit).value->current_state; + (*pit).value->current_state = state; + + live_pids.set({ it.value.pid, thread.tid }); + } + } + } + + m_pids.clear(); + for (auto& c : m_cpus) { + c.total_cpu_percent = 0.0; + c.total_cpu_percent_kernel = 0.0; + } + Vector<PidAndTid, 16> pids_to_remove; + for (auto& it : m_threads) { + if (!live_pids.contains(it.key)) { + pids_to_remove.append(it.key); + continue; + } + auto& process = *it.value; + u32 ticks_scheduled_diff = (process.current_state.ticks_user + process.current_state.ticks_kernel) + - (process.previous_state.ticks_user + process.previous_state.ticks_kernel); + u32 ticks_scheduled_diff_kernel = process.current_state.ticks_kernel - process.previous_state.ticks_kernel; + process.current_state.cpu_percent = ((float)ticks_scheduled_diff * 100) / (float)(sum_ticks_scheduled - last_sum_ticks_scheduled); + process.current_state.cpu_percent_kernel = ((float)ticks_scheduled_diff_kernel * 100) / (float)(sum_ticks_scheduled - last_sum_ticks_scheduled); + if (it.key.pid != 0) { + auto& cpu_info = m_cpus[process.current_state.cpu]; + cpu_info.total_cpu_percent += process.current_state.cpu_percent; + cpu_info.total_cpu_percent_kernel += process.current_state.cpu_percent_kernel; + m_pids.append(it.key); + } + } + for (auto pid : pids_to_remove) + m_threads.remove(pid); + + if (on_cpu_info_change) + on_cpu_info_change(m_cpus); + + // FIXME: This is a rather hackish way of invalidating indexes. + // It would be good if GUI::Model had a way to orchestrate removal/insertion while preserving indexes. + did_update(previous_pid_count == m_pids.size() ? GUI::Model::UpdateFlag::DontInvalidateIndexes : GUI::Model::UpdateFlag::InvalidateAllIndexes); +} diff --git a/Userland/Applications/SystemMonitor/ProcessModel.h b/Userland/Applications/SystemMonitor/ProcessModel.h new file mode 100644 index 0000000000..11f39cac76 --- /dev/null +++ b/Userland/Applications/SystemMonitor/ProcessModel.h @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/HashMap.h> +#include <AK/NonnullOwnPtrVector.h> +#include <AK/String.h> +#include <AK/Vector.h> +#include <LibGUI/Model.h> +#include <unistd.h> + +class GraphWidget; + +struct PidAndTid { + bool operator==(const PidAndTid& other) const + { + return pid == other.pid && tid == other.tid; + } + pid_t pid; + int tid; +}; + +class ProcessModel final : public GUI::Model { +public: + enum Column { + Icon = 0, + Name, + CPU, + Processor, + State, + Priority, + EffectivePriority, + User, + PID, + TID, + PPID, + PGID, + SID, + Virtual, + Physical, + DirtyPrivate, + CleanInode, + PurgeableVolatile, + PurgeableNonvolatile, + Veil, + Pledge, + Syscalls, + InodeFaults, + ZeroFaults, + CowFaults, + FileReadBytes, + FileWriteBytes, + UnixSocketReadBytes, + UnixSocketWriteBytes, + IPv4SocketReadBytes, + IPv4SocketWriteBytes, + __Count + }; + + static ProcessModel& the(); + + static NonnullRefPtr<ProcessModel> create() { return adopt(*new ProcessModel); } + virtual ~ProcessModel() override; + + virtual int row_count(const GUI::ModelIndex&) const override; + virtual int column_count(const GUI::ModelIndex&) const override; + virtual String column_name(int column) const override; + virtual GUI::Variant data(const GUI::ModelIndex&, GUI::ModelRole) const override; + virtual void update() override; + + struct CpuInfo { + u32 id; + float total_cpu_percent { 0.0 }; + float total_cpu_percent_kernel { 0.0 }; + + CpuInfo(u32 id) + : id(id) + { + } + }; + + Function<void(const NonnullOwnPtrVector<CpuInfo>&)> on_cpu_info_change; + + const NonnullOwnPtrVector<CpuInfo>& cpus() const { return m_cpus; } + +private: + ProcessModel(); + + struct ThreadState { + pid_t tid; + pid_t pid; + pid_t ppid; + pid_t pgid; + pid_t sid; + unsigned times_scheduled; + unsigned ticks_user; + unsigned ticks_kernel; + String executable; + String name; + String state; + String user; + String pledge; + String veil; + u32 cpu; + u32 priority; + u32 effective_priority; + size_t amount_virtual; + size_t amount_resident; + size_t amount_dirty_private; + size_t amount_clean_inode; + size_t amount_purgeable_volatile; + size_t amount_purgeable_nonvolatile; + unsigned syscall_count; + unsigned inode_faults; + unsigned zero_faults; + unsigned cow_faults; + unsigned unix_socket_read_bytes; + unsigned unix_socket_write_bytes; + unsigned ipv4_socket_read_bytes; + unsigned ipv4_socket_write_bytes; + unsigned file_read_bytes; + unsigned file_write_bytes; + float cpu_percent; + float cpu_percent_kernel; + }; + + struct Thread { + ThreadState current_state; + ThreadState previous_state; + }; + + HashMap<uid_t, String> m_usernames; + HashMap<PidAndTid, NonnullOwnPtr<Thread>> m_threads; + NonnullOwnPtrVector<CpuInfo> m_cpus; + Vector<PidAndTid> m_pids; + RefPtr<Gfx::Bitmap> m_generic_process_icon; + RefPtr<Gfx::Bitmap> m_high_priority_icon; + RefPtr<Gfx::Bitmap> m_low_priority_icon; + RefPtr<Gfx::Bitmap> m_normal_priority_icon; + RefPtr<Core::File> m_proc_all; +}; + +namespace AK { +template<> +struct Traits<PidAndTid> : public GenericTraits<PidAndTid> { + static unsigned hash(const PidAndTid& value) { return pair_int_hash(value.pid, value.tid); } +}; +} diff --git a/Userland/Applications/SystemMonitor/ProcessUnveiledPathsWidget.cpp b/Userland/Applications/SystemMonitor/ProcessUnveiledPathsWidget.cpp new file mode 100644 index 0000000000..9e2d22443c --- /dev/null +++ b/Userland/Applications/SystemMonitor/ProcessUnveiledPathsWidget.cpp @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "ProcessUnveiledPathsWidget.h" +#include <LibGUI/BoxLayout.h> +#include <LibGUI/JsonArrayModel.h> +#include <LibGUI/SortingProxyModel.h> +#include <LibGUI/TableView.h> + +ProcessUnveiledPathsWidget::ProcessUnveiledPathsWidget() +{ + set_layout<GUI::VerticalBoxLayout>(); + layout()->set_margins({ 4, 4, 4, 4 }); + m_table_view = add<GUI::TableView>(); + + Vector<GUI::JsonArrayModel::FieldSpec> pid_unveil_fields; + pid_unveil_fields.empend("path", "Path", Gfx::TextAlignment::CenterLeft); + pid_unveil_fields.empend("permissions", "Permissions", Gfx::TextAlignment::CenterLeft); + + m_model = GUI::JsonArrayModel::create({}, move(pid_unveil_fields)); + m_table_view->set_model(GUI::SortingProxyModel::create(*m_model)); +} + +ProcessUnveiledPathsWidget::~ProcessUnveiledPathsWidget() +{ +} + +void ProcessUnveiledPathsWidget::set_pid(pid_t pid) +{ + if (m_pid == pid) + return; + m_pid = pid; + m_model->set_json_path(String::formatted("/proc/{}/unveil", m_pid)); +} diff --git a/Userland/Applications/SystemMonitor/ProcessUnveiledPathsWidget.h b/Userland/Applications/SystemMonitor/ProcessUnveiledPathsWidget.h new file mode 100644 index 0000000000..d1eac898b2 --- /dev/null +++ b/Userland/Applications/SystemMonitor/ProcessUnveiledPathsWidget.h @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Widget.h> + +class ProcessUnveiledPathsWidget final : public GUI::Widget { + C_OBJECT(ProcessUnveiledPathsWidget); + +public: + virtual ~ProcessUnveiledPathsWidget() override; + + void set_pid(pid_t); + +private: + ProcessUnveiledPathsWidget(); + + RefPtr<GUI::TableView> m_table_view; + RefPtr<GUI::JsonArrayModel> m_model; + pid_t m_pid { -1 }; +}; diff --git a/Userland/Applications/SystemMonitor/ThreadStackWidget.cpp b/Userland/Applications/SystemMonitor/ThreadStackWidget.cpp new file mode 100644 index 0000000000..01e37791a0 --- /dev/null +++ b/Userland/Applications/SystemMonitor/ThreadStackWidget.cpp @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "ThreadStackWidget.h" +#include <AK/ByteBuffer.h> +#include <LibCore/File.h> +#include <LibCore/Timer.h> +#include <LibGUI/BoxLayout.h> + +ThreadStackWidget::ThreadStackWidget() +{ + set_layout<GUI::VerticalBoxLayout>(); + layout()->set_margins({ 4, 4, 4, 4 }); + m_stack_editor = add<GUI::TextEditor>(); + m_stack_editor->set_mode(GUI::TextEditor::ReadOnly); + + m_timer = add<Core::Timer>(1000, [this] { refresh(); }); +} + +ThreadStackWidget::~ThreadStackWidget() +{ +} + +void ThreadStackWidget::set_ids(pid_t pid, pid_t tid) +{ + if (m_pid == pid && m_tid == tid) + return; + m_pid = pid; + m_tid = tid; + refresh(); +} + +void ThreadStackWidget::refresh() +{ + auto file = Core::File::construct(String::formatted("/proc/{}/stacks/{}", m_pid, m_tid)); + if (!file->open(Core::IODevice::ReadOnly)) { + m_stack_editor->set_text(String::formatted("Unable to open {}", file->filename())); + return; + } + + auto new_text = file->read_all(); + if (m_stack_editor->text() != new_text) { + m_stack_editor->set_text(new_text); + } +} diff --git a/Userland/Applications/SystemMonitor/ThreadStackWidget.h b/Userland/Applications/SystemMonitor/ThreadStackWidget.h new file mode 100644 index 0000000000..5a10e93b0a --- /dev/null +++ b/Userland/Applications/SystemMonitor/ThreadStackWidget.h @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/TextEditor.h> +#include <LibGUI/Widget.h> + +class ThreadStackWidget final : public GUI::Widget { + C_OBJECT(ThreadStackWidget) +public: + virtual ~ThreadStackWidget() override; + + void set_ids(pid_t pid, pid_t tid); + void refresh(); + +private: + ThreadStackWidget(); + + pid_t m_pid { -1 }; + pid_t m_tid { -1 }; + RefPtr<GUI::TextEditor> m_stack_editor; + RefPtr<Core::Timer> m_timer; +}; diff --git a/Userland/Applications/SystemMonitor/main.cpp b/Userland/Applications/SystemMonitor/main.cpp new file mode 100644 index 0000000000..bdf88e44d1 --- /dev/null +++ b/Userland/Applications/SystemMonitor/main.cpp @@ -0,0 +1,670 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "DevicesModel.h" +#include "GraphWidget.h" +#include "InterruptsWidget.h" +#include "MemoryStatsWidget.h" +#include "NetworkStatisticsWidget.h" +#include "ProcessFileDescriptorMapWidget.h" +#include "ProcessMemoryMapWidget.h" +#include "ProcessModel.h" +#include "ProcessUnveiledPathsWidget.h" +#include "ThreadStackWidget.h" +#include <AK/NumberFormat.h> +#include <LibCore/ArgsParser.h> +#include <LibCore/Timer.h> +#include <LibGUI/Action.h> +#include <LibGUI/ActionGroup.h> +#include <LibGUI/Application.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/GroupBox.h> +#include <LibGUI/Icon.h> +#include <LibGUI/JsonArrayModel.h> +#include <LibGUI/Label.h> +#include <LibGUI/LazyWidget.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/Painter.h> +#include <LibGUI/SortingProxyModel.h> +#include <LibGUI/Splitter.h> +#include <LibGUI/TabWidget.h> +#include <LibGUI/TableView.h> +#include <LibGUI/ToolBar.h> +#include <LibGUI/Widget.h> +#include <LibGUI/Window.h> +#include <LibGfx/Palette.h> +#include <LibPCIDB/Database.h> +#include <serenity.h> +#include <signal.h> +#include <spawn.h> +#include <stdio.h> +#include <unistd.h> + +static NonnullRefPtr<GUI::Widget> build_file_systems_tab(); +static NonnullRefPtr<GUI::Widget> build_pci_devices_tab(); +static NonnullRefPtr<GUI::Widget> build_devices_tab(); +static NonnullRefPtr<GUI::Widget> build_graphs_tab(); +static NonnullRefPtr<GUI::Widget> build_processors_tab(); + +class UnavailableProcessWidget final : public GUI::Frame { + C_OBJECT(UnavailableProcessWidget) +public: + virtual ~UnavailableProcessWidget() override { } + + const String& text() const { return m_text; } + void set_text(String text) { m_text = move(text); } + +private: + UnavailableProcessWidget(String text) + : m_text(move(text)) + { + } + + virtual void paint_event(GUI::PaintEvent& event) override + { + Frame::paint_event(event); + if (text().is_empty()) + return; + GUI::Painter painter(*this); + painter.add_clip_rect(event.rect()); + painter.draw_text(frame_inner_rect(), text(), Gfx::TextAlignment::Center, palette().window_text(), Gfx::TextElision::Right); + } + + String m_text; +}; + +static bool can_access_pid(pid_t pid) +{ + auto path = String::formatted("/proc/{}", pid); + return access(path.characters(), X_OK) == 0; +} + +int main(int argc, char** argv) +{ + if (pledge("stdio proc shared_buffer accept rpath exec unix cpath fattr", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio proc shared_buffer accept rpath exec", nullptr) < 0) { + perror("pledge"); + return 1; + } + + if (unveil("/etc/passwd", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/res", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/proc", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/dev", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/bin", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/bin/Profiler", "rx") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/bin/Inspector", "rx") < 0) { + perror("unveil"); + return 1; + } + + unveil(nullptr, nullptr); + + const char* args_tab = "processes"; + Core::ArgsParser parser; + parser.add_option(args_tab, "Tab, one of 'processes', 'graphs', 'fs', 'pci', 'devices', 'network', 'processors' or 'interrupts'", "open-tab", 't', "tab"); + parser.parse(argc, argv); + StringView args_tab_view = args_tab; + + auto app_icon = GUI::Icon::default_icon("app-system-monitor"); + + auto window = GUI::Window::construct(); + window->set_title("System Monitor"); + window->resize(680, 400); + + auto& keeper = window->set_main_widget<GUI::Widget>(); + keeper.set_layout<GUI::VerticalBoxLayout>(); + keeper.set_fill_with_background_color(true); + keeper.layout()->set_margins({ 2, 2, 2, 2 }); + + auto& tabwidget = keeper.add<GUI::TabWidget>(); + + auto process_container_splitter = GUI::VerticalSplitter::construct(); + tabwidget.add_widget("Processes", process_container_splitter); + process_container_splitter->layout()->set_margins({ 4, 4, 4, 4 }); + + auto& process_table_container = process_container_splitter->add<GUI::Widget>(); + + auto graphs_widget = build_graphs_tab(); + tabwidget.add_widget("Graphs", graphs_widget); + + auto file_systems_widget = build_file_systems_tab(); + tabwidget.add_widget("File systems", file_systems_widget); + + auto pci_devices_widget = build_pci_devices_tab(); + tabwidget.add_widget("PCI devices", pci_devices_widget); + + auto devices_widget = build_devices_tab(); + tabwidget.add_widget("Devices", devices_widget); + + auto network_stats_widget = NetworkStatisticsWidget::construct(); + tabwidget.add_widget("Network", network_stats_widget); + + auto processors_widget = build_processors_tab(); + tabwidget.add_widget("Processors", processors_widget); + + auto interrupts_widget = InterruptsWidget::construct(); + tabwidget.add_widget("Interrupts", interrupts_widget); + + process_table_container.set_layout<GUI::VerticalBoxLayout>(); + process_table_container.layout()->set_spacing(0); + + auto& process_table_view = process_table_container.add<GUI::TableView>(); + process_table_view.set_column_headers_visible(true); + process_table_view.set_model(GUI::SortingProxyModel::create(ProcessModel::create())); + process_table_view.set_key_column_and_sort_order(ProcessModel::Column::CPU, GUI::SortOrder::Descending); + process_table_view.model()->update(); + + auto& refresh_timer = window->add<Core::Timer>( + 3000, [&] { + process_table_view.model()->update(); + if (auto* memory_stats_widget = MemoryStatsWidget::the()) + memory_stats_widget->refresh(); + }); + + auto selected_id = [&](ProcessModel::Column column) -> pid_t { + if (process_table_view.selection().is_empty()) + return -1; + auto pid_index = process_table_view.model()->index(process_table_view.selection().first().row(), column); + return pid_index.data().to_i32(); + }; + + auto kill_action = GUI::Action::create("Kill process", { Mod_Ctrl, Key_K }, Gfx::Bitmap::load_from_file("/res/icons/16x16/kill.png"), [&](const GUI::Action&) { + pid_t pid = selected_id(ProcessModel::Column::PID); + if (pid != -1) + kill(pid, SIGKILL); + }); + + auto stop_action = GUI::Action::create("Stop process", { Mod_Ctrl, Key_S }, Gfx::Bitmap::load_from_file("/res/icons/16x16/stop-hand.png"), [&](const GUI::Action&) { + pid_t pid = selected_id(ProcessModel::Column::PID); + if (pid != -1) + kill(pid, SIGSTOP); + }); + + auto continue_action = GUI::Action::create("Continue process", { Mod_Ctrl, Key_C }, Gfx::Bitmap::load_from_file("/res/icons/16x16/continue.png"), [&](const GUI::Action&) { + pid_t pid = selected_id(ProcessModel::Column::PID); + if (pid != -1) + kill(pid, SIGCONT); + }); + + auto profile_action = GUI::Action::create("Profile process", { Mod_Ctrl, Key_P }, + Gfx::Bitmap::load_from_file("/res/icons/16x16/app-profiler.png"), [&](auto&) { + pid_t pid = selected_id(ProcessModel::Column::PID); + if (pid != -1) { + auto pid_string = String::format("%d", pid); + pid_t child; + const char* argv[] = { "/bin/Profiler", "--pid", pid_string.characters(), nullptr }; + if ((errno = posix_spawn(&child, "/bin/Profiler", nullptr, nullptr, const_cast<char**>(argv), environ))) { + perror("posix_spawn"); + } else { + if (disown(child) < 0) + perror("disown"); + } + } + }); + + auto inspect_action = GUI::Action::create("Inspect process", { Mod_Ctrl, Key_I }, + Gfx::Bitmap::load_from_file("/res/icons/16x16/app-inspector.png"), [&](auto&) { + pid_t pid = selected_id(ProcessModel::Column::PID); + if (pid != -1) { + auto pid_string = String::format("%d", pid); + pid_t child; + const char* argv[] = { "/bin/Inspector", pid_string.characters(), nullptr }; + if ((errno = posix_spawn(&child, "/bin/Inspector", nullptr, nullptr, const_cast<char**>(argv), environ))) { + perror("posix_spawn"); + } else { + if (disown(child) < 0) + perror("disown"); + } + } + }); + + auto menubar = GUI::MenuBar::construct(); + auto& app_menu = menubar->add_menu("System Monitor"); + app_menu.add_action(GUI::CommonActions::make_quit_action([](auto&) { + GUI::Application::the()->quit(); + return; + })); + + auto& process_menu = menubar->add_menu("Process"); + process_menu.add_action(kill_action); + process_menu.add_action(stop_action); + process_menu.add_action(continue_action); + process_menu.add_separator(); + process_menu.add_action(profile_action); + process_menu.add_action(inspect_action); + + auto process_context_menu = GUI::Menu::construct(); + process_context_menu->add_action(kill_action); + process_context_menu->add_action(stop_action); + process_context_menu->add_action(continue_action); + process_context_menu->add_separator(); + process_context_menu->add_action(profile_action); + process_context_menu->add_action(inspect_action); + process_table_view.on_context_menu_request = [&]([[maybe_unused]] const GUI::ModelIndex& index, const GUI::ContextMenuEvent& event) { + process_context_menu->popup(event.screen_position()); + }; + + auto& frequency_menu = menubar->add_menu("Frequency"); + GUI::ActionGroup frequency_action_group; + frequency_action_group.set_exclusive(true); + + auto make_frequency_action = [&](auto& title, int interval, bool checked = false) { + auto action = GUI::Action::create_checkable(title, [&refresh_timer, interval](auto&) { + refresh_timer.restart(interval); + }); + action->set_checked(checked); + frequency_action_group.add_action(*action); + frequency_menu.add_action(*action); + }; + + make_frequency_action("1 sec", 1000); + make_frequency_action("3 sec", 3000, true); + make_frequency_action("5 sec", 5000); + + auto& help_menu = menubar->add_menu("Help"); + help_menu.add_action(GUI::CommonActions::make_about_action("System Monitor", app_icon, window)); + + app->set_menubar(move(menubar)); + + auto& process_tab_unused_widget = process_container_splitter->add<UnavailableProcessWidget>("No process selected"); + process_tab_unused_widget.set_visible(true); + + auto& process_tab_widget = process_container_splitter->add<GUI::TabWidget>(); + process_tab_widget.set_tab_position(GUI::TabWidget::TabPosition::Bottom); + process_tab_widget.set_visible(false); + + auto& memory_map_widget = process_tab_widget.add_tab<ProcessMemoryMapWidget>("Memory map"); + auto& open_files_widget = process_tab_widget.add_tab<ProcessFileDescriptorMapWidget>("Open files"); + auto& unveiled_paths_widget = process_tab_widget.add_tab<ProcessUnveiledPathsWidget>("Unveiled paths"); + auto& stack_widget = process_tab_widget.add_tab<ThreadStackWidget>("Stack"); + + process_table_view.on_selection = [&](auto&) { + auto pid = selected_id(ProcessModel::Column::PID); + auto tid = selected_id(ProcessModel::Column::TID); + if (!can_access_pid(pid)) { + process_tab_widget.set_visible(false); + process_tab_unused_widget.set_text("Process cannot be accessed"); + process_tab_unused_widget.set_visible(true); + return; + } + + process_tab_widget.set_visible(true); + process_tab_unused_widget.set_visible(false); + open_files_widget.set_pid(pid); + stack_widget.set_ids(pid, tid); + memory_map_widget.set_pid(pid); + unveiled_paths_widget.set_pid(pid); + }; + + window->show(); + + window->set_icon(app_icon.bitmap_for_size(16)); + + if (args_tab_view == "processes") + tabwidget.set_active_widget(process_container_splitter); + else if (args_tab_view == "graphs") + tabwidget.set_active_widget(graphs_widget); + else if (args_tab_view == "fs") + tabwidget.set_active_widget(file_systems_widget); + else if (args_tab_view == "pci") + tabwidget.set_active_widget(pci_devices_widget); + else if (args_tab_view == "devices") + tabwidget.set_active_widget(devices_widget); + else if (args_tab_view == "network") + tabwidget.set_active_widget(network_stats_widget); + else if (args_tab_view == "processors") + tabwidget.set_active_widget(processors_widget); + else if (args_tab_view == "interrupts") + tabwidget.set_active_widget(interrupts_widget); + + return app->exec(); +} + +class ProgressBarPaintingDelegate final : public GUI::TableCellPaintingDelegate { +public: + virtual ~ProgressBarPaintingDelegate() override { } + + virtual void paint(GUI::Painter& painter, const Gfx::IntRect& a_rect, const Palette& palette, const GUI::ModelIndex& index) override + { + auto rect = a_rect.shrunken(2, 2); + auto percentage = index.data(GUI::ModelRole::Custom).to_i32(); + + auto data = index.data(); + String text; + if (data.is_string()) + text = data.as_string(); + Gfx::StylePainter::paint_progress_bar(painter, rect, palette, 0, 100, percentage, text); + painter.draw_rect(rect, Color::Black); + } +}; + +NonnullRefPtr<GUI::Widget> build_file_systems_tab() +{ + auto fs_widget = GUI::LazyWidget::construct(); + + fs_widget->on_first_show = [](GUI::LazyWidget& self) { + self.set_layout<GUI::VerticalBoxLayout>(); + self.layout()->set_margins({ 4, 4, 4, 4 }); + auto& fs_table_view = self.add<GUI::TableView>(); + + Vector<GUI::JsonArrayModel::FieldSpec> df_fields; + df_fields.empend("mount_point", "Mount point", Gfx::TextAlignment::CenterLeft); + df_fields.empend("class_name", "Class", Gfx::TextAlignment::CenterLeft); + df_fields.empend("source", "Source", Gfx::TextAlignment::CenterLeft); + df_fields.empend( + "Size", Gfx::TextAlignment::CenterRight, + [](const JsonObject& object) { + StringBuilder size_builder; + size_builder.append(" "); + size_builder.append(human_readable_size(object.get("total_block_count").to_u32() * object.get("block_size").to_u32())); + size_builder.append(" "); + return size_builder.to_string(); + }, + [](const JsonObject& object) { + return object.get("total_block_count").to_u32() * object.get("block_size").to_u32(); + }, + [](const JsonObject& object) { + auto total_blocks = object.get("total_block_count").to_u32(); + if (total_blocks == 0) + return 0; + auto free_blocks = object.get("free_block_count").to_u32(); + auto used_blocks = total_blocks - free_blocks; + int percentage = (int)((float)used_blocks / (float)total_blocks * 100.0f); + return percentage; + }); + df_fields.empend( + "Used", Gfx::TextAlignment::CenterRight, + [](const JsonObject& object) { + auto total_blocks = object.get("total_block_count").to_u32(); + auto free_blocks = object.get("free_block_count").to_u32(); + auto used_blocks = total_blocks - free_blocks; + return human_readable_size(used_blocks * object.get("block_size").to_u32()); }, + [](const JsonObject& object) { + auto total_blocks = object.get("total_block_count").to_u32(); + auto free_blocks = object.get("free_block_count").to_u32(); + auto used_blocks = total_blocks - free_blocks; + return used_blocks * object.get("block_size").to_u32(); + }); + df_fields.empend( + "Available", Gfx::TextAlignment::CenterRight, + [](const JsonObject& object) { + return human_readable_size(object.get("free_block_count").to_u32() * object.get("block_size").to_u32()); + }, + [](const JsonObject& object) { + return object.get("free_block_count").to_u32() * object.get("block_size").to_u32(); + }); + df_fields.empend("Access", Gfx::TextAlignment::CenterLeft, [](const JsonObject& object) { + bool readonly = object.get("readonly").to_bool(); + int mount_flags = object.get("mount_flags").to_int(); + return readonly || (mount_flags & MS_RDONLY) ? "Read-only" : "Read/Write"; + }); + df_fields.empend("Mount flags", Gfx::TextAlignment::CenterLeft, [](const JsonObject& object) { + int mount_flags = object.get("mount_flags").to_int(); + StringBuilder builder; + bool first = true; + auto check = [&](int flag, const char* name) { + if (!(mount_flags & flag)) + return; + if (!first) + builder.append(','); + builder.append(name); + first = false; + }; + check(MS_NODEV, "nodev"); + check(MS_NOEXEC, "noexec"); + check(MS_NOSUID, "nosuid"); + check(MS_BIND, "bind"); + check(MS_RDONLY, "ro"); + if (builder.string_view().is_empty()) + return String("defaults"); + return builder.to_string(); + }); + df_fields.empend("free_block_count", "Free blocks", Gfx::TextAlignment::CenterRight); + df_fields.empend("total_block_count", "Total blocks", Gfx::TextAlignment::CenterRight); + df_fields.empend("free_inode_count", "Free inodes", Gfx::TextAlignment::CenterRight); + df_fields.empend("total_inode_count", "Total inodes", Gfx::TextAlignment::CenterRight); + df_fields.empend("block_size", "Block size", Gfx::TextAlignment::CenterRight); + fs_table_view.set_model(GUI::SortingProxyModel::create(GUI::JsonArrayModel::create("/proc/df", move(df_fields)))); + + fs_table_view.set_column_painting_delegate(3, make<ProgressBarPaintingDelegate>()); + + fs_table_view.model()->update(); + }; + return fs_widget; +} + +NonnullRefPtr<GUI::Widget> build_pci_devices_tab() +{ + auto pci_widget = GUI::LazyWidget::construct(); + + pci_widget->on_first_show = [](GUI::LazyWidget& self) { + self.set_layout<GUI::VerticalBoxLayout>(); + self.layout()->set_margins({ 4, 4, 4, 4 }); + auto& pci_table_view = self.add<GUI::TableView>(); + + auto db = PCIDB::Database::open(); + + Vector<GUI::JsonArrayModel::FieldSpec> pci_fields; + pci_fields.empend( + "Address", Gfx::TextAlignment::CenterLeft, + [](const JsonObject& object) { + auto seg = object.get("seg").to_u32(); + auto bus = object.get("bus").to_u32(); + auto slot = object.get("slot").to_u32(); + auto function = object.get("function").to_u32(); + return String::formatted("{:04x}:{:02x}:{:02x}.{}", seg, bus, slot, function); + }); + pci_fields.empend( + "Class", Gfx::TextAlignment::CenterLeft, + [db](const JsonObject& object) { + auto class_id = object.get("class").to_u32(); + String class_name = db->get_class(class_id); + return class_name == "" ? String::formatted("{:04x}", class_id) : class_name; + }); + pci_fields.empend( + "Vendor", Gfx::TextAlignment::CenterLeft, + [db](const JsonObject& object) { + auto vendor_id = object.get("vendor_id").to_u32(); + String vendor_name = db->get_vendor(vendor_id); + return vendor_name == "" ? String::formatted("{:02x}", vendor_id) : vendor_name; + }); + pci_fields.empend( + "Device", Gfx::TextAlignment::CenterLeft, + [db](const JsonObject& object) { + auto vendor_id = object.get("vendor_id").to_u32(); + auto device_id = object.get("device_id").to_u32(); + String device_name = db->get_device(vendor_id, device_id); + return device_name == "" ? String::formatted("{:02x}", device_id) : device_name; + }); + pci_fields.empend( + "Revision", Gfx::TextAlignment::CenterRight, + [](const JsonObject& object) { + auto revision_id = object.get("revision_id").to_u32(); + return String::formatted("{:02x}", revision_id); + }); + + pci_table_view.set_model(GUI::SortingProxyModel::create(GUI::JsonArrayModel::create("/proc/pci", move(pci_fields)))); + pci_table_view.model()->update(); + }; + + return pci_widget; +} + +NonnullRefPtr<GUI::Widget> build_devices_tab() +{ + auto devices_widget = GUI::LazyWidget::construct(); + + devices_widget->on_first_show = [](GUI::LazyWidget& self) { + self.set_layout<GUI::VerticalBoxLayout>(); + self.layout()->set_margins({ 4, 4, 4, 4 }); + + auto& devices_table_view = self.add<GUI::TableView>(); + devices_table_view.set_model(GUI::SortingProxyModel::create(DevicesModel::create())); + devices_table_view.model()->update(); + }; + + return devices_widget; +} + +NonnullRefPtr<GUI::Widget> build_graphs_tab() +{ + auto graphs_container = GUI::LazyWidget::construct(); + + graphs_container->on_first_show = [](GUI::LazyWidget& self) { + self.set_fill_with_background_color(true); + self.set_background_role(ColorRole::Button); + self.set_layout<GUI::VerticalBoxLayout>(); + self.layout()->set_margins({ 4, 4, 4, 4 }); + + auto& cpu_graph_group_box = self.add<GUI::GroupBox>("CPU usage"); + cpu_graph_group_box.set_layout<GUI::HorizontalBoxLayout>(); + cpu_graph_group_box.layout()->set_margins({ 6, 16, 6, 6 }); + cpu_graph_group_box.set_fixed_height(120); + Vector<GraphWidget*> cpu_graphs; + for (size_t i = 0; i < ProcessModel::the().cpus().size(); i++) { + auto& cpu_graph = cpu_graph_group_box.add<GraphWidget>(); + cpu_graph.set_max(100); + cpu_graph.set_background_color(Color::White); + cpu_graph.set_value_format(0, { + .line_color = Color::Blue, + .background_color = Color::from_rgb(0xaaaaff), + .text_formatter = [](int value) { + return String::formatted("Total: {}%", value); + }, + }); + cpu_graph.set_value_format(1, { + .line_color = Color::Red, + .background_color = Color::from_rgb(0xffaaaa), + .text_formatter = [](int value) { + return String::formatted("Kernel: {}%", value); + }, + }); + cpu_graphs.append(&cpu_graph); + } + ProcessModel::the().on_cpu_info_change = [cpu_graphs](const NonnullOwnPtrVector<ProcessModel::CpuInfo>& cpus) { + for (size_t i = 0; i < cpus.size(); i++) + cpu_graphs[i]->add_value({ (int)cpus[i].total_cpu_percent, (int)cpus[i].total_cpu_percent_kernel }); + }; + + auto& memory_graph_group_box = self.add<GUI::GroupBox>("Memory usage"); + memory_graph_group_box.set_layout<GUI::VerticalBoxLayout>(); + memory_graph_group_box.layout()->set_margins({ 6, 16, 6, 6 }); + memory_graph_group_box.set_fixed_height(120); + auto& memory_graph = memory_graph_group_box.add<GraphWidget>(); + memory_graph.set_background_color(Color::White); + memory_graph.set_stack_values(true); + memory_graph.set_value_format(0, { + .line_color = Color::from_rgb(0x619910), + .background_color = Color::from_rgb(0xbbffbb), + .text_formatter = [&memory_graph](int value) { + return String::formatted("Committed: {} KiB", value); + }, + }); + memory_graph.set_value_format(1, { + .line_color = Color::Blue, + .background_color = Color::from_rgb(0xaaaaff), + .text_formatter = [&memory_graph](int value) { + return String::formatted("Allocated: {} KiB", value); + }, + }); + memory_graph.set_value_format(2, { + .line_color = Color::Red, + .background_color = Color::from_rgb(0xffaaaa), + .text_formatter = [&memory_graph](int value) { + return String::formatted("Kernel heap: {} KiB", value); + }, + }); + + self.add<MemoryStatsWidget>(memory_graph); + }; + return graphs_container; +} + +NonnullRefPtr<GUI::Widget> build_processors_tab() +{ + auto processors_widget = GUI::LazyWidget::construct(); + + processors_widget->on_first_show = [](GUI::LazyWidget& self) { + self.set_layout<GUI::VerticalBoxLayout>(); + self.layout()->set_margins({ 4, 4, 4, 4 }); + + Vector<GUI::JsonArrayModel::FieldSpec> processors_field; + processors_field.empend("processor", "Processor", Gfx::TextAlignment::CenterRight); + processors_field.empend("cpuid", "CPUID", Gfx::TextAlignment::CenterLeft); + processors_field.empend("brandstr", "Brand", Gfx::TextAlignment::CenterLeft); + processors_field.empend("Features", Gfx::TextAlignment::CenterLeft, [](auto& object) { + StringBuilder builder; + auto features = object.get("features").as_array(); + for (auto& feature : features.values()) { + builder.append(feature.to_string()); + builder.append(' '); + } + return GUI::Variant(builder.to_string()); + }); + processors_field.empend("family", "Family", Gfx::TextAlignment::CenterRight); + processors_field.empend("model", "Model", Gfx::TextAlignment::CenterRight); + processors_field.empend("stepping", "Stepping", Gfx::TextAlignment::CenterRight); + processors_field.empend("type", "Type", Gfx::TextAlignment::CenterRight); + + auto& processors_table_view = self.add<GUI::TableView>(); + processors_table_view.set_model(GUI::JsonArrayModel::create("/proc/cpuinfo", move(processors_field))); + processors_table_view.model()->update(); + }; + + return processors_widget; +} diff --git a/Userland/Applications/Terminal/CMakeLists.txt b/Userland/Applications/Terminal/CMakeLists.txt new file mode 100644 index 0000000000..1fb969546e --- /dev/null +++ b/Userland/Applications/Terminal/CMakeLists.txt @@ -0,0 +1,9 @@ +compile_gml(TerminalSettingsWindow.gml TerminalSettingsWindowGML.h terminal_settings_window_gml) + +set(SOURCES + TerminalSettingsWindowGML.h + main.cpp +) + +serenity_app(Terminal ICON app-terminal) +target_link_libraries(Terminal LibGUI LibVT) diff --git a/Userland/Applications/Terminal/TerminalSettingsWindow.gml b/Userland/Applications/Terminal/TerminalSettingsWindow.gml new file mode 100644 index 0000000000..07b8400c56 --- /dev/null +++ b/Userland/Applications/Terminal/TerminalSettingsWindow.gml @@ -0,0 +1,63 @@ +@GUI::Widget { + fill_with_background_color: true + + layout: @GUI::VerticalBoxLayout { + margins: [4, 4, 4, 4] + } + + @GUI::GroupBox { + title: "Bell mode" + shrink_to_fit: true + + layout: @GUI::VerticalBoxLayout { + margins: [6, 16, 6, 6] + } + + @GUI::RadioButton { + name: "beep_bell_radio" + text: "System beep" + } + + @GUI::RadioButton { + name: "visual_bell_radio" + text: "Visual bell" + } + + @GUI::RadioButton { + name: "no_bell_radio" + text: "No bell" + } + } + + @GUI::GroupBox { + title: "Background opacity" + shrink_to_fit: true + + layout: @GUI::VerticalBoxLayout { + margins: [6, 16, 6, 6] + } + + @GUI::OpacitySlider { + name: "background_opacity_slider" + min: 0 + max: 255 + orientation: "Horizontal" + } + } + + @GUI::GroupBox { + title: "Scrollback size (lines)" + shrink_to_fit: true + + layout: @GUI::VerticalBoxLayout { + margins: [6, 16, 6, 6] + } + + @GUI::SpinBox { + name: "history_size_spinbox" + min: 0 + max: 40960 + orientation: "Horizontal" + } + } +} diff --git a/Userland/Applications/Terminal/main.cpp b/Userland/Applications/Terminal/main.cpp new file mode 100644 index 0000000000..9267b9465f --- /dev/null +++ b/Userland/Applications/Terminal/main.cpp @@ -0,0 +1,525 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include <AK/URL.h> +#include <Applications/Terminal/TerminalSettingsWindowGML.h> +#include <LibCore/ArgsParser.h> +#include <LibDesktop/Launcher.h> +#include <LibGUI/Action.h> +#include <LibGUI/ActionGroup.h> +#include <LibGUI/Application.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/CheckBox.h> +#include <LibGUI/Event.h> +#include <LibGUI/FontPicker.h> +#include <LibGUI/GroupBox.h> +#include <LibGUI/Icon.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/OpacitySlider.h> +#include <LibGUI/RadioButton.h> +#include <LibGUI/SpinBox.h> +#include <LibGUI/TextBox.h> +#include <LibGUI/Widget.h> +#include <LibGUI/Window.h> +#include <LibGfx/Font.h> +#include <LibGfx/Palette.h> +#include <LibVT/TerminalWidget.h> +#include <assert.h> +#include <errno.h> +#include <fcntl.h> +#include <pwd.h> +#include <serenity.h> +#include <signal.h> +#include <spawn.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/ioctl.h> +#include <sys/select.h> +#include <sys/wait.h> +#include <unistd.h> + +static void utmp_update(const char* tty, pid_t pid, bool create) +{ + if (!tty) + return; + int utmpupdate_pid = fork(); + if (utmpupdate_pid < 0) { + perror("fork"); + return; + } + if (utmpupdate_pid == 0) { + // Be careful here! Because fork() only clones one thread it's + // possible that we deadlock on anything involving a mutex, + // including the heap! So resort to low-level APIs + char pid_str[32]; + snprintf(pid_str, sizeof(pid_str), "%d", pid); + execl("/bin/utmpupdate", "/bin/utmpupdate", "-f", "Terminal", "-p", pid_str, (create ? "-c" : "-d"), tty, nullptr); + } else { + wait_again: + int status = 0; + if (waitpid(utmpupdate_pid, &status, 0) < 0) { + int err = errno; + if (err == EINTR) + goto wait_again; + perror("waitpid"); + return; + } + if (WIFEXITED(status) && WEXITSTATUS(status) != 0) + dbgln("Terminal: utmpupdate exited with status {}", WEXITSTATUS(status)); + else if (WIFSIGNALED(status)) + dbgln("Terminal: utmpupdate exited due to unhandled signal {}", WTERMSIG(status)); + } +} + +static pid_t run_command(int ptm_fd, String command) +{ + pid_t pid = fork(); + if (pid < 0) { + perror("fork"); + dbgln("run_command: could not fork to run '{}'", command); + return pid; + } + + if (pid == 0) { + const char* tty_name = ptsname(ptm_fd); + if (!tty_name) { + perror("ptsname"); + exit(1); + } + close(ptm_fd); + int pts_fd = open(tty_name, O_RDWR); + if (pts_fd < 0) { + perror("open"); + exit(1); + } + + if (setsid() < 0) { + perror("setsid"); + } + + close(0); + close(1); + close(2); + + int rc = dup2(pts_fd, 0); + if (rc < 0) { + perror("dup2"); + exit(1); + } + rc = dup2(pts_fd, 1); + if (rc < 0) { + perror("dup2"); + exit(1); + } + rc = dup2(pts_fd, 2); + if (rc < 0) { + perror("dup2"); + exit(1); + } + rc = close(pts_fd); + if (rc < 0) { + perror("close"); + exit(1); + } + rc = ioctl(0, TIOCSCTTY); + if (rc < 0) { + perror("ioctl(TIOCSCTTY)"); + exit(1); + } + + String shell = "/bin/Shell"; + auto* pw = getpwuid(getuid()); + if (pw && pw->pw_shell) { + shell = pw->pw_shell; + } + endpwent(); + + const char* args[4] = { shell.characters(), nullptr, nullptr, nullptr }; + if (!command.is_empty()) { + args[1] = "-c"; + args[2] = command.characters(); + } + const char* envs[] = { "PROMPT=\\X\\u@\\h:\\w\\a\\e[33;1m\\h\\e[0m \\e[34;1m\\w\\e[0m \\p ", "TERM=xterm", "PAGER=more", "PATH=/bin:/usr/bin:/usr/local/bin", nullptr }; + rc = execve(shell.characters(), const_cast<char**>(args), const_cast<char**>(envs)); + if (rc < 0) { + perror("execve"); + exit(1); + } + ASSERT_NOT_REACHED(); + } + + return pid; +} + +static RefPtr<GUI::Window> create_settings_window(TerminalWidget& terminal) +{ + auto window = GUI::Window::construct(terminal.window()); + window->set_title("Terminal settings"); + window->set_minimizable(false); + window->set_resizable(false); + window->resize(200, 210); + window->set_modal(true); + + auto& settings = window->set_main_widget<GUI::Widget>(); + settings.load_from_gml(terminal_settings_window_gml); + + auto& beep_bell_radio = *settings.find_descendant_of_type_named<GUI::RadioButton>("beep_bell_radio"); + auto& visual_bell_radio = *settings.find_descendant_of_type_named<GUI::RadioButton>("visual_bell_radio"); + auto& no_bell_radio = *settings.find_descendant_of_type_named<GUI::RadioButton>("no_bell_radio"); + + switch (terminal.bell_mode()) { + case TerminalWidget::BellMode::Visible: + visual_bell_radio.set_checked(true); + break; + case TerminalWidget::BellMode::AudibleBeep: + beep_bell_radio.set_checked(true); + break; + case TerminalWidget::BellMode::Disabled: + no_bell_radio.set_checked(true); + break; + } + + beep_bell_radio.on_checked = [&terminal](bool) { + terminal.set_bell_mode(TerminalWidget::BellMode::AudibleBeep); + }; + visual_bell_radio.on_checked = [&terminal](bool) { + terminal.set_bell_mode(TerminalWidget::BellMode::Visible); + }; + no_bell_radio.on_checked = [&terminal](bool) { + terminal.set_bell_mode(TerminalWidget::BellMode::Disabled); + }; + + auto& slider = *settings.find_descendant_of_type_named<GUI::OpacitySlider>("background_opacity_slider"); + slider.on_change = [&terminal](int value) { + terminal.set_opacity(value); + }; + slider.set_value(terminal.opacity()); + + auto& history_size_spinbox = *settings.find_descendant_of_type_named<GUI::SpinBox>("history_size_spinbox"); + history_size_spinbox.set_value(terminal.max_history_size()); + history_size_spinbox.on_change = [&terminal](int value) { + terminal.set_max_history_size(value); + }; + + return window; +} + +static RefPtr<GUI::Window> create_find_window(TerminalWidget& terminal) +{ + auto window = GUI::Window::construct(); + window->set_title("Find in Terminal"); + window->set_resizable(false); + window->resize(300, 90); + window->set_modal(true); + + auto& search = window->set_main_widget<GUI::Widget>(); + search.set_fill_with_background_color(true); + search.set_background_role(ColorRole::Button); + search.set_layout<GUI::VerticalBoxLayout>(); + search.layout()->set_margins({ 4, 4, 4, 4 }); + + auto& find = search.add<GUI::Widget>(); + find.set_layout<GUI::HorizontalBoxLayout>(); + find.layout()->set_margins({ 4, 4, 4, 4 }); + find.set_fixed_height(30); + + auto& find_textbox = find.add<GUI::TextBox>(); + find_textbox.set_fixed_width(230); + find_textbox.set_focus(true); + if (terminal.has_selection()) { + String selected_text = terminal.selected_text(); + selected_text.replace("\n", " ", true); + find_textbox.set_text(selected_text); + } + auto& find_backwards = find.add<GUI::Button>(); + find_backwards.set_fixed_width(25); + find_backwards.set_icon(Gfx::Bitmap::load_from_file("/res/icons/16x16/upward-triangle.png")); + auto& find_forwards = find.add<GUI::Button>(); + find_forwards.set_fixed_width(25); + find_forwards.set_icon(Gfx::Bitmap::load_from_file("/res/icons/16x16/downward-triangle.png")); + + find_textbox.on_return_pressed = [&]() { + find_backwards.click(); + }; + + auto& match_case = search.add<GUI::CheckBox>("Case sensitive"); + auto& wrap_around = search.add<GUI::CheckBox>("Wrap around"); + + find_backwards.on_click = [&](auto) { + auto needle = find_textbox.text(); + if (needle.is_empty()) { + return; + } + + auto found_range = terminal.find_previous(needle, terminal.normalized_selection().start(), match_case.is_checked(), wrap_around.is_checked()); + + if (found_range.is_valid()) { + terminal.scroll_to_row(found_range.start().row()); + terminal.set_selection(found_range); + } + }; + find_forwards.on_click = [&](auto) { + auto needle = find_textbox.text(); + if (needle.is_empty()) { + return; + } + + auto found_range = terminal.find_next(needle, terminal.normalized_selection().end(), match_case.is_checked(), wrap_around.is_checked()); + + if (found_range.is_valid()) { + terminal.scroll_to_row(found_range.start().row()); + terminal.set_selection(found_range); + } + }; + + return window; +} + +int main(int argc, char** argv) +{ + if (pledge("stdio tty rpath accept cpath wpath shared_buffer proc exec unix fattr sigaction", nullptr) < 0) { + perror("pledge"); + return 1; + } + + struct sigaction act; + memset(&act, 0, sizeof(act)); + act.sa_flags = SA_NOCLDWAIT; + act.sa_handler = SIG_IGN; + int rc = sigaction(SIGCHLD, &act, nullptr); + if (rc < 0) { + perror("sigaction"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio tty rpath accept cpath wpath shared_buffer proc exec unix", nullptr) < 0) { + perror("pledge"); + return 1; + } + + const char* command_to_execute = nullptr; + + Core::ArgsParser args_parser; + args_parser.add_option(command_to_execute, "Execute this command inside the terminal", nullptr, 'e', "command"); + + args_parser.parse(argc, argv); + + int ptm_fd = posix_openpt(O_RDWR | O_CLOEXEC); + if (ptm_fd < 0) { + perror("posix_openpt"); + return 1; + } + if (grantpt(ptm_fd) < 0) { + perror("grantpt"); + return 1; + } + if (unlockpt(ptm_fd) < 0) { + perror("unlockpt"); + return 1; + } + + RefPtr<Core::ConfigFile> config = Core::ConfigFile::get_for_app("Terminal"); + + pid_t shell_pid = 0; + + if (command_to_execute) + shell_pid = run_command(ptm_fd, command_to_execute); + else + shell_pid = run_command(ptm_fd, config->read_entry("Startup", "Command", "")); + + auto* pts_name = ptsname(ptm_fd); + utmp_update(pts_name, shell_pid, true); + + auto app_icon = GUI::Icon::default_icon("app-terminal"); + + auto window = GUI::Window::construct(); + window->set_title("Terminal"); + window->set_background_color(Color::Black); + window->set_double_buffering_enabled(false); + + auto& terminal = window->set_main_widget<TerminalWidget>(ptm_fd, true, config); + terminal.on_command_exit = [&] { + app->quit(0); + }; + terminal.on_title_change = [&](auto& title) { + window->set_title(title); + }; + terminal.apply_size_increments_to_window(*window); + window->show(); + window->set_icon(app_icon.bitmap_for_size(16)); + + auto bell = config->read_entry("Window", "Bell", "Visible"); + if (bell == "AudibleBeep") { + terminal.set_bell_mode(TerminalWidget::BellMode::AudibleBeep); + } else if (bell == "Disabled") { + terminal.set_bell_mode(TerminalWidget::BellMode::Disabled); + } else { + terminal.set_bell_mode(TerminalWidget::BellMode::Visible); + } + + RefPtr<GUI::Window> settings_window; + RefPtr<GUI::Window> find_window; + + auto new_opacity = config->read_num_entry("Window", "Opacity", 255); + terminal.set_opacity(new_opacity); + window->set_has_alpha_channel(new_opacity < 255); + + auto new_scrollback_size = config->read_num_entry("Terminal", "MaxHistorySize", terminal.max_history_size()); + terminal.set_max_history_size(new_scrollback_size); + + auto open_settings_action = GUI::Action::create("Settings...", Gfx::Bitmap::load_from_file("/res/icons/16x16/gear.png"), + [&](const GUI::Action&) { + if (!settings_window) { + settings_window = create_settings_window(terminal); + settings_window->on_close_request = [&] { + settings_window->remove_from_parent(); + settings_window = nullptr; + return GUI::Window::CloseRequestDecision::Close; + }; + } + if (!settings_window->is_visible()) { + settings_window->center_within(*window); + settings_window->show(); + } + settings_window->move_to_front(); + }); + + terminal.context_menu().add_separator(); + auto pick_font_action = GUI::Action::create("Terminal font...", Gfx::Bitmap::load_from_file("/res/icons/16x16/app-font-editor.png"), + [&](auto&) { + auto picker = GUI::FontPicker::construct(window, &terminal.font(), true); + if (picker->exec() == GUI::Dialog::ExecOK) { + terminal.set_font_and_resize_to_fit(*picker->font()); + window->resize(terminal.size()); + config->write_entry("Text", "Font", picker->font()->qualified_name()); + config->sync(); + } + }); + + terminal.context_menu().add_action(pick_font_action); + + terminal.context_menu().add_separator(); + terminal.context_menu().add_action(open_settings_action); + + auto menubar = GUI::MenuBar::construct(); + + auto& app_menu = menubar->add_menu("Terminal"); + app_menu.add_action(GUI::Action::create("Open new terminal", { Mod_Ctrl | Mod_Shift, Key_N }, Gfx::Bitmap::load_from_file("/res/icons/16x16/app-terminal.png"), [&](auto&) { + pid_t child; + const char* argv[] = { "Terminal", nullptr }; + if ((errno = posix_spawn(&child, "/bin/Terminal", nullptr, nullptr, const_cast<char**>(argv), environ))) { + perror("posix_spawn"); + } else { + if (disown(child) < 0) + perror("disown"); + } + })); + + app_menu.add_action(open_settings_action); + app_menu.add_separator(); + app_menu.add_action(GUI::CommonActions::make_quit_action([](auto&) { + dbgln("Terminal: Quit menu activated!"); + GUI::Application::the()->quit(); + })); + + auto& edit_menu = menubar->add_menu("Edit"); + edit_menu.add_action(terminal.copy_action()); + edit_menu.add_action(terminal.paste_action()); + edit_menu.add_separator(); + edit_menu.add_action(GUI::Action::create("Find...", { Mod_Ctrl | Mod_Shift, Key_F }, Gfx::Bitmap::load_from_file("/res/icons/16x16/find.png"), + [&](auto&) { + if (!find_window) { + find_window = create_find_window(terminal); + find_window->on_close_request = [&] { + find_window = nullptr; + return GUI::Window::CloseRequestDecision::Close; + }; + } + find_window->show(); + find_window->move_to_front(); + })); + + auto& view_menu = menubar->add_menu("View"); + view_menu.add_action(terminal.clear_including_history_action()); + view_menu.add_separator(); + view_menu.add_action(pick_font_action); + + auto& help_menu = menubar->add_menu("Help"); + help_menu.add_action(GUI::CommonActions::make_help_action([](auto&) { + Desktop::Launcher::open(URL::create_with_file_protocol("/usr/share/man/man1/Terminal.md"), "/bin/Help"); + })); + help_menu.add_action(GUI::CommonActions::make_about_action("Terminal", app_icon, window)); + + app->set_menubar(move(menubar)); + + if (unveil("/res", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/bin", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/bin/Terminal", "x") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/bin/utmpupdate", "x") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/etc/FileIconProvider.ini", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil("/tmp/portal/launch", "rw") < 0) { + perror("unveil"); + return 1; + } + + if (unveil(config->file_name().characters(), "rwc")) { + perror("unveil"); + return 1; + } + + unveil(nullptr, nullptr); + + config->sync(); + int result = app->exec(); + dbgln("Exiting terminal, updating utmp"); + utmp_update(pts_name, 0, false); + return result; +} diff --git a/Userland/Applications/TextEditor/CMakeLists.txt b/Userland/Applications/TextEditor/CMakeLists.txt new file mode 100644 index 0000000000..07eccd212e --- /dev/null +++ b/Userland/Applications/TextEditor/CMakeLists.txt @@ -0,0 +1,10 @@ +compile_gml(TextEditorWindow.gml TextEditorWindowGML.h text_editor_window_gml) + +set(SOURCES + main.cpp + TextEditorWidget.cpp + TextEditorWindowGML.h +) + +serenity_app(TextEditor ICON app-text-editor) +target_link_libraries(TextEditor LibWeb LibMarkdown LibGUI LibShell LibRegex LibDesktop) diff --git a/Userland/Applications/TextEditor/TextEditorWidget.cpp b/Userland/Applications/TextEditor/TextEditorWidget.cpp new file mode 100644 index 0000000000..982bcc282c --- /dev/null +++ b/Userland/Applications/TextEditor/TextEditorWidget.cpp @@ -0,0 +1,667 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "TextEditorWidget.h" +#include <AK/JsonObject.h> +#include <AK/JsonValue.h> +#include <AK/Optional.h> +#include <AK/StringBuilder.h> +#include <AK/URL.h> +#include <Applications/TextEditor/TextEditorWindowGML.h> +#include <LibCore/File.h> +#include <LibCore/MimeData.h> +#include <LibDesktop/Launcher.h> +#include <LibGUI/Action.h> +#include <LibGUI/ActionGroup.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/CppSyntaxHighlighter.h> +#include <LibGUI/FilePicker.h> +#include <LibGUI/FontPicker.h> +#include <LibGUI/GMLSyntaxHighlighter.h> +#include <LibGUI/INISyntaxHighlighter.h> +#include <LibGUI/JSSyntaxHighlighter.h> +#include <LibGUI/Menu.h> +#include <LibGUI/MenuBar.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/RegularEditingEngine.h> +#include <LibGUI/ShellSyntaxHighlighter.h> +#include <LibGUI/Splitter.h> +#include <LibGUI/StatusBar.h> +#include <LibGUI/TextBox.h> +#include <LibGUI/TextEditor.h> +#include <LibGUI/ToolBar.h> +#include <LibGUI/ToolBarContainer.h> +#include <LibGUI/VimEditingEngine.h> +#include <LibGfx/Font.h> +#include <LibMarkdown/Document.h> +#include <LibWeb/OutOfProcessWebView.h> +#include <string.h> + +TextEditorWidget::TextEditorWidget() +{ + load_from_gml(text_editor_window_gml); + + auto& toolbar = *find_descendant_of_type_named<GUI::ToolBar>("toolbar"); + + m_editor = *find_descendant_of_type_named<GUI::TextEditor>("editor"); + m_editor->set_ruler_visible(true); + m_editor->set_automatic_indentation_enabled(true); + m_editor->set_line_wrapping_enabled(true); + m_editor->set_editing_engine(make<GUI::RegularEditingEngine>()); + + m_editor->on_change = [this] { + update_preview(); + + // Do not mark as dirty on the first change (When document is first opened.) + if (m_document_opening) { + m_document_opening = false; + return; + } + + bool was_dirty = m_document_dirty; + m_document_dirty = true; + if (!was_dirty) + update_title(); + }; + + m_page_view = *find_descendant_of_type_named<Web::OutOfProcessWebView>("webview"); + m_page_view->on_link_hover = [this](auto& url) { + if (url.is_valid()) + m_statusbar->set_text(url.to_string()); + else + update_statusbar_cursor_position(); + }; + m_page_view->on_link_click = [&](auto& url, auto&, unsigned) { + if (!Desktop::Launcher::open(url)) { + GUI::MessageBox::show( + window(), + String::formatted("The link to '{}' could not be opened.", url), + "Failed to open link", + GUI::MessageBox::Type::Error); + } + }; + + m_find_replace_widget = *find_descendant_of_type_named<GUI::Widget>("find_replace_widget"); + + m_find_widget = *find_descendant_of_type_named<GUI::Widget>("find_widget"); + + m_replace_widget = *find_descendant_of_type_named<GUI::Widget>("replace_widget"); + + m_find_textbox = m_find_widget->add<GUI::TextBox>(); + m_replace_textbox = m_replace_widget->add<GUI::TextBox>(); + + m_find_next_action = GUI::Action::create("Find next", { Mod_Ctrl, Key_G }, Gfx::Bitmap::load_from_file("/res/icons/16x16/find-next.png"), [&](auto&) { + auto needle = m_find_textbox->text(); + if (needle.is_empty()) { + dbgln("find_next(\"\")"); + return; + } + + if (m_find_use_regex) + m_editor->document().update_regex_matches(needle); + + auto found_range = m_editor->document().find_next(needle, m_editor->normalized_selection().end(), GUI::TextDocument::SearchShouldWrap::Yes, m_find_use_regex); + dbgln("find_next('{}') returned {}", needle, found_range); + if (found_range.is_valid()) { + m_editor->set_selection(found_range); + } else { + GUI::MessageBox::show(window(), + String::formatted("Not found: \"{}\"", needle), + "Not found", + GUI::MessageBox::Type::Information); + } + }); + + m_find_regex_action = GUI::Action::create("Find regex", { Mod_Ctrl | Mod_Shift, Key_R }, [&](auto&) { + m_find_regex_button->set_checked(!m_find_regex_button->is_checked()); + m_find_use_regex = m_find_regex_button->is_checked(); + }); + + m_find_previous_action = GUI::Action::create("Find previous", { Mod_Ctrl | Mod_Shift, Key_G }, [&](auto&) { + auto needle = m_find_textbox->text(); + if (needle.is_empty()) { + dbgln("find_prev(\"\")"); + return; + } + + auto selection_start = m_editor->normalized_selection().start(); + if (!selection_start.is_valid()) + selection_start = m_editor->normalized_selection().end(); + + if (m_find_use_regex) + m_editor->document().update_regex_matches(needle); + + auto found_range = m_editor->document().find_previous(needle, selection_start, GUI::TextDocument::SearchShouldWrap::Yes, m_find_use_regex); + + dbgln("find_prev(\"{}\") returned {}", needle, found_range); + if (found_range.is_valid()) { + m_editor->set_selection(found_range); + } else { + GUI::MessageBox::show(window(), + String::formatted("Not found: \"{}\"", needle), + "Not found", + GUI::MessageBox::Type::Information); + } + }); + + m_replace_next_action = GUI::Action::create("Replace next", { Mod_Ctrl, Key_F1 }, [&](auto&) { + auto needle = m_find_textbox->text(); + auto substitute = m_replace_textbox->text(); + + if (needle.is_empty()) + return; + + auto selection_start = m_editor->normalized_selection().start(); + if (!selection_start.is_valid()) + selection_start = m_editor->normalized_selection().start(); + + if (m_find_use_regex) + m_editor->document().update_regex_matches(needle); + + auto found_range = m_editor->document().find_next(needle, selection_start, GUI::TextDocument::SearchShouldWrap::Yes, m_find_use_regex); + + if (found_range.is_valid()) { + m_editor->set_selection(found_range); + m_editor->insert_at_cursor_or_replace_selection(substitute); + } else { + GUI::MessageBox::show(window(), + String::formatted("Not found: \"{}\"", needle), + "Not found", + GUI::MessageBox::Type::Information); + } + }); + + m_replace_previous_action = GUI::Action::create("Replace previous", { Mod_Ctrl | Mod_Shift, Key_F1 }, [&](auto&) { + auto needle = m_find_textbox->text(); + auto substitute = m_replace_textbox->text(); + if (needle.is_empty()) + return; + + auto selection_start = m_editor->normalized_selection().start(); + if (!selection_start.is_valid()) + selection_start = m_editor->normalized_selection().start(); + + if (m_find_use_regex) + m_editor->document().update_regex_matches(needle); + + auto found_range = m_editor->document().find_previous(needle, selection_start); + + if (found_range.is_valid()) { + m_editor->set_selection(found_range); + m_editor->insert_at_cursor_or_replace_selection(substitute); + } else { + GUI::MessageBox::show(window(), + String::formatted("Not found: \"{}\"", needle), + "Not found", + GUI::MessageBox::Type::Information); + } + }); + + m_replace_all_action = GUI::Action::create("Replace all", { Mod_Ctrl, Key_F2 }, [&](auto&) { + auto needle = m_find_textbox->text(); + auto substitute = m_replace_textbox->text(); + if (needle.is_empty()) + return; + if (m_find_use_regex) + m_editor->document().update_regex_matches(needle); + + auto found_range = m_editor->document().find_next(needle, {}, GUI::TextDocument::SearchShouldWrap::Yes, m_find_use_regex); + while (found_range.is_valid()) { + m_editor->set_selection(found_range); + m_editor->insert_at_cursor_or_replace_selection(substitute); + found_range = m_editor->document().find_next(needle, {}, GUI::TextDocument::SearchShouldWrap::Yes, m_find_use_regex); + } + }); + + m_find_previous_button = *find_descendant_of_type_named<GUI::Button>("find_previous_button"); + m_find_previous_button->set_action(*m_find_previous_action); + + m_find_next_button = *find_descendant_of_type_named<GUI::Button>("find_next_button"); + m_find_next_button->set_action(*m_find_next_action); + + m_find_textbox->on_return_pressed = [this] { + m_find_next_button->click(); + }; + + m_find_regex_button = m_find_widget->add<GUI::Button>(".*"); + m_find_regex_button->set_fixed_width(20); + m_find_regex_button->set_action(*m_find_regex_action); + + m_find_textbox->on_escape_pressed = [this] { + m_find_replace_widget->set_visible(false); + m_editor->set_focus(true); + }; + + m_replace_previous_button = *find_descendant_of_type_named<GUI::Button>("replace_previous_button"); + m_replace_previous_button->set_action(*m_replace_previous_action); + + m_replace_next_button = *find_descendant_of_type_named<GUI::Button>("replace_next_button"); + m_replace_next_button->set_action(*m_replace_next_action); + + m_replace_all_button = *find_descendant_of_type_named<GUI::Button>("replace_all_button"); + m_replace_all_button->set_action(*m_replace_all_action); + + m_replace_textbox->on_return_pressed = [this] { + m_replace_next_button->click(); + }; + + m_replace_textbox->on_escape_pressed = [this] { + m_find_replace_widget->set_visible(false); + m_editor->set_focus(true); + }; + + m_vim_emulation_setting_action = GUI::Action::create_checkable("Vim emulation", { Mod_Ctrl | Mod_Shift | Mod_Alt, Key_V }, [&](auto& action) { + if (action.is_checked()) + m_editor->set_editing_engine(make<GUI::VimEditingEngine>()); + else + m_editor->set_editing_engine(make<GUI::RegularEditingEngine>()); + }); + m_vim_emulation_setting_action->set_checked(false); + + m_find_replace_action = GUI::Action::create("Find/Replace...", { Mod_Ctrl, Key_F }, Gfx::Bitmap::load_from_file("/res/icons/16x16/find.png"), [this](auto&) { + m_find_replace_widget->set_visible(true); + m_find_widget->set_visible(true); + m_replace_widget->set_visible(true); + m_find_textbox->set_focus(true); + + if (m_editor->has_selection()) { + auto selected_text = m_editor->document().text_in_range(m_editor->normalized_selection()); + m_find_textbox->set_text(selected_text); + } + m_find_textbox->select_all(); + }); + + m_editor->add_custom_context_menu_action(*m_find_replace_action); + m_editor->add_custom_context_menu_action(*m_find_next_action); + m_editor->add_custom_context_menu_action(*m_find_previous_action); + + m_statusbar = *find_descendant_of_type_named<GUI::StatusBar>("statusbar"); + + m_editor->on_cursor_change = [this] { update_statusbar_cursor_position(); }; + + m_new_action = GUI::Action::create("New", { Mod_Ctrl, Key_N }, Gfx::Bitmap::load_from_file("/res/icons/16x16/new.png"), [this](const GUI::Action&) { + if (m_document_dirty) { + auto save_document_first_result = GUI::MessageBox::show(window(), "Save changes to current document first?", "Warning", GUI::MessageBox::Type::Warning, GUI::MessageBox::InputType::YesNoCancel); + if (save_document_first_result == GUI::Dialog::ExecResult::ExecYes) + m_save_action->activate(); + if (save_document_first_result == GUI::Dialog::ExecResult::ExecCancel) + return; + } + + m_document_dirty = false; + m_editor->set_text(StringView()); + set_path(LexicalPath()); + update_title(); + }); + + m_open_action = GUI::CommonActions::make_open_action([this](auto&) { + Optional<String> open_path = GUI::FilePicker::get_open_filepath(window()); + + if (!open_path.has_value()) + return; + + if (m_document_dirty) { + auto save_document_first_result = GUI::MessageBox::show(window(), "Save changes to current document first?", "Warning", GUI::MessageBox::Type::Warning, GUI::MessageBox::InputType::YesNoCancel); + if (save_document_first_result == GUI::Dialog::ExecResult::ExecYes) + m_save_action->activate(); + if (save_document_first_result == GUI::Dialog::ExecResult::ExecCancel) + return; + } + + open_sesame(open_path.value()); + }); + + m_save_as_action = GUI::CommonActions::make_save_as_action([&](auto&) { + Optional<String> save_path = GUI::FilePicker::get_save_filepath(window(), m_name.is_null() ? "Untitled" : m_name, m_extension.is_null() ? "txt" : m_extension); + if (!save_path.has_value()) + return; + + if (!m_editor->write_to_file(save_path.value())) { + GUI::MessageBox::show(window(), "Unable to save file.\n", "Error", GUI::MessageBox::Type::Error); + return; + } + + m_document_dirty = false; + set_path(LexicalPath(save_path.value())); + dbgln("Wrote document to {}", save_path.value()); + }); + + m_save_action = GUI::CommonActions::make_save_action([&](auto&) { + if (!m_path.is_empty()) { + if (!m_editor->write_to_file(m_path)) { + GUI::MessageBox::show(window(), "Unable to save file.\n", "Error", GUI::MessageBox::Type::Error); + } else { + m_document_dirty = false; + update_title(); + } + return; + } + + m_save_as_action->activate(); + }); + + m_line_wrapping_setting_action = GUI::Action::create_checkable("Line wrapping", [&](auto& action) { + m_editor->set_line_wrapping_enabled(action.is_checked()); + }); + m_line_wrapping_setting_action->set_checked(m_editor->is_line_wrapping_enabled()); + + auto menubar = GUI::MenuBar::construct(); + auto& app_menu = menubar->add_menu("Text Editor"); + app_menu.add_action(*m_new_action); + app_menu.add_action(*m_open_action); + app_menu.add_action(*m_save_action); + app_menu.add_action(*m_save_as_action); + app_menu.add_separator(); + app_menu.add_action(GUI::CommonActions::make_quit_action([this](auto&) { + if (!request_close()) + return; + GUI::Application::the()->quit(); + })); + + auto& edit_menu = menubar->add_menu("Edit"); + edit_menu.add_action(m_editor->undo_action()); + edit_menu.add_action(m_editor->redo_action()); + edit_menu.add_separator(); + edit_menu.add_action(m_editor->cut_action()); + edit_menu.add_action(m_editor->copy_action()); + edit_menu.add_action(m_editor->paste_action()); + edit_menu.add_action(m_editor->delete_action()); + edit_menu.add_separator(); + edit_menu.add_action(*m_vim_emulation_setting_action); + edit_menu.add_separator(); + edit_menu.add_action(*m_find_replace_action); + edit_menu.add_action(*m_find_next_action); + edit_menu.add_action(*m_find_regex_action); + edit_menu.add_action(*m_find_previous_action); + edit_menu.add_action(*m_replace_next_action); + edit_menu.add_action(*m_replace_previous_action); + edit_menu.add_action(*m_replace_all_action); + + m_no_preview_action = GUI::Action::create_checkable( + "No preview", [this](auto&) { + set_preview_mode(PreviewMode::None); + }); + + m_markdown_preview_action = GUI::Action::create_checkable( + "Markdown preview", [this](auto&) { + set_preview_mode(PreviewMode::Markdown); + }, + this); + + m_html_preview_action = GUI::Action::create_checkable( + "HTML preview", [this](auto&) { + set_preview_mode(PreviewMode::HTML); + }, + this); + + m_preview_actions.add_action(*m_no_preview_action); + m_preview_actions.add_action(*m_markdown_preview_action); + m_preview_actions.add_action(*m_html_preview_action); + m_preview_actions.set_exclusive(true); + + auto& view_menu = menubar->add_menu("View"); + view_menu.add_action(GUI::Action::create("Editor font...", Gfx::Bitmap::load_from_file("/res/icons/16x16/app-font-editor.png"), + [&](auto&) { + auto picker = GUI::FontPicker::construct(window(), &m_editor->font(), true); + if (picker->exec() == GUI::Dialog::ExecOK) { + dbgln("setting font {}", picker->font()->qualified_name()); + m_editor->set_font(picker->font()); + } + })); + + view_menu.add_separator(); + view_menu.add_action(*m_line_wrapping_setting_action); + view_menu.add_separator(); + view_menu.add_action(*m_no_preview_action); + view_menu.add_action(*m_markdown_preview_action); + view_menu.add_action(*m_html_preview_action); + view_menu.add_separator(); + + syntax_actions.set_exclusive(true); + + auto& syntax_menu = view_menu.add_submenu("Syntax"); + m_plain_text_highlight = GUI::Action::create_checkable("Plain text", [&](auto&) { + m_editor->set_syntax_highlighter({}); + m_editor->update(); + }); + m_plain_text_highlight->set_checked(true); + syntax_actions.add_action(*m_plain_text_highlight); + syntax_menu.add_action(*m_plain_text_highlight); + + m_cpp_highlight = GUI::Action::create_checkable("C++", [&](auto&) { + m_editor->set_syntax_highlighter(make<GUI::CppSyntaxHighlighter>()); + m_editor->update(); + }); + syntax_actions.add_action(*m_cpp_highlight); + syntax_menu.add_action(*m_cpp_highlight); + + m_js_highlight = GUI::Action::create_checkable("JavaScript", [&](auto&) { + m_editor->set_syntax_highlighter(make<GUI::JSSyntaxHighlighter>()); + m_editor->update(); + }); + syntax_actions.add_action(*m_js_highlight); + syntax_menu.add_action(*m_js_highlight); + + m_gml_highlight = GUI::Action::create_checkable("GML", [&](auto&) { + m_editor->set_syntax_highlighter(make<GUI::GMLSyntaxHighlighter>()); + m_editor->update(); + }); + syntax_actions.add_action(*m_gml_highlight); + syntax_menu.add_action(*m_gml_highlight); + + m_ini_highlight = GUI::Action::create_checkable("INI File", [&](auto&) { + m_editor->set_syntax_highlighter(make<GUI::IniSyntaxHighlighter>()); + m_editor->update(); + }); + syntax_actions.add_action(*m_ini_highlight); + syntax_menu.add_action(*m_ini_highlight); + + m_shell_highlight = GUI::Action::create_checkable("Shell File", [&](auto&) { + m_editor->set_syntax_highlighter(make<GUI::ShellSyntaxHighlighter>()); + m_editor->update(); + }); + syntax_actions.add_action(*m_shell_highlight); + syntax_menu.add_action(*m_shell_highlight); + + auto& help_menu = menubar->add_menu("Help"); + help_menu.add_action(GUI::CommonActions::make_about_action("Text Editor", GUI::Icon::default_icon("app-text-editor"), window())); + + GUI::Application::the()->set_menubar(move(menubar)); + + toolbar.add_action(*m_new_action); + toolbar.add_action(*m_open_action); + toolbar.add_action(*m_save_action); + + toolbar.add_separator(); + + toolbar.add_action(m_editor->cut_action()); + toolbar.add_action(m_editor->copy_action()); + toolbar.add_action(m_editor->paste_action()); + toolbar.add_action(m_editor->delete_action()); + + toolbar.add_separator(); + + toolbar.add_action(m_editor->undo_action()); + toolbar.add_action(m_editor->redo_action()); +} + +TextEditorWidget::~TextEditorWidget() +{ +} + +void TextEditorWidget::set_path(const LexicalPath& lexical_path) +{ + m_path = lexical_path.string(); + m_name = lexical_path.title(); + m_extension = lexical_path.extension(); + + if (m_extension == "c" || m_extension == "cc" || m_extension == "cxx" || m_extension == "cpp" || m_extension == "h") { + m_cpp_highlight->activate(); + } else if (m_extension == "js" || m_extension == "json") { + m_js_highlight->activate(); + } else if (m_extension == "gml") { + m_gml_highlight->activate(); + } else if (m_extension == "ini") { + m_ini_highlight->activate(); + } else { + m_plain_text_highlight->activate(); + } + + if (m_auto_detect_preview_mode) { + if (m_extension == "md") + set_preview_mode(PreviewMode::Markdown); + else if (m_extension == "html") + set_preview_mode(PreviewMode::HTML); + else + set_preview_mode(PreviewMode::None); + } + + update_title(); +} + +void TextEditorWidget::update_title() +{ + StringBuilder builder; + if (m_path.is_empty()) + builder.append("Untitled"); + else + builder.append(m_path); + if (m_document_dirty) + builder.append(" (*)"); + builder.append(" - Text Editor"); + window()->set_title(builder.to_string()); +} + +void TextEditorWidget::open_sesame(const String& path) +{ + auto file = Core::File::construct(path); + if (!file->open(Core::IODevice::ReadOnly) && file->error() != ENOENT) { + GUI::MessageBox::show(window(), String::formatted("Opening \"{}\" failed: {}", path, strerror(errno)), "Error", GUI::MessageBox::Type::Error); + return; + } + + m_editor->set_text(file->read_all()); + m_document_dirty = false; + m_document_opening = true; + + set_path(LexicalPath(path)); + + m_editor->set_focus(true); +} + +bool TextEditorWidget::request_close() +{ + if (!m_document_dirty) + return true; + auto result = GUI::MessageBox::show(window(), "The document has been modified. Would you like to save?", "Unsaved changes", GUI::MessageBox::Type::Warning, GUI::MessageBox::InputType::YesNoCancel); + + if (result == GUI::MessageBox::ExecYes) { + m_save_action->activate(); + return true; + } + + if (result == GUI::MessageBox::ExecNo) + return true; + + return false; +} + +void TextEditorWidget::drop_event(GUI::DropEvent& event) +{ + event.accept(); + window()->move_to_front(); + + if (event.mime_data().has_urls()) { + auto urls = event.mime_data().urls(); + if (urls.is_empty()) + return; + if (urls.size() > 1) { + GUI::MessageBox::show(window(), "TextEditor can only open one file at a time!", "One at a time please!", GUI::MessageBox::Type::Error); + return; + } + open_sesame(urls.first().path()); + } +} + +void TextEditorWidget::set_preview_mode(PreviewMode mode) +{ + if (m_preview_mode == mode) + return; + m_preview_mode = mode; + + if (m_preview_mode == PreviewMode::HTML) { + m_html_preview_action->set_checked(true); + m_page_view->set_visible(true); + update_html_preview(); + } else if (m_preview_mode == PreviewMode::Markdown) { + m_markdown_preview_action->set_checked(true); + m_page_view->set_visible(true); + update_markdown_preview(); + } else { + m_no_preview_action->set_checked(true); + m_page_view->set_visible(false); + } +} + +void TextEditorWidget::update_preview() +{ + switch (m_preview_mode) { + case PreviewMode::Markdown: + update_markdown_preview(); + break; + case PreviewMode::HTML: + update_html_preview(); + break; + default: + break; + } +} + +void TextEditorWidget::update_markdown_preview() +{ + auto document = Markdown::Document::parse(m_editor->text()); + if (document) { + auto html = document->render_to_html(); + auto current_scroll_pos = m_page_view->visible_content_rect(); + m_page_view->load_html(html, URL::create_with_file_protocol(m_path)); + m_page_view->scroll_into_view(current_scroll_pos, true, true); + } +} + +void TextEditorWidget::update_html_preview() +{ + auto current_scroll_pos = m_page_view->visible_content_rect(); + m_page_view->load_html(m_editor->text(), URL::create_with_file_protocol(m_path)); + m_page_view->scroll_into_view(current_scroll_pos, true, true); +} + +void TextEditorWidget::update_statusbar_cursor_position() +{ + StringBuilder builder; + builder.appendff("Line: {}, Column: {}", m_editor->cursor().line() + 1, m_editor->cursor().column()); + m_statusbar->set_text(builder.to_string()); +} diff --git a/Userland/Applications/TextEditor/TextEditorWidget.h b/Userland/Applications/TextEditor/TextEditorWidget.h new file mode 100644 index 0000000000..763087b9e4 --- /dev/null +++ b/Userland/Applications/TextEditor/TextEditorWidget.h @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/Function.h> +#include <AK/LexicalPath.h> +#include <LibGUI/ActionGroup.h> +#include <LibGUI/Application.h> +#include <LibGUI/Icon.h> +#include <LibGUI/TextEditor.h> +#include <LibGUI/Widget.h> +#include <LibGUI/Window.h> +#include <LibWeb/Forward.h> + +class TextEditorWidget final : public GUI::Widget { + C_OBJECT(TextEditorWidget) +public: + virtual ~TextEditorWidget() override; + void open_sesame(const String& path); + bool request_close(); + + GUI::TextEditor& editor() { return *m_editor; } + + enum class PreviewMode { + None, + Markdown, + HTML, + }; + + void set_preview_mode(PreviewMode); + void set_auto_detect_preview_mode(bool value) { m_auto_detect_preview_mode = value; } + + void update_title(); + +private: + TextEditorWidget(); + void set_path(const LexicalPath& file); + void update_preview(); + void update_markdown_preview(); + void update_html_preview(); + void update_statusbar_cursor_position(); + + virtual void drop_event(GUI::DropEvent&) override; + + RefPtr<GUI::TextEditor> m_editor; + String m_path; + String m_name; + String m_extension; + RefPtr<GUI::Action> m_new_action; + RefPtr<GUI::Action> m_open_action; + RefPtr<GUI::Action> m_save_action; + RefPtr<GUI::Action> m_save_as_action; + RefPtr<GUI::Action> m_find_replace_action; + RefPtr<GUI::Action> m_line_wrapping_setting_action; + RefPtr<GUI::Action> m_vim_emulation_setting_action; + + RefPtr<GUI::Action> m_find_next_action; + RefPtr<GUI::Action> m_find_regex_action; + RefPtr<GUI::Action> m_find_previous_action; + RefPtr<GUI::Action> m_replace_next_action; + RefPtr<GUI::Action> m_replace_previous_action; + RefPtr<GUI::Action> m_replace_all_action; + + GUI::ActionGroup m_preview_actions; + RefPtr<GUI::Action> m_no_preview_action; + RefPtr<GUI::Action> m_markdown_preview_action; + RefPtr<GUI::Action> m_html_preview_action; + + RefPtr<GUI::StatusBar> m_statusbar; + + RefPtr<GUI::TextBox> m_find_textbox; + RefPtr<GUI::TextBox> m_replace_textbox; + RefPtr<GUI::Button> m_find_previous_button; + RefPtr<GUI::Button> m_find_next_button; + RefPtr<GUI::Button> m_find_regex_button; + RefPtr<GUI::Button> m_replace_previous_button; + RefPtr<GUI::Button> m_replace_next_button; + RefPtr<GUI::Button> m_replace_all_button; + RefPtr<GUI::Widget> m_find_replace_widget; + RefPtr<GUI::Widget> m_find_widget; + RefPtr<GUI::Widget> m_replace_widget; + + GUI::ActionGroup syntax_actions; + RefPtr<GUI::Action> m_plain_text_highlight; + RefPtr<GUI::Action> m_cpp_highlight; + RefPtr<GUI::Action> m_js_highlight; + RefPtr<GUI::Action> m_gml_highlight; + RefPtr<GUI::Action> m_ini_highlight; + RefPtr<GUI::Action> m_shell_highlight; + + RefPtr<Web::OutOfProcessWebView> m_page_view; + + bool m_document_dirty { false }; + bool m_document_opening { false }; + bool m_auto_detect_preview_mode { false }; + bool m_find_use_regex { false }; + + PreviewMode m_preview_mode { PreviewMode::None }; +}; diff --git a/Userland/Applications/TextEditor/TextEditorWindow.gml b/Userland/Applications/TextEditor/TextEditorWindow.gml new file mode 100644 index 0000000000..433d01abff --- /dev/null +++ b/Userland/Applications/TextEditor/TextEditorWindow.gml @@ -0,0 +1,88 @@ +@GUI::Widget { + name: "main" + fill_with_background_color: true + + layout: @GUI::VerticalBoxLayout { + spacing: 2 + } + + @GUI::ToolBarContainer { + @GUI::ToolBar { + name: "toolbar" + } + } + + @GUI::HorizontalSplitter { + @GUI::TextEditor { + name: "editor" + } + + @Web::OutOfProcessWebView { + name: "webview" + visible: false + } + } + + @GUI::Widget { + name: "find_replace_widget" + visible: false + fill_with_background_color: true + fixed_height: 48 + + layout: @GUI::VerticalBoxLayout { + margins: [2, 2, 2, 4] + } + + @GUI::Widget { + name: "find_widget" + fill_with_background_color: true + fixed_height: 22 + + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::Button { + name: "find_previous_button" + text: "Find previous" + fixed_width: 150 + } + + @GUI::Button { + name: "find_next_button" + text: "Find next" + fixed_width: 150 + } + } + + @GUI::Widget { + name: "replace_widget" + fill_with_background_color: true + fixed_height: 22 + + layout: @GUI::HorizontalBoxLayout { + } + + @GUI::Button { + name: "replace_previous_button" + text: "Replace previous" + fixed_width: 100 + } + + @GUI::Button { + name: "replace_next_button" + text: "Replace next" + fixed_width: 100 + } + + @GUI::Button { + name: "replace_all_button" + text: "Replace all" + fixed_width: 100 + } + } + } + + @GUI::StatusBar { + name: "statusbar" + } +} diff --git a/Userland/Applications/TextEditor/main.cpp b/Userland/Applications/TextEditor/main.cpp new file mode 100644 index 0000000000..8e9c8fdfb8 --- /dev/null +++ b/Userland/Applications/TextEditor/main.cpp @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "TextEditorWidget.h" +#include <LibCore/ArgsParser.h> +#include <LibGfx/Bitmap.h> +#include <stdio.h> + +int main(int argc, char** argv) +{ + if (pledge("stdio thread rpath accept cpath wpath shared_buffer unix fattr", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio thread rpath accept cpath wpath shared_buffer unix", nullptr) < 0) { + perror("pledge"); + return 1; + } + + const char* preview_mode = "auto"; + const char* file_to_edit = nullptr; + Core::ArgsParser parser; + parser.add_option(preview_mode, "Preview mode, one of 'none', 'html', 'markdown', 'auto'", "preview-mode", '\0', "mode"); + parser.add_positional_argument(file_to_edit, "File to edit", "file", Core::ArgsParser::Required::No); + + parser.parse(argc, argv); + + StringView preview_mode_view = preview_mode; + + auto app_icon = GUI::Icon::default_icon("app-text-editor"); + + auto window = GUI::Window::construct(); + window->resize(640, 400); + + auto& text_widget = window->set_main_widget<TextEditorWidget>(); + + text_widget.editor().set_focus(true); + + window->on_close_request = [&]() -> GUI::Window::CloseRequestDecision { + if (text_widget.request_close()) + return GUI::Window::CloseRequestDecision::Close; + return GUI::Window::CloseRequestDecision::StayOpen; + }; + + if (preview_mode_view == "auto") { + text_widget.set_auto_detect_preview_mode(true); + } else if (preview_mode_view == "markdown") { + text_widget.set_preview_mode(TextEditorWidget::PreviewMode::Markdown); + } else if (preview_mode_view == "html") { + text_widget.set_preview_mode(TextEditorWidget::PreviewMode::HTML); + } else if (preview_mode_view == "none") { + text_widget.set_preview_mode(TextEditorWidget::PreviewMode::None); + } else { + warnln("Invalid mode '{}'", preview_mode); + return 1; + } + + if (file_to_edit) + text_widget.open_sesame(file_to_edit); + else + text_widget.update_title(); + + window->show(); + window->set_icon(app_icon.bitmap_for_size(16)); + + return app->exec(); +} diff --git a/Userland/Applications/ThemeEditor/CMakeLists.txt b/Userland/Applications/ThemeEditor/CMakeLists.txt new file mode 100644 index 0000000000..94ca0e1f3e --- /dev/null +++ b/Userland/Applications/ThemeEditor/CMakeLists.txt @@ -0,0 +1,7 @@ +set(SOURCES + PreviewWidget.cpp + main.cpp +) + +serenity_app(ThemeEditor ICON app-theme-editor) +target_link_libraries(ThemeEditor LibGUI) diff --git a/Userland/Applications/ThemeEditor/PreviewWidget.cpp b/Userland/Applications/ThemeEditor/PreviewWidget.cpp new file mode 100644 index 0000000000..a57da75936 --- /dev/null +++ b/Userland/Applications/ThemeEditor/PreviewWidget.cpp @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "PreviewWidget.h" +#include <AK/StringView.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/CheckBox.h> +#include <LibGUI/Painter.h> +#include <LibGUI/RadioButton.h> +#include <LibGUI/StatusBar.h> +#include <LibGUI/TextEditor.h> +#include <LibGfx/Bitmap.h> +#include <LibGfx/WindowTheme.h> + +namespace ThemeEditor { + +class MiniWidgetGallery final : public GUI::Widget { + C_OBJECT(MiniWidgetGallery); + +public: + void set_preview_palette(const Gfx::Palette& palette) + { + set_palette(palette); + Function<void(GUI::Widget&)> recurse = [&](GUI::Widget& parent_widget) { + parent_widget.for_each_child_widget([&](auto& widget) { + widget.set_palette(palette); + recurse(widget); + return IterationDecision::Continue; + }); + }; + recurse(*this); + } + +private: + MiniWidgetGallery() + { + set_fill_with_background_color(true); + m_button = add<GUI::Button>(); + m_button->set_text("Button"); + m_checkbox = add<GUI::CheckBox>(); + m_checkbox->set_text("Check box"); + m_radio = add<GUI::RadioButton>(); + m_radio->set_text("Radio button"); + m_statusbar = add<GUI::StatusBar>(); + m_statusbar->set_text("Status bar"); + m_editor = add<GUI::TextEditor>(); + m_editor->set_text("Text editor\nwith multiple\nlines."); + } + + virtual void resize_event(GUI::ResizeEvent&) override + { + m_editor->set_relative_rect(10, 70, 200, 140); + m_button->set_relative_rect(10, 10, 200, 20); + m_checkbox->set_relative_rect(10, 30, 200, 20); + m_radio->set_relative_rect(10, 50, 200, 20); + m_statusbar->set_relative_rect(0, height() - 16, width(), 16); + } + + RefPtr<GUI::TextEditor> m_editor; + RefPtr<GUI::Button> m_button; + RefPtr<GUI::CheckBox> m_checkbox; + RefPtr<GUI::RadioButton> m_radio; + RefPtr<GUI::StatusBar> m_statusbar; +}; + +PreviewWidget::PreviewWidget(const Gfx::Palette& preview_palette) + : m_preview_palette(preview_palette) +{ + m_active_window_icon = Gfx::Bitmap::load_from_file("/res/icons/16x16/window.png"); + m_inactive_window_icon = Gfx::Bitmap::load_from_file("/res/icons/16x16/window.png"); + + m_close_bitmap = Gfx::Bitmap::load_from_file("/res/icons/16x16/window-close.png"); + m_maximize_bitmap = Gfx::Bitmap::load_from_file("/res/icons/16x16/upward-triangle.png"); + m_minimize_bitmap = Gfx::Bitmap::load_from_file("/res/icons/16x16/downward-triangle.png"); + + m_gallery = add<MiniWidgetGallery>(); + set_greedy_for_hits(true); +} + +PreviewWidget::~PreviewWidget() +{ +} + +void PreviewWidget::set_preview_palette(const Gfx::Palette& palette) +{ + m_preview_palette = palette; + m_gallery->set_preview_palette(palette); + update(); +} + +void PreviewWidget::paint_event(GUI::PaintEvent& event) +{ + GUI::Frame::paint_event(event); + GUI::Painter painter(*this); + + painter.add_clip_rect(event.rect()); + painter.add_clip_rect(frame_inner_rect()); + + painter.fill_rect(frame_inner_rect(), m_preview_palette.desktop_background()); + + struct Button { + Gfx::IntRect rect; + RefPtr<Gfx::Bitmap> bitmap; + }; + + auto paint_window = [&](auto& title, const Gfx::IntRect& rect, auto state, const Gfx::Bitmap& icon) { + int window_button_width = m_preview_palette.window_title_button_width(); + int window_button_height = m_preview_palette.window_title_button_height(); + auto title_bar_text_rect = Gfx::WindowTheme::current().title_bar_text_rect(Gfx::WindowTheme::WindowType::Normal, rect, m_preview_palette); + int pos = title_bar_text_rect.right() + 1; + + Vector<Button> buttons; + buttons.append(Button { {}, m_close_bitmap }); + buttons.append(Button { {}, m_maximize_bitmap }); + buttons.append(Button { {}, m_minimize_bitmap }); + + for (auto& button : buttons) { + pos -= window_button_width; + Gfx::IntRect rect { pos, 0, window_button_width, window_button_height }; + rect.center_vertically_within(title_bar_text_rect); + button.rect = rect; + } + + auto frame_rect = Gfx::WindowTheme::current().frame_rect_for_window(Gfx::WindowTheme::WindowType::Normal, rect, m_preview_palette); + Gfx::PainterStateSaver saver(painter); + painter.translate(frame_rect.location()); + Gfx::WindowTheme::current().paint_normal_frame(painter, state, rect, title, icon, m_preview_palette, buttons.last().rect); + + for (auto& button : buttons) { + Gfx::StylePainter::paint_button(painter, button.rect, m_preview_palette, Gfx::ButtonStyle::Normal, false); + auto bitmap_rect = button.bitmap->rect(); + bitmap_rect.center_within(button.rect); + painter.blit(bitmap_rect.location(), *button.bitmap, button.bitmap->rect()); + } + }; + + Gfx::IntRect active_rect { 0, 0, 320, 240 }; + active_rect.center_within(frame_inner_rect()); + Gfx::IntRect inactive_rect = active_rect.translated(-20, -20); + + paint_window("Inactive window", inactive_rect, Gfx::WindowTheme::WindowState::Inactive, *m_active_window_icon); + paint_window("Active window", active_rect, Gfx::WindowTheme::WindowState::Active, *m_inactive_window_icon); +} + +void PreviewWidget::resize_event(GUI::ResizeEvent&) +{ + Gfx::IntRect gallery_rect { 0, 0, 320, 240 }; + gallery_rect.center_within(rect()); + m_gallery->set_relative_rect(gallery_rect); +} + +} diff --git a/Userland/Applications/ThemeEditor/PreviewWidget.h b/Userland/Applications/ThemeEditor/PreviewWidget.h new file mode 100644 index 0000000000..41ef25e0a8 --- /dev/null +++ b/Userland/Applications/ThemeEditor/PreviewWidget.h @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Frame.h> +#include <LibGfx/Palette.h> + +namespace ThemeEditor { + +class MiniWidgetGallery; + +class PreviewWidget final : public GUI::Frame { + C_OBJECT(PreviewWidget); + +public: + virtual ~PreviewWidget() override; + + const Gfx::Palette& preview_palette() const { return m_preview_palette; } + void set_preview_palette(const Gfx::Palette&); + +private: + explicit PreviewWidget(const Gfx::Palette&); + + virtual void paint_event(GUI::PaintEvent&) override; + virtual void resize_event(GUI::ResizeEvent&) override; + + Gfx::Palette m_preview_palette; + + RefPtr<Gfx::Bitmap> m_active_window_icon; + RefPtr<Gfx::Bitmap> m_inactive_window_icon; + + RefPtr<MiniWidgetGallery> m_gallery; + + RefPtr<Gfx::Bitmap> m_close_bitmap; + RefPtr<Gfx::Bitmap> m_maximize_bitmap; + RefPtr<Gfx::Bitmap> m_minimize_bitmap; +}; + +} diff --git a/Userland/Applications/ThemeEditor/main.cpp b/Userland/Applications/ThemeEditor/main.cpp new file mode 100644 index 0000000000..d4a5936770 --- /dev/null +++ b/Userland/Applications/ThemeEditor/main.cpp @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "PreviewWidget.h" +#include <LibGUI/Application.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/ColorInput.h> +#include <LibGUI/ComboBox.h> +#include <LibGUI/Icon.h> +#include <LibGUI/Model.h> +#include <LibGUI/Window.h> + +class ColorRoleModel final : public GUI::Model { +public: + virtual int row_count(const GUI::ModelIndex&) const { return m_color_roles.size(); } + virtual int column_count(const GUI::ModelIndex&) const { return 1; } + virtual GUI::Variant data(const GUI::ModelIndex& index, GUI::ModelRole role = GUI::ModelRole::Display) const + { + if (role == GUI::ModelRole::Display) + return Gfx::to_string(m_color_roles[(size_t)index.row()]); + return {}; + } + virtual void update() { did_update(); } + + explicit ColorRoleModel(const Vector<Gfx::ColorRole>& color_roles) + : m_color_roles(color_roles) + { + } + + Gfx::ColorRole color_role(const GUI::ModelIndex& index) const + { + return m_color_roles[index.row()]; + } + + Gfx::ColorRole color_role(size_t index) const + { + return m_color_roles[index]; + } + +private: + const Vector<Gfx::ColorRole>& m_color_roles; +}; + +int main(int argc, char** argv) +{ + + if (pledge("stdio thread rpath accept cpath wpath shared_buffer unix fattr", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio thread rpath accept shared_buffer", nullptr) < 0) { + perror("pledge"); + return 1; + } + + if (unveil("/res", "r") < 0) { + perror("unveil"); + return 1; + } + + if (unveil(nullptr, nullptr) < 0) { + perror("unveil"); + return 1; + } + + auto app_icon = GUI::Icon::default_icon("app-theme-editor"); + + Gfx::Palette preview_palette = app->palette(); + + auto window = GUI::Window::construct(); + auto& main_widget = window->set_main_widget<GUI::Widget>(); + main_widget.set_fill_with_background_color(true); + main_widget.set_layout<GUI::VerticalBoxLayout>(); + + auto& preview_widget = main_widget.add<ThemeEditor::PreviewWidget>(app->palette()); + preview_widget.set_fixed_size(480, 360); + + auto& horizontal_container = main_widget.add<GUI::Widget>(); + horizontal_container.set_layout<GUI::HorizontalBoxLayout>(); + horizontal_container.set_fixed_size(480, 20); + + auto& combo_box = horizontal_container.add<GUI::ComboBox>(); + auto& color_input = horizontal_container.add<GUI::ColorInput>(); + + Vector<Gfx::ColorRole> color_roles; +#define __ENUMERATE_COLOR_ROLE(role) color_roles.append(Gfx::ColorRole::role); + ENUMERATE_COLOR_ROLES(__ENUMERATE_COLOR_ROLE) +#undef __ENUMERATE_COLOR_ROLE + + combo_box.set_only_allow_values_from_model(true); + combo_box.set_model(adopt(*new ColorRoleModel(color_roles))); + combo_box.on_change = [&](auto&, auto& index) { + auto role = static_cast<const ColorRoleModel*>(index.model())->color_role(index); + color_input.set_color(preview_palette.color(role)); + }; + + combo_box.set_selected_index((size_t)Gfx::ColorRole::Window - 1); + + color_input.on_change = [&] { + auto role = static_cast<const ColorRoleModel*>(combo_box.model())->color_role(combo_box.selected_index()); + preview_palette.set_color(role, color_input.color()); + preview_widget.set_preview_palette(preview_palette); + }; + + window->resize(480, 500); + window->show(); + window->set_title("Theme Editor"); + window->set_icon(app_icon.bitmap_for_size(16)); + return app->exec(); +} diff --git a/Userland/Applications/Welcome/BackgroundWidget.cpp b/Userland/Applications/Welcome/BackgroundWidget.cpp new file mode 100644 index 0000000000..c64f2fc93b --- /dev/null +++ b/Userland/Applications/Welcome/BackgroundWidget.cpp @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "BackgroundWidget.h" +#include <LibGUI/Painter.h> +#include <LibGfx/Color.h> +#include <LibGfx/Palette.h> + +BackgroundWidget::BackgroundWidget() +{ +} + +BackgroundWidget::~BackgroundWidget() +{ +} + +void BackgroundWidget::paint_event(GUI::PaintEvent& event) +{ + GUI::Frame::paint_event(event); + + GUI::Painter painter(*this); + painter.add_clip_rect(event.rect()); + painter.fill_rect_with_gradient(event.rect(), Color::from_rgb(0xccdddd), Color::from_rgb(0xdcdcde)); +} + +void BackgroundWidget::resize_event(GUI::ResizeEvent& event) +{ + GUI::Widget::resize_event(event); +} diff --git a/Userland/Applications/Welcome/BackgroundWidget.h b/Userland/Applications/Welcome/BackgroundWidget.h new file mode 100644 index 0000000000..fea3871edc --- /dev/null +++ b/Userland/Applications/Welcome/BackgroundWidget.h @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Frame.h> + +class BackgroundWidget : public GUI::Frame { + C_OBJECT(BackgroundWidget) +public: + virtual ~BackgroundWidget() override; + +private: + BackgroundWidget(); + + virtual void paint_event(GUI::PaintEvent&) override; + virtual void resize_event(GUI::ResizeEvent&) override; +}; diff --git a/Userland/Applications/Welcome/CMakeLists.txt b/Userland/Applications/Welcome/CMakeLists.txt new file mode 100644 index 0000000000..e9d1ef3380 --- /dev/null +++ b/Userland/Applications/Welcome/CMakeLists.txt @@ -0,0 +1,9 @@ +set(SOURCES + BackgroundWidget.cpp + main.cpp + TextWidget.cpp + UnuncheckableButton.cpp +) + +serenity_bin(Welcome) +target_link_libraries(Welcome LibGUI) diff --git a/Userland/Applications/Welcome/TextWidget.cpp b/Userland/Applications/Welcome/TextWidget.cpp new file mode 100644 index 0000000000..263d4514f8 --- /dev/null +++ b/Userland/Applications/Welcome/TextWidget.cpp @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "TextWidget.h" +#include <AK/Optional.h> +#include <AK/String.h> +#include <AK/StringBuilder.h> +#include <AK/Vector.h> +#include <LibGUI/Painter.h> +#include <LibGfx/Font.h> +#include <LibGfx/Palette.h> + +TextWidget::TextWidget(const StringView& text) + : m_text(text) +{ + set_frame_thickness(0); + set_frame_shadow(Gfx::FrameShadow::Plain); + set_frame_shape(Gfx::FrameShape::NoFrame); +} + +TextWidget::~TextWidget() +{ +} + +void TextWidget::set_text(const StringView& text) +{ + if (text == m_text) + return; + m_text = text; + wrap_and_set_height(); + update(); +} + +void TextWidget::paint_event(GUI::PaintEvent& event) +{ + GUI::Frame::paint_event(event); + + GUI::Painter painter(*this); + painter.add_clip_rect(event.rect()); + + int indent = 0; + if (frame_thickness() > 0) + indent = font().glyph_width('x') / 2; + + for (size_t i = 0; i < m_lines.size(); i++) { + auto& line = m_lines[i]; + + auto text_rect = frame_inner_rect(); + text_rect.move_by(indent, i * m_line_height); + if (!line.is_empty()) + text_rect.set_width(text_rect.width() - indent * 2); + + if (is_enabled()) { + painter.draw_text(text_rect, line, m_text_alignment, palette().color(foreground_role()), Gfx::TextElision::None); + } else { + painter.draw_text(text_rect.translated(1, 1), line, font(), text_alignment(), Color::White, Gfx::TextElision::Right); + painter.draw_text(text_rect, line, font(), text_alignment(), Color::from_rgb(0x808080), Gfx::TextElision::Right); + } + } +} + +void TextWidget::resize_event(GUI::ResizeEvent& event) +{ + wrap_and_set_height(); + GUI::Widget::resize_event(event); +} + +void TextWidget::wrap_and_set_height() +{ + Vector<String> words; + Optional<size_t> start; + for (size_t i = 0; i < m_text.length(); i++) { + auto ch = m_text[i]; + + if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') { + if (start.has_value()) + words.append(m_text.substring(start.value(), i - start.value())); + start.clear(); + } else if (!start.has_value()) { + start = i; + } + } + if (start.has_value()) + words.append(m_text.substring(start.value(), m_text.length() - start.value())); + + auto rect = frame_inner_rect(); + if (frame_thickness() > 0) + rect.set_width(rect.width() - font().glyph_width('x')); + + StringBuilder builder; + Vector<String> lines; + int line_width = 0; + for (auto& word : words) { + int word_width = font().width(word); + if (line_width != 0) + word_width += font().glyph_width('x'); + + if (line_width + word_width > rect.width()) { + lines.append(builder.to_string()); + builder.clear(); + line_width = 0; + } + + if (line_width != 0) + builder.append(' '); + builder.append(word); + line_width += word_width; + } + auto last_line = builder.to_string(); + if (!last_line.is_empty()) { + lines.append(last_line); + } + + m_lines = lines; + + set_fixed_height(m_lines.size() * m_line_height + frame_thickness() * 2); +} diff --git a/Userland/Applications/Welcome/TextWidget.h b/Userland/Applications/Welcome/TextWidget.h new file mode 100644 index 0000000000..c4353096ff --- /dev/null +++ b/Userland/Applications/Welcome/TextWidget.h @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/String.h> +#include <AK/Vector.h> +#include <LibGUI/Frame.h> +#include <LibGfx/TextAlignment.h> + +class TextWidget : public GUI::Frame { + C_OBJECT(TextWidget); + +public: + virtual ~TextWidget() override; + + String text() const { return m_text; } + void set_text(const StringView&); + + Gfx::TextAlignment text_alignment() const { return m_text_alignment; } + void set_text_alignment(Gfx::TextAlignment text_alignment) { m_text_alignment = text_alignment; } + + bool should_wrap() const { return m_should_wrap; } + void set_should_wrap(bool should_wrap) { m_should_wrap = should_wrap; } + + int line_height() const { return m_line_height; } + void set_line_height(int line_height) { m_line_height = line_height; } + + void wrap_and_set_height(); + +private: + explicit TextWidget(const StringView& text = {}); + + virtual void paint_event(GUI::PaintEvent&) override; + virtual void resize_event(GUI::ResizeEvent&) override; + + String m_text; + Vector<String> m_lines; + Gfx::TextAlignment m_text_alignment { Gfx::TextAlignment::Center }; + bool m_should_wrap { false }; + int m_line_height { 0 }; +}; diff --git a/Userland/Applications/Welcome/UnuncheckableButton.cpp b/Userland/Applications/Welcome/UnuncheckableButton.cpp new file mode 100644 index 0000000000..29194fff81 --- /dev/null +++ b/Userland/Applications/Welcome/UnuncheckableButton.cpp @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "UnuncheckableButton.h" + +UnuncheckableButton::UnuncheckableButton() +{ +} + +UnuncheckableButton::~UnuncheckableButton() +{ +} diff --git a/Userland/Applications/Welcome/UnuncheckableButton.h b/Userland/Applications/Welcome/UnuncheckableButton.h new file mode 100644 index 0000000000..b91a41ef9c --- /dev/null +++ b/Userland/Applications/Welcome/UnuncheckableButton.h @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <LibGUI/Button.h> + +class UnuncheckableButton : public GUI::Button { + C_OBJECT(UnuncheckableButton) +public: + virtual ~UnuncheckableButton() override; + + virtual bool is_uncheckable() const override { return false; } + +private: + UnuncheckableButton(); +}; diff --git a/Userland/Applications/Welcome/main.cpp b/Userland/Applications/Welcome/main.cpp new file mode 100644 index 0000000000..101b35fd97 --- /dev/null +++ b/Userland/Applications/Welcome/main.cpp @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "BackgroundWidget.h" +#include "TextWidget.h" +#include "UnuncheckableButton.h" +#include <AK/ByteBuffer.h> +#include <AK/Optional.h> +#include <AK/String.h> +#include <AK/StringBuilder.h> +#include <AK/Vector.h> +#include <LibCore/File.h> +#include <LibGUI/Application.h> +#include <LibGUI/BoxLayout.h> +#include <LibGUI/Button.h> +#include <LibGUI/ImageWidget.h> +#include <LibGUI/Label.h> +#include <LibGUI/MessageBox.h> +#include <LibGUI/StackWidget.h> +#include <LibGUI/Window.h> +#include <LibGfx/Bitmap.h> +#include <LibGfx/Font.h> +#include <LibGfx/FontDatabase.h> +#include <stdio.h> +#include <unistd.h> + +struct ContentPage { + String menu_name; + String title; + String icon = String::empty(); + Vector<String> content; +}; + +static Optional<Vector<ContentPage>> parse_welcome_file(const String& path) +{ + auto file = Core::File::construct(path); + if (!file->open(Core::IODevice::ReadOnly)) + return {}; + + Vector<ContentPage> pages; + StringBuilder current_output_line; + bool started = false; + ContentPage current; + while (file->can_read_line()) { + auto line = file->read_line(); + if (line.is_empty()) { + if (!current_output_line.to_string().is_empty()) + current.content.append(current_output_line.to_string()); + current_output_line.clear(); + continue; + } + switch (line[0]) { + case '*': + dbgln("menu_item line:\t{}", line); + if (started) + pages.append(current); + else + started = true; + + current = {}; + current.menu_name = line.substring(2, line.length() - 2); + break; + case '$': + dbgln("icon line: \t{}", line); + current.icon = line.substring(2, line.length() - 2); + break; + case '>': + dbgln("title line:\t{}", line); + current.title = line.substring(2, line.length() - 2); + break; + case '#': + dbgln("comment line:\t{}", line); + break; + default: + dbgln("content line:\t", line); + if (current_output_line.length() != 0) + current_output_line.append(' '); + current_output_line.append(line); + break; + } + } + + if (started) { + current.content.append(current_output_line.to_string()); + pages.append(current); + } + + return pages; +} + +int main(int argc, char** argv) +{ + if (pledge("stdio shared_buffer rpath unix cpath fattr", nullptr) < 0) { + perror("pledge"); + return 1; + } + + auto app = GUI::Application::construct(argc, argv); + + if (pledge("stdio shared_buffer rpath", nullptr) < 0) { + perror("pledge"); + return 1; + } + + if (unveil("/res", "r") < 0) { + perror("unveil"); + return 1; + } + + unveil(nullptr, nullptr); + + Optional<Vector<ContentPage>> _pages = parse_welcome_file("/res/welcome.txt"); + if (!_pages.has_value()) { + GUI::MessageBox::show(nullptr, "Could not open Welcome file.", "Welcome", GUI::MessageBox::Type::Error); + return 1; + } + auto pages = _pages.value(); + + auto window = GUI::Window::construct(); + window->set_title("Welcome"); + window->resize(640, 360); + window->center_on_screen(); + + auto& background = window->set_main_widget<BackgroundWidget>(); + background.set_fill_with_background_color(false); + background.set_layout<GUI::VerticalBoxLayout>(); + background.layout()->set_margins({ 16, 8, 16, 8 }); + background.layout()->set_spacing(8); + + // + // header + // + + auto& header = background.add<GUI::Label>(); + header.set_font(Gfx::Font::load_from_file("/res/fonts/PebbletonBold14.font")); + header.set_text("Welcome to SerenityOS!"); + header.set_text_alignment(Gfx::TextAlignment::CenterLeft); + header.set_fixed_height(30); + + // + // main section + // + + auto& main_section = background.add<GUI::Widget>(); + main_section.set_layout<GUI::HorizontalBoxLayout>(); + main_section.layout()->set_margins({ 0, 0, 0, 0 }); + main_section.layout()->set_spacing(8); + + auto& menu = main_section.add<GUI::Widget>(); + menu.set_layout<GUI::VerticalBoxLayout>(); + menu.layout()->set_margins({ 0, 0, 0, 0 }); + menu.layout()->set_spacing(4); + menu.set_fixed_width(100); + + auto& stack = main_section.add<GUI::StackWidget>(); + + bool first = true; + for (auto& page : pages) { + auto& content = stack.add<GUI::Widget>(); + content.set_layout<GUI::VerticalBoxLayout>(); + content.layout()->set_margins({ 0, 0, 0, 0 }); + content.layout()->set_spacing(8); + + auto& title_box = content.add<GUI::Widget>(); + title_box.set_layout<GUI::HorizontalBoxLayout>(); + title_box.layout()->set_spacing(4); + title_box.set_fixed_height(16); + + if (!page.icon.is_empty()) { + auto& icon = title_box.add<GUI::ImageWidget>(); + icon.set_fixed_size(16, 16); + icon.load_from_file(page.icon); + } + + auto& content_title = title_box.add<GUI::Label>(); + content_title.set_font(Gfx::FontDatabase::default_bold_font()); + content_title.set_text(page.title); + content_title.set_text_alignment(Gfx::TextAlignment::CenterLeft); + content_title.set_fixed_height(10); + + for (auto& paragraph : page.content) { + auto& content_text = content.add<TextWidget>(); + content_text.set_font(Gfx::FontDatabase::default_font()); + content_text.set_text(paragraph); + content_text.set_text_alignment(Gfx::TextAlignment::TopLeft); + content_text.set_line_height(12); + content_text.wrap_and_set_height(); + } + + auto& menu_option = menu.add<UnuncheckableButton>(); + menu_option.set_font(Gfx::FontDatabase::default_font()); + menu_option.set_text(page.menu_name); + menu_option.set_text_alignment(Gfx::TextAlignment::CenterLeft); + menu_option.set_fixed_height(20); + menu_option.set_checkable(true); + menu_option.set_exclusive(true); + + if (first) + menu_option.set_checked(true); + + menu_option.on_click = [content = &content, &stack](auto) { + stack.set_active_widget(content); + content->invalidate_layout(); + }; + + first = false; + } + + window->show(); + return app->exec(); +} |