From 0a00a5d61a8b341eaafb5633756b220caefb88c2 Mon Sep 17 00:00:00 2001 From: Johan Dahlin Date: Wed, 8 Apr 2026 13:31:51 +0200 Subject: [PATCH] UI/Gtk: Add browser window, tabs, menus, and dialogs Add the complete browser UI: - BrowserWindow: AdwHeaderBar with navigation, tab management via AdwTabView/AdwTabBar, find-in-page, fullscreen, zoom, D-Bus single-instance with open/activate handlers - Tab: WebContentView lifecycle, ViewImplementation callbacks for title, URL, favicon, cursor, tooltips, dialogs, window management - LadybirdBrowserWindow: GtkBuilder template widget with toolbar, tab bar, find bar, devtools banner, and hamburger menu - LadybirdLocationEntry: URL entry with autocomplete, domain highlighting, and security icon - Menu: GAction-based context menus and application menu with keyboard accelerators - Dialogs: JS alert/confirm/prompt (AdwAlertDialog), color picker, file picker, select dropdown, download save dialog, toast - GtkBuilder .ui resources for browser window, location entry completions, and list popovers Updates Application and main.cpp to create browser windows and handle D-Bus activation from remote instances. --- UI/Gtk/Application.cpp | 278 ++++++++++++- UI/Gtk/Application.h | 28 ++ UI/Gtk/BrowserWindow.cpp | 501 +++++++++++++++++++++++ UI/Gtk/BrowserWindow.h | 86 ++++ UI/Gtk/CMakeLists.txt | 31 ++ UI/Gtk/Dialogs.cpp | 206 ++++++++++ UI/Gtk/Dialogs.h | 31 ++ UI/Gtk/Menu.cpp | 489 ++++++++++++++++++++++ UI/Gtk/Menu.h | 27 ++ UI/Gtk/Resources/browser-window.ui | 252 ++++++++++++ UI/Gtk/Resources/list-popover.ui | 21 + UI/Gtk/Resources/location-entry.ui | 24 ++ UI/Gtk/Resources/resources.gresource.xml | 8 + UI/Gtk/Tab.cpp | 347 ++++++++++++++++ UI/Gtk/Tab.h | 55 +++ UI/Gtk/Widgets/LadybirdBrowserWindow.cpp | 88 ++++ UI/Gtk/Widgets/LadybirdBrowserWindow.h | 29 ++ UI/Gtk/Widgets/LadybirdLocationEntry.cpp | 368 +++++++++++++++++ UI/Gtk/Widgets/LadybirdLocationEntry.h | 21 + UI/Gtk/main.cpp | 8 + 20 files changed, 2886 insertions(+), 12 deletions(-) create mode 100644 UI/Gtk/BrowserWindow.cpp create mode 100644 UI/Gtk/BrowserWindow.h create mode 100644 UI/Gtk/Dialogs.cpp create mode 100644 UI/Gtk/Dialogs.h create mode 100644 UI/Gtk/Menu.cpp create mode 100644 UI/Gtk/Menu.h create mode 100644 UI/Gtk/Resources/browser-window.ui create mode 100644 UI/Gtk/Resources/list-popover.ui create mode 100644 UI/Gtk/Resources/location-entry.ui create mode 100644 UI/Gtk/Resources/resources.gresource.xml create mode 100644 UI/Gtk/Tab.cpp create mode 100644 UI/Gtk/Tab.h create mode 100644 UI/Gtk/Widgets/LadybirdBrowserWindow.cpp create mode 100644 UI/Gtk/Widgets/LadybirdBrowserWindow.h create mode 100644 UI/Gtk/Widgets/LadybirdLocationEntry.cpp create mode 100644 UI/Gtk/Widgets/LadybirdLocationEntry.h diff --git a/UI/Gtk/Application.cpp b/UI/Gtk/Application.cpp index 7a2cdf423e7..f12e5b3da68 100644 --- a/UI/Gtk/Application.cpp +++ b/UI/Gtk/Application.cpp @@ -4,14 +4,21 @@ * SPDX-License-Identifier: BSD-2-Clause */ +#include +#include #include +#include +#include #include +#include +#include namespace Ladybird { Application::Application() = default; Application::~Application() { + m_windows.clear(); g_clear_object(&m_adw_application); } @@ -28,6 +35,11 @@ NonnullOwnPtr Application::create_platform_event_loop() warnln("Failed to register GApplication: {}", error->message); g_error_free(error); } + + if (g_application_get_is_remote(G_APPLICATION(m_adw_application))) + forward_urls_to_remote_and_exit(); + + setup_dbus_handlers(); } auto event_loop = WebView::Application::create_platform_event_loop(); @@ -38,17 +50,259 @@ NonnullOwnPtr Application::create_platform_event_loop() return event_loop; } -Optional Application::active_web_view() const { return {}; } -Optional Application::open_blank_new_tab(Web::HTML::ActivateTab) const { return {}; } -Optional Application::ask_user_for_download_path(StringView) const { return {}; } -void Application::display_download_confirmation_dialog(StringView, LexicalPath const&) const { } -void Application::display_error_dialog(StringView) const { } -Utf16String Application::clipboard_text() const { return {}; } -Vector Application::clipboard_entries() const { return {}; } -void Application::insert_clipboard_entry(Web::Clipboard::SystemClipboardRepresentation) { } -void Application::rebuild_bookmarks_menu() const { } -void Application::update_bookmarks_bar_display(bool) const { } -void Application::on_devtools_enabled() const { } -void Application::on_devtools_disabled() const { } +void Application::forward_urls_to_remote_and_exit() +{ + auto const& raw_urls = browser_options().raw_urls; + if (!raw_urls.is_empty()) { + Vector> files; + for (auto const& url : raw_urls) + files.append(GObjectPtr { g_file_new_for_commandline_arg(url.characters()) }); + Vector raw_files; + for (auto& file : files) + raw_files.append(file.ptr()); + g_application_open(G_APPLICATION(m_adw_application), raw_files.data(), static_cast(raw_files.size()), ""); + } else { + g_application_activate(G_APPLICATION(m_adw_application)); + } + exit(0); +} + +void Application::setup_dbus_handlers() +{ + g_signal_connect(m_adw_application, "open", G_CALLBACK(+[](GApplication*, GFile** files, int n_files, char const*, gpointer) { + auto& app = Application::the(); + Vector urls; + for (int i = 0; i < n_files; i++) { + g_autofree char* uri = g_file_get_uri(files[i]); + if (uri) { + if (auto url = URL::Parser::basic_parse(StringView { uri, strlen(uri) }); url.has_value()) + urls.append(url.release_value()); + } + } + app.on_open(move(urls)); + }), + nullptr); + + g_signal_connect(m_adw_application, "activate", G_CALLBACK(+[](GApplication*, gpointer) { + Application::the().on_activate(); + }), + nullptr); +} + +void Application::on_open(Vector urls) +{ + if (auto* window = active_window()) { + for (size_t i = 0; i < urls.size(); i++) + window->create_new_tab(urls[i], (i == 0) ? Web::HTML::ActivateTab::Yes : Web::HTML::ActivateTab::No); + window->present(); + } else { + new_window(urls); + } +} + +void Application::on_activate() +{ + if (auto* window = active_window()) + window->present(); + else + new_window({}); +} + +BrowserWindow& Application::new_window(Vector const& initial_urls) +{ + auto window = make(m_adw_application, initial_urls); + auto& window_ref = *window; + m_active_window = &window_ref; + + // Track active window via focus + g_signal_connect(window_ref.gtk_window(), "notify::is-active", G_CALLBACK(+[](GObject* gtk_window, GParamSpec*, gpointer) { + if (!gtk_window_is_active(GTK_WINDOW(gtk_window))) + return; + auto& app = Application::the(); + app.for_each_window([&](BrowserWindow& bw) { + if (GTK_WINDOW(bw.gtk_window()) == GTK_WINDOW(gtk_window)) + app.set_active_window(&bw); + }); + }), + nullptr); + + // Clean up when window is destroyed — defer removal to avoid mutating m_windows during iteration + g_signal_connect(window_ref.gtk_window(), "destroy", G_CALLBACK(+[](GtkWidget* gtk_window, gpointer) { + auto& app = Application::the(); + BrowserWindow* to_remove = nullptr; + app.for_each_window([&](BrowserWindow& bw) { + if (GTK_WIDGET(bw.gtk_window()) == gtk_window) + to_remove = &bw; + }); + if (to_remove) { + if (app.active_window() == to_remove) + app.set_active_window(nullptr); + app.remove_window(*to_remove); + bool has_windows = false; + app.for_each_window([&](auto&) { has_windows = true; }); + if (!has_windows) + Core::EventLoop::current().quit(0); + } + }), + nullptr); + + window_ref.present(); + m_windows.append(move(window)); + return window_ref; +} + +void Application::remove_window(BrowserWindow& window) +{ + m_windows.remove_first_matching([&](auto& w) { return w.ptr() == &window; }); + if (m_active_window == &window) + m_active_window = m_windows.is_empty() ? nullptr : m_windows.last().ptr(); +} + +Tab* Application::active_tab() const +{ + if (!m_active_window) + return nullptr; + return m_active_window->current_tab(); +} + +Optional Application::active_web_view() const +{ + if (auto* tab = active_tab()) + return static_cast(tab->view()); + return {}; +} + +Optional Application::open_blank_new_tab(Web::HTML::ActivateTab activate_tab) const +{ + if (!m_active_window) + return {}; + auto& tab = m_active_window->create_new_tab(activate_tab); + return static_cast(tab.view()); +} + +Optional Application::ask_user_for_download_path(StringView file) const +{ + if (!m_active_window) + return {}; + + GObjectPtr dialog { gtk_file_dialog_new() }; + gtk_file_dialog_set_title(GTK_FILE_DIALOG(dialog.ptr()), "Save As"); + + auto const* downloads_dir = g_get_user_special_dir(G_USER_DIRECTORY_DOWNLOAD); + if (downloads_dir) { + GObjectPtr initial_folder { g_file_new_for_path(downloads_dir) }; + gtk_file_dialog_set_initial_folder(GTK_FILE_DIALOG(dialog.ptr()), G_FILE(initial_folder.ptr())); + } + gtk_file_dialog_set_initial_name(GTK_FILE_DIALOG(dialog.ptr()), ByteString(file).characters()); + + Optional result; + Core::EventLoop nested_loop; + + gtk_file_dialog_save(GTK_FILE_DIALOG(dialog.ptr()), m_active_window->gtk_window(), nullptr, +[](GObject* source, GAsyncResult* async_result, gpointer user_data) { + auto* result_ptr = static_cast*>(user_data); + GError* error = nullptr; + GObjectPtr file { gtk_file_dialog_save_finish(GTK_FILE_DIALOG(source), async_result, &error) }; + if (file.ptr()) { + g_autofree char* path = g_file_get_path(G_FILE(file.ptr())); + if (path) + *result_ptr = ByteString(path); + } + if (error) + g_error_free(error); + Core::EventLoop::current().quit(0); }, &result); + + nested_loop.exec(); + return result; +} + +void Application::display_download_confirmation_dialog(StringView download_name, LexicalPath const& path) const +{ + if (!m_active_window) + return; + auto message = ByteString::formatted("{} saved to {}", download_name, path.dirname()); + auto* toast = adw_toast_new(message.characters()); + adw_toast_set_timeout(toast, 5); + m_active_window->show_toast(toast); +} + +void Application::display_error_dialog(StringView error_message) const +{ + if (!m_active_window) + return; + Dialogs::show_error(m_active_window->gtk_window(), error_message); +} + +// GDK4 only provides an async clipboard API. Spin a nested event loop to read synchronously. +static Optional read_clipboard_text_sync() +{ + auto* clipboard = gdk_display_get_clipboard(gdk_display_get_default()); + + Optional result; + Core::EventLoop nested_loop; + + gdk_clipboard_read_text_async(clipboard, nullptr, [](GObject* source, GAsyncResult* async_result, gpointer user_data) { + auto* result_ptr = static_cast*>(user_data); + g_autofree char* text = gdk_clipboard_read_text_finish(GDK_CLIPBOARD(source), async_result, nullptr); + if (text) + *result_ptr = ByteString(text); + Core::EventLoop::current().quit(0); }, &result); + + nested_loop.exec(); + return result; +} + +Utf16String Application::clipboard_text() const +{ + if (browser_options().headless_mode.has_value()) + return WebView::Application::clipboard_text(); + + if (auto text = read_clipboard_text_sync(); text.has_value()) + return Utf16String::from_utf8(text->view()); + return {}; +} + +Vector Application::clipboard_entries() const +{ + if (browser_options().headless_mode.has_value()) + return WebView::Application::clipboard_entries(); + + Vector representations; + if (auto text = read_clipboard_text_sync(); text.has_value()) + representations.empend(text.release_value(), MUST(String::from_utf8("text/plain"sv))); + return representations; +} + +void Application::insert_clipboard_entry(Web::Clipboard::SystemClipboardRepresentation entry) +{ + if (browser_options().headless_mode.has_value()) { + WebView::Application::insert_clipboard_entry(move(entry)); + return; + } + auto* clipboard = gdk_display_get_clipboard(gdk_display_get_default()); + if (entry.mime_type == "text/plain"sv) + gdk_clipboard_set_text(clipboard, entry.data.characters()); +} + +void Application::rebuild_bookmarks_menu() const +{ +} + +void Application::update_bookmarks_bar_display(bool) const +{ +} + +void Application::on_devtools_enabled() const +{ + WebView::Application::on_devtools_enabled(); + for (auto& window : m_windows) + window->on_devtools_enabled(); +} + +void Application::on_devtools_disabled() const +{ + WebView::Application::on_devtools_disabled(); + for (auto& window : m_windows) + window->on_devtools_disabled(); +} } diff --git a/UI/Gtk/Application.h b/UI/Gtk/Application.h index 89326964dcb..0327256baf4 100644 --- a/UI/Gtk/Application.h +++ b/UI/Gtk/Application.h @@ -6,20 +6,41 @@ #pragma once +#include +#include +#include #include #include namespace Ladybird { +class BrowserWindow; +class Tab; + class Application : public WebView::Application { WEB_VIEW_APPLICATION(Application) public: virtual ~Application() override; + BrowserWindow& new_window(Vector const& initial_urls); + void remove_window(BrowserWindow&); + + BrowserWindow* active_window() const { return m_active_window; } + void set_active_window(BrowserWindow* w) { m_active_window = w; } + + Tab* active_tab() const; + AdwApplication* adw_application() const { return m_adw_application; } + template + void for_each_window(Callback callback) + { + for (auto& window : m_windows) + callback(*window); + } + private: explicit Application(); @@ -44,7 +65,14 @@ private: virtual void on_devtools_enabled() const override; virtual void on_devtools_disabled() const override; + void forward_urls_to_remote_and_exit(); + void setup_dbus_handlers(); + void on_open(Vector urls); + void on_activate(); + AdwApplication* m_adw_application { nullptr }; + Vector> m_windows; + BrowserWindow* m_active_window { nullptr }; }; } diff --git a/UI/Gtk/BrowserWindow.cpp b/UI/Gtk/BrowserWindow.cpp new file mode 100644 index 00000000000..5f89ed71223 --- /dev/null +++ b/UI/Gtk/BrowserWindow.cpp @@ -0,0 +1,501 @@ +/* + * Copyright (c) 2026, Johan Dahlin + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Ladybird { + +class NavActionObserver final : public WebView::Action::Observer { +public: + NavActionObserver(GSimpleAction* gaction) + : m_gaction(gaction) + { + } + + void on_enabled_state_changed(WebView::Action& action) override + { + g_simple_action_set_enabled(m_gaction, action.enabled()); + } + +private: + GSimpleAction* m_gaction; +}; + +void BrowserWindow::ActionBinding::detach() +{ + if (action && observer) + action->remove_observer(*observer); + action = nullptr; + observer = nullptr; +} + +BrowserWindow::BrowserWindow(AdwApplication* app, Vector const& initial_urls) +{ + setup_ui(app); + setup_keyboard_shortcuts(); + + if (initial_urls.is_empty()) { + create_new_tab(Web::HTML::ActivateTab::Yes); + } else { + for (size_t i = 0; i < initial_urls.size(); ++i) { + create_new_tab(initial_urls[i], (i == 0) ? Web::HTML::ActivateTab::Yes : Web::HTML::ActivateTab::No); + } + } +} + +BrowserWindow::~BrowserWindow() +{ + m_back_binding.detach(); + m_forward_binding.detach(); + g_signal_handlers_disconnect_by_data(m_window, this); + if (m_location_entry) + g_signal_handlers_disconnect_by_data(m_location_entry, this); + m_tabs.clear(); +} + +void BrowserWindow::register_actions() +{ + struct ActionData { + BrowserWindow* window; + void (*callback)(BrowserWindow&); + }; + auto add_action = [&](char const* name, void (*callback)(BrowserWindow&), bool enabled = true) { + GObjectPtr action { g_simple_action_new(name, nullptr) }; + g_simple_action_set_enabled(G_SIMPLE_ACTION(action.ptr()), enabled); + g_signal_connect_data(action.ptr(), "activate", G_CALLBACK(+[](GSimpleAction*, GVariant*, gpointer user_data) { + auto* data = static_cast(user_data); + data->callback(*data->window); }), new ActionData { this, callback }, +[](gpointer data, GClosure*) { delete static_cast(data); }, static_cast(0)); + g_action_map_add_action(G_ACTION_MAP(m_window), G_ACTION(action.ptr())); + }; + + add_action("new-tab", [](BrowserWindow& self) { self.create_new_tab(Web::HTML::ActivateTab::Yes); }); + add_action("new-window", [](BrowserWindow&) { Application::the().new_window({}); }); + add_action("close-tab", [](BrowserWindow& self) { self.close_current_tab(); }); + add_action("focus-location", [](BrowserWindow& self) { ladybird_location_entry_focus_and_select_all(self.m_location_entry); }); + + add_action("go-back", [](BrowserWindow& self) { + if (auto* tab = self.current_tab()) + tab->view().traverse_the_history_by_delta(-1); }, false); + + add_action("go-forward", [](BrowserWindow& self) { + if (auto* tab = self.current_tab()) + tab->view().traverse_the_history_by_delta(1); }, false); + + add_action("zoom-in", [](BrowserWindow& self) { + if (auto* tab = self.current_tab()) + tab->view().zoom_in(); }); + + add_action("zoom-out", [](BrowserWindow& self) { + if (auto* tab = self.current_tab()) + tab->view().zoom_out(); }); + + add_action("zoom-reset", [](BrowserWindow& self) { + if (auto* tab = self.current_tab()) + tab->view().reset_zoom(); }, false); + + add_action("find", [](BrowserWindow& self) { self.show_find_bar(); }); + add_action("find-close", [](BrowserWindow& self) { self.hide_find_bar(); }); + + add_action("find-next", [](BrowserWindow& self) { + if (auto* tab = self.current_tab()) + tab->view().find_in_page_next_match(); }); + + add_action("find-previous", [](BrowserWindow& self) { + if (auto* tab = self.current_tab()) + tab->view().find_in_page_previous_match(); }); + + add_action("quit", [](BrowserWindow&) { Core::EventLoop::current().quit(0); }); + + add_action("fullscreen", [](BrowserWindow& self) { + if (gtk_window_is_fullscreen(GTK_WINDOW(self.m_window))) + gtk_window_unfullscreen(GTK_WINDOW(self.m_window)); + else + gtk_window_fullscreen(GTK_WINDOW(self.m_window)); }); + + auto& app = WebView::Application::the(); + add_action_to_map(G_ACTION_MAP(m_window), "reload", app.reload_action()); + add_action_to_map(G_ACTION_MAP(m_window), "preferences", app.open_settings_page_action()); + add_action_to_map(G_ACTION_MAP(m_window), "about", app.open_about_page_action()); +} + +void BrowserWindow::setup_ui(AdwApplication* app) +{ + auto* browser_window_widget = LadybirdWidgets::create_browser_window_widget(app); + m_window = ADW_APPLICATION_WINDOW(browser_window_widget); + + m_tab_view = LadybirdWidgets::browser_window_tab_view(browser_window_widget); + m_header_bar = LadybirdWidgets::browser_window_header_bar(browser_window_widget); + m_restore_button = LadybirdWidgets::browser_window_restore_button(browser_window_widget); + m_zoom_label = LadybirdWidgets::browser_window_zoom_label(browser_window_widget); + m_devtools_banner = LadybirdWidgets::browser_window_devtools_banner(browser_window_widget); + m_find_bar_revealer = LadybirdWidgets::browser_window_find_bar_revealer(browser_window_widget); + m_find_entry = LadybirdWidgets::browser_window_find_entry(browser_window_widget); + m_find_result_label = LadybirdWidgets::browser_window_find_result_label(browser_window_widget); + m_toast_overlay = LadybirdWidgets::browser_window_toast_overlay(browser_window_widget); + + // Connect find entry signals + g_signal_connect_swapped(m_find_entry, "search-changed", G_CALLBACK(+[](BrowserWindow* self, GtkSearchEntry* entry) { + auto* text = gtk_editable_get_text(GTK_EDITABLE(entry)); + if (auto* tab = self->current_tab()) { + if (text && text[0] != '\0') + tab->view().find_in_page(MUST(String::from_utf8(StringView(text, strlen(text))))); + } + }), + this); + g_signal_connect_swapped(m_find_entry, "activate", G_CALLBACK(+[](BrowserWindow* self, GtkSearchEntry*) { + if (auto* tab = self->current_tab()) + tab->view().find_in_page_next_match(); + }), + this); + g_signal_connect_swapped(m_find_entry, "next-match", G_CALLBACK(+[](BrowserWindow* self, GtkSearchEntry*) { + if (auto* tab = self->current_tab()) + tab->view().find_in_page_next_match(); + }), + this); + g_signal_connect_swapped(m_find_entry, "previous-match", G_CALLBACK(+[](BrowserWindow* self, GtkSearchEntry*) { + if (auto* tab = self->current_tab()) + tab->view().find_in_page_previous_match(); + }), + this); + g_signal_connect_swapped(m_find_entry, "stop-search", G_CALLBACK(+[](BrowserWindow* self, GtkSearchEntry*) { + self->hide_find_bar(); + }), + this); + + register_actions(); + + g_signal_connect_swapped(m_tab_view, "close-page", G_CALLBACK(+[](BrowserWindow* self, AdwTabPage* page) -> gboolean { + self->on_tab_close_request(page); + return GDK_EVENT_STOP; + }), + this); + + g_signal_connect_swapped(m_tab_view, "notify::selected-page", G_CALLBACK(+[](BrowserWindow* self, GParamSpec*) { + self->on_tab_switched(); + }), + this); + + // URL entry (centered title widget) + m_location_entry = ladybird_location_entry_new(); + ladybird_location_entry_set_on_navigate(m_location_entry, [this](String url_string) { + if (auto url = URL::Parser::basic_parse(url_string); url.has_value()) { + if (auto* tab = current_tab()) + tab->navigate(url.release_value()); + if (auto* v = view()) + gtk_widget_grab_focus(GTK_WIDGET(v->gtk_widget())); + } + }); + + adw_header_bar_set_title_widget(m_header_bar, GTK_WIDGET(m_location_entry)); + + GObjectPtr developer_tools_submenu { g_menu_new() }; + GObjectPtr inspect_gmenu { create_application_menu(WebView::Application::the().inspect_menu(), [](WebView::Action& action) { + return ByteString::formatted("win.inspect-{}", static_cast(action.id())); + }) }; + GObjectPtr debug_gmenu { create_application_menu(WebView::Application::the().debug_menu(), [](WebView::Action& action) { + return ByteString::formatted("win.debug-{}", static_cast(action.id())); + }) }; + g_menu_append_section(G_MENU(developer_tools_submenu.ptr()), nullptr, G_MENU_MODEL(inspect_gmenu.ptr())); + g_menu_append_section(G_MENU(developer_tools_submenu.ptr()), "Debug", G_MENU_MODEL(debug_gmenu.ptr())); + append_submenu_to_section_containing_action(LadybirdWidgets::browser_window_hamburger_menu(browser_window_widget), "win.new-window", "Developer Tools", G_MENU_MODEL(developer_tools_submenu.ptr())); + + // Listen for fullscreen state changes + g_signal_connect_swapped(m_window, "notify::fullscreened", G_CALLBACK(+[](BrowserWindow* self, GParamSpec*) { + gboolean fullscreen = gtk_window_is_fullscreen(GTK_WINDOW(self->m_window)); + adw_header_bar_set_show_start_title_buttons(self->m_header_bar, !fullscreen); + adw_header_bar_set_show_end_title_buttons(self->m_header_bar, !fullscreen); + gtk_widget_set_visible(GTK_WIDGET(self->m_restore_button), fullscreen); + }), + this); + + add_menu_actions_to_map(G_ACTION_MAP(m_window), WebView::Application::the().inspect_menu(), [](WebView::Action& action) { + return ByteString::formatted("inspect-{}", static_cast(action.id())); + }); + add_menu_actions_to_map(G_ACTION_MAP(m_window), WebView::Application::the().debug_menu(), [](WebView::Action& action) { + return ByteString::formatted("debug-{}", static_cast(action.id())); + }); + + auto* application = gtk_window_get_application(GTK_WINDOW(m_window)); + install_action_accelerators(application, "win.reload", WebView::Application::the().reload_action()); + install_action_accelerators(application, "win.preferences", WebView::Application::the().open_settings_page_action()); + install_action_accelerators(application, "win.about", WebView::Application::the().open_about_page_action()); + install_menu_action_accelerators(application, "win.inspect", WebView::Application::the().inspect_menu()); + install_menu_action_accelerators(application, "win.debug", WebView::Application::the().debug_menu()); + + if (WebView::Application::browser_options().devtools_port.has_value()) + on_devtools_enabled(); +} + +void BrowserWindow::setup_keyboard_shortcuts() +{ + auto* app = gtk_window_get_application(GTK_WINDOW(m_window)); + + auto set_accels = [&](char const* action, std::initializer_list accels) { + Vector list; + list.ensure_capacity(accels.size() + 1); + for (auto* a : accels) + list.append(a); + list.append(nullptr); + gtk_application_set_accels_for_action(app, action, list.data()); + }; + + set_accels("win.new-tab", { "t" }); + set_accels("win.close-tab", { "w" }); + set_accels("win.focus-location", { "l" }); + set_accels("win.find", { "f" }); + set_accels("win.find-close", { "Escape" }); + set_accels("win.go-back", { "Left" }); + set_accels("win.go-forward", { "Right" }); + set_accels("win.zoom-in", { "equal", "plus" }); + set_accels("win.zoom-out", { "minus" }); + set_accels("win.zoom-reset", { "0" }); + set_accels("win.fullscreen", { "F11" }); + set_accels("win.quit", { "q" }); + set_accels("win.new-window", { "n" }); +} + +void BrowserWindow::on_tab_switched() +{ + auto* tab = current_tab(); + if (!tab) + return; + + auto const& url = tab->view().url(); + if (is_internal_url(url)) { + ladybird_location_entry_set_text(m_location_entry, ""); + } else { + auto url_str = url.serialize().to_byte_string(); + ladybird_location_entry_set_url(m_location_entry, url_str.characters()); + } + + bind_navigation_actions(tab->view()); + update_zoom_label(); +} + +Tab& BrowserWindow::create_new_tab(Web::HTML::ActivateTab activate_tab) +{ + auto& new_tab_url = WebView::Application::settings().new_tab_page_url(); + auto& tab = create_new_tab(new_tab_url, activate_tab); + return tab; +} + +Tab& BrowserWindow::create_new_tab(URL::URL const& url, Web::HTML::ActivateTab activate_tab) +{ + auto tab = make(*this, url); + auto& tab_ref = *tab; + + auto* page = adw_tab_view_append(m_tab_view, tab_ref.widget()); + adw_tab_page_set_title(page, "New Tab"); + tab_ref.set_tab_page(page); + + if (activate_tab == Web::HTML::ActivateTab::Yes) { + adw_tab_view_set_selected_page(m_tab_view, page); + bind_navigation_actions(tab_ref.view()); + + if (is_internal_url(url)) { + ladybird_location_entry_set_text(m_location_entry, ""); + ladybird_location_entry_focus_and_select_all(m_location_entry); + } + } + + m_tabs.append(move(tab)); + return tab_ref; +} + +Tab& BrowserWindow::create_child_tab(Web::HTML::ActivateTab activate_tab, Tab& parent, u64 page_index) +{ + auto tab = make(*this, parent.view().client(), page_index); + auto& tab_ref = *tab; + + auto* page = adw_tab_view_append(m_tab_view, tab_ref.widget()); + adw_tab_page_set_title(page, "New Tab"); + tab_ref.set_tab_page(page); + + if (activate_tab == Web::HTML::ActivateTab::Yes) + adw_tab_view_set_selected_page(m_tab_view, page); + + m_tabs.append(move(tab)); + return tab_ref; +} + +void BrowserWindow::close_tab(Tab& tab) +{ + auto* page = tab.tab_page(); + if (page) + adw_tab_view_close_page(m_tab_view, page); +} + +void BrowserWindow::close_current_tab() +{ + if (auto* tab = current_tab()) + close_tab(*tab); +} + +void BrowserWindow::on_tab_close_request(AdwTabPage* page) +{ + auto* child = adw_tab_page_get_child(page); + for (auto& tab : m_tabs) { + if (tab->widget() == child) { + adw_tab_view_close_page_finish(m_tab_view, page, TRUE); + m_tabs.remove_first_matching([&](auto& t) { return t.ptr() == tab.ptr(); }); + if (m_tabs.is_empty()) + gtk_window_close(GTK_WINDOW(m_window)); + return; + } + } + adw_tab_view_close_page_finish(m_tab_view, page, TRUE); +} + +Tab* BrowserWindow::current_tab() const +{ + auto* page = adw_tab_view_get_selected_page(m_tab_view); + if (!page) + return nullptr; + + auto* child = adw_tab_page_get_child(page); + for (auto& tab : m_tabs) { + if (tab->widget() == child) + return tab.ptr(); + } + return nullptr; +} + +WebContentView* BrowserWindow::view() const +{ + auto* tab = current_tab(); + if (!tab) + return nullptr; + return &tab->view(); +} + +void BrowserWindow::present() +{ + gtk_window_present(GTK_WINDOW(m_window)); +} + +int BrowserWindow::tab_count() const +{ + return adw_tab_view_get_n_pages(m_tab_view); +} + +void BrowserWindow::update_navigation_buttons(bool back_enabled, bool forward_enabled) +{ + auto* back_action = G_SIMPLE_ACTION(g_action_map_lookup_action(G_ACTION_MAP(m_window), "go-back")); + if (back_action) + g_simple_action_set_enabled(back_action, back_enabled); + + auto* forward_action = G_SIMPLE_ACTION(g_action_map_lookup_action(G_ACTION_MAP(m_window), "go-forward")); + if (forward_action) + g_simple_action_set_enabled(forward_action, forward_enabled); +} + +void BrowserWindow::bind_navigation_actions(WebContentView& view) +{ + m_back_binding.detach(); + m_forward_binding.detach(); + + auto bind = [&](ActionBinding& binding, WebView::Action& action, char const* name) { + auto* gaction = G_SIMPLE_ACTION(g_action_map_lookup_action(G_ACTION_MAP(m_window), name)); + g_simple_action_set_enabled(gaction, action.enabled()); + auto observer = make(gaction); + binding = { &action, observer.ptr() }; + action.add_observer(move(observer)); + }; + + bind(m_back_binding, view.navigate_back_action(), "go-back"); + bind(m_forward_binding, view.navigate_forward_action(), "go-forward"); +} + +void BrowserWindow::update_location_entry(StringView url) +{ + if (url.is_empty()) { + ladybird_location_entry_set_text(m_location_entry, ""); + return; + } + auto byte_url = ByteString(url); + ladybird_location_entry_set_url(m_location_entry, byte_url.characters()); +} + +void BrowserWindow::show_find_bar() +{ + gtk_revealer_set_reveal_child(m_find_bar_revealer, TRUE); + gtk_widget_grab_focus(GTK_WIDGET(m_find_entry)); +} + +void BrowserWindow::hide_find_bar() +{ + gtk_revealer_set_reveal_child(m_find_bar_revealer, FALSE); + if (auto* v = view()) + gtk_widget_grab_focus(GTK_WIDGET(v->gtk_widget())); +} + +void BrowserWindow::update_find_in_page_result(size_t current_match_index, Optional const& total_match_count) +{ + if (total_match_count.has_value()) { + auto text = ByteString::formatted("{} of {} matches", current_match_index + 1, total_match_count.value()); + gtk_label_set_text(m_find_result_label, text.characters()); + } else { + gtk_label_set_text(m_find_result_label, "No matches"); + } +} + +void BrowserWindow::on_devtools_enabled() +{ + auto port = WebView::Application::browser_options().devtools_port; + auto message = ByteString::formatted("DevTools is enabled on port {}", port.value_or(0)); + adw_banner_set_title(m_devtools_banner, message.characters()); + adw_banner_set_revealed(m_devtools_banner, TRUE); + + g_signal_connect_swapped(m_devtools_banner, "button-clicked", G_CALLBACK(+[](BrowserWindow* self, AdwBanner*) { + (void)WebView::Application::the().toggle_devtools_enabled(); + self->on_devtools_disabled(); + }), + this); +} + +void BrowserWindow::on_devtools_disabled() +{ + adw_banner_set_revealed(m_devtools_banner, FALSE); +} + +bool BrowserWindow::is_internal_url(URL::URL const& url) +{ + return url.scheme().is_empty() || url == URL::about_blank() || url == URL::about_newtab(); +} + +void BrowserWindow::update_zoom_label() +{ + if (!m_zoom_label) + return; + auto* tab = current_tab(); + if (!tab) + return; + auto zoom = tab->view().zoom_level(); + auto text = ByteString::formatted("{}%", static_cast(zoom * 100)); + gtk_label_set_text(m_zoom_label, text.characters()); + + auto* action = g_action_map_lookup_action(G_ACTION_MAP(m_window), "zoom-reset"); + if (action) + g_simple_action_set_enabled(G_SIMPLE_ACTION(action), zoom != 1.0); +} + +void BrowserWindow::show_toast(AdwToast* toast) +{ + if (m_toast_overlay) + adw_toast_overlay_add_toast(m_toast_overlay, toast); +} + +} diff --git a/UI/Gtk/BrowserWindow.h b/UI/Gtk/BrowserWindow.h new file mode 100644 index 00000000000..a73806536d5 --- /dev/null +++ b/UI/Gtk/BrowserWindow.h @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026, Johan Dahlin + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace Ladybird { + +class Tab; +class WebContentView; + +class BrowserWindow { +public: + BrowserWindow(AdwApplication* app, Vector const& initial_urls); + ~BrowserWindow(); + + GtkWindow* gtk_window() const { return GTK_WINDOW(m_window); } + + Tab& create_new_tab(Web::HTML::ActivateTab activate_tab); + Tab& create_new_tab(URL::URL const& url, Web::HTML::ActivateTab activate_tab); + Tab& create_child_tab(Web::HTML::ActivateTab activate_tab, Tab& parent, u64 page_index); + void close_tab(Tab& tab); + void close_current_tab(); + Tab* current_tab() const; + WebContentView* view() const; + void present(); + int tab_count() const; + + void update_navigation_buttons(bool back_enabled, bool forward_enabled); + void update_location_entry(StringView url); + void update_zoom_label(); + void update_find_in_page_result(size_t current_match_index, Optional const& total_match_count); + + void show_find_bar(); + void hide_find_bar(); + + void on_devtools_enabled(); + void on_devtools_disabled(); + + void show_toast(AdwToast* toast); + + static bool is_internal_url(URL::URL const& url); + +private: + void setup_ui(AdwApplication* app); + void register_actions(); + void setup_keyboard_shortcuts(); + void on_tab_close_request(AdwTabPage* page); + void on_tab_switched(); + void bind_navigation_actions(WebContentView& view); + + AdwApplicationWindow* m_window { nullptr }; + LadybirdLocationEntry* m_location_entry { nullptr }; + Vector> m_tabs; + + AdwTabView* m_tab_view { nullptr }; + AdwHeaderBar* m_header_bar { nullptr }; + GtkButton* m_restore_button { nullptr }; + GtkLabel* m_zoom_label { nullptr }; + AdwBanner* m_devtools_banner { nullptr }; + GtkRevealer* m_find_bar_revealer { nullptr }; + GtkSearchEntry* m_find_entry { nullptr }; + GtkLabel* m_find_result_label { nullptr }; + AdwToastOverlay* m_toast_overlay { nullptr }; + + struct ActionBinding { + WebView::Action* action { nullptr }; + WebView::Action::Observer* observer { nullptr }; + void detach(); + }; + ActionBinding m_back_binding; + ActionBinding m_forward_binding; +}; + +} diff --git a/UI/Gtk/CMakeLists.txt b/UI/Gtk/CMakeLists.txt index 8d01752f398..8f153bc10e5 100644 --- a/UI/Gtk/CMakeLists.txt +++ b/UI/Gtk/CMakeLists.txt @@ -4,12 +4,43 @@ pkg_check_modules(LIBADWAITA REQUIRED IMPORTED_TARGET libadwaita-1>=1.4) add_executable(ladybird main.cpp) +find_program(GLIB_COMPILE_RESOURCES NAMES glib-compile-resources REQUIRED) + +set(GTK_UI_RESOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/Resources/browser-window.ui" + "${CMAKE_CURRENT_SOURCE_DIR}/Resources/list-popover.ui" + "${CMAKE_CURRENT_SOURCE_DIR}/Resources/location-entry.ui" +) + +set(GTK_GRESOURCE_XML "${CMAKE_CURRENT_SOURCE_DIR}/Resources/resources.gresource.xml") +set(GTK_GRESOURCE_SOURCE "${CMAKE_CURRENT_BINARY_DIR}/gtk-ui-resources.cpp") + +add_custom_command( + OUTPUT "${GTK_GRESOURCE_SOURCE}" + DEPENDS "${GTK_GRESOURCE_XML}" ${GTK_UI_RESOURCES} + COMMAND "${GLIB_COMPILE_RESOURCES}" + "${GTK_GRESOURCE_XML}" + --sourcedir "${CMAKE_CURRENT_SOURCE_DIR}/Resources" + --target "${GTK_GRESOURCE_SOURCE}" + --generate-source + VERBATIM +) +add_custom_target(gtk_ui_resources DEPENDS "${GTK_GRESOURCE_SOURCE}") + target_sources(ladybird PRIVATE Application.cpp + BrowserWindow.cpp + Dialogs.cpp Events.cpp EventLoopImplementationGtk.cpp + Menu.cpp + Tab.cpp WebContentView.cpp + Widgets/LadybirdBrowserWindow.cpp + Widgets/LadybirdLocationEntry.cpp Widgets/LadybirdWebView.cpp + "${GTK_GRESOURCE_SOURCE}" ) target_link_libraries(ladybird PRIVATE PkgConfig::GTK4 PkgConfig::LIBADWAITA) +add_dependencies(ladybird gtk_ui_resources) create_ladybird_bundle(ladybird) diff --git a/UI/Gtk/Dialogs.cpp b/UI/Gtk/Dialogs.cpp new file mode 100644 index 00000000000..e102adae78e --- /dev/null +++ b/UI/Gtk/Dialogs.cpp @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2026, Johan Dahlin + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include + +#include + +namespace Ladybird::Dialogs { + +void show_error(GtkWindow* parent, StringView message) +{ + auto* dialog = adw_alert_dialog_new("Error", nullptr); + adw_alert_dialog_format_body(ADW_ALERT_DIALOG(dialog), "%.*s", + static_cast(message.length()), message.characters_without_null_termination()); + adw_alert_dialog_add_response(ADW_ALERT_DIALOG(dialog), "ok", "OK"); + adw_dialog_present(ADW_DIALOG(dialog), GTK_WIDGET(parent)); +} + +void show_alert(GtkWindow* parent, WebContentView* view, String const& message) +{ + auto* dialog = adw_alert_dialog_new("Alert", nullptr); + auto msg = message.to_byte_string(); + adw_alert_dialog_set_body(ADW_ALERT_DIALOG(dialog), msg.characters()); + adw_alert_dialog_add_response(ADW_ALERT_DIALOG(dialog), "ok", "OK"); + g_signal_connect_swapped(dialog, "response", G_CALLBACK(+[](WebContentView* view, char const*) { + view->alert_closed(); + }), + view); + adw_dialog_present(ADW_DIALOG(dialog), GTK_WIDGET(parent)); +} + +void show_confirm(GtkWindow* parent, WebContentView* view, String const& message) +{ + auto* dialog = adw_alert_dialog_new("Confirm", nullptr); + auto msg = message.to_byte_string(); + adw_alert_dialog_set_body(ADW_ALERT_DIALOG(dialog), msg.characters()); + adw_alert_dialog_add_response(ADW_ALERT_DIALOG(dialog), "cancel", "Cancel"); + adw_alert_dialog_add_response(ADW_ALERT_DIALOG(dialog), "ok", "OK"); + adw_alert_dialog_set_response_appearance(ADW_ALERT_DIALOG(dialog), "ok", ADW_RESPONSE_SUGGESTED); + adw_alert_dialog_set_default_response(ADW_ALERT_DIALOG(dialog), "ok"); + g_signal_connect_swapped(dialog, "response", G_CALLBACK(+[](WebContentView* view, char const* response) { + view->confirm_closed(StringView(response, strlen(response)) == "ok"sv); + }), + view); + adw_dialog_present(ADW_DIALOG(dialog), GTK_WIDGET(parent)); +} + +void show_prompt(GtkWindow* parent, WebContentView* view, String const& message, String const& default_value) +{ + auto* dialog = adw_alert_dialog_new("Prompt", nullptr); + auto msg = message.to_byte_string(); + adw_alert_dialog_set_body(ADW_ALERT_DIALOG(dialog), msg.characters()); + adw_alert_dialog_add_response(ADW_ALERT_DIALOG(dialog), "cancel", "Cancel"); + adw_alert_dialog_add_response(ADW_ALERT_DIALOG(dialog), "ok", "OK"); + adw_alert_dialog_set_response_appearance(ADW_ALERT_DIALOG(dialog), "ok", ADW_RESPONSE_SUGGESTED); + adw_alert_dialog_set_default_response(ADW_ALERT_DIALOG(dialog), "ok"); + + auto* entry = gtk_entry_new(); + auto def = default_value.to_byte_string(); + gtk_editable_set_text(GTK_EDITABLE(entry), def.characters()); + adw_alert_dialog_set_extra_child(ADW_ALERT_DIALOG(dialog), entry); + + struct PromptData { + WebContentView* view; + GtkEntry* entry; + }; + auto* data = new PromptData { view, GTK_ENTRY(entry) }; + + g_signal_connect(dialog, "response", G_CALLBACK(+[](AdwAlertDialog*, char const* response, gpointer user_data) { + auto* data = static_cast(user_data); + if (StringView(response, strlen(response)) == "ok"sv) { + auto* text = gtk_editable_get_text(GTK_EDITABLE(data->entry)); + data->view->prompt_closed(MUST(String::from_utf8(StringView(text, strlen(text))))); + } else { + data->view->prompt_closed({}); + } + delete data; + }), + data); + adw_dialog_present(ADW_DIALOG(dialog), GTK_WIDGET(parent)); +} + +void show_color_picker(GtkWindow* parent, WebContentView* view, Color current_color) +{ + GObjectPtr dialog { gtk_color_dialog_new() }; + auto rgba = GdkRGBA { + static_cast(current_color.red()) / 255.0f, + static_cast(current_color.green()) / 255.0f, + static_cast(current_color.blue()) / 255.0f, + static_cast(current_color.alpha()) / 255.0f + }; + + gtk_color_dialog_choose_rgba(GTK_COLOR_DIALOG(dialog.ptr()), parent, &rgba, nullptr, +[](GObject* source, GAsyncResult* result, gpointer user_data) { + auto* view = static_cast(user_data); + GError* error = nullptr; + auto* color = gtk_color_dialog_choose_rgba_finish(GTK_COLOR_DIALOG(source), result, &error); + if (error) { + view->color_picker_update({}, Web::HTML::ColorPickerUpdateState::Closed); + g_error_free(error); + return; + } + auto picked = Color( + static_cast(color->red * 255), + static_cast(color->green * 255), + static_cast(color->blue * 255), + static_cast(color->alpha * 255)); + view->color_picker_update(picked, Web::HTML::ColorPickerUpdateState::Closed); + gdk_rgba_free(color); }, view); +} + +void show_file_picker(GtkWindow* parent, WebContentView* view, Web::HTML::FileFilter const& accepted_file_types, Web::HTML::AllowMultipleFiles allow_multiple) +{ + GObjectPtr dialog { gtk_file_dialog_new() }; + if (allow_multiple == Web::HTML::AllowMultipleFiles::Yes) + gtk_file_dialog_set_title(GTK_FILE_DIALOG(dialog.ptr()), "Select Files"); + else + gtk_file_dialog_set_title(GTK_FILE_DIALOG(dialog.ptr()), "Select File"); + + // Build file filters from accepted types + GObjectPtr filters { g_list_store_new(GTK_TYPE_FILE_FILTER) }; + if (!accepted_file_types.filters.is_empty()) { + GObjectPtr filter { gtk_file_filter_new() }; + gtk_file_filter_set_name(GTK_FILE_FILTER(filter.ptr()), "Accepted files"); + for (auto const& filter_type : accepted_file_types.filters) { + filter_type.visit( + [&](Web::HTML::FileFilter::Extension const& ext) { + auto pattern = ByteString::formatted("*.{}", ext.value); + gtk_file_filter_add_pattern(GTK_FILE_FILTER(filter.ptr()), pattern.characters()); + }, + [&](Web::HTML::FileFilter::MimeType const& mime) { + auto mime_str = mime.value.to_byte_string(); + gtk_file_filter_add_mime_type(GTK_FILE_FILTER(filter.ptr()), mime_str.characters()); + }, + [&](Web::HTML::FileFilter::FileType const& file_type) { + switch (file_type) { + case Web::HTML::FileFilter::FileType::Audio: + gtk_file_filter_add_mime_type(GTK_FILE_FILTER(filter.ptr()), "audio/*"); + break; + case Web::HTML::FileFilter::FileType::Image: + gtk_file_filter_add_mime_type(GTK_FILE_FILTER(filter.ptr()), "image/*"); + break; + case Web::HTML::FileFilter::FileType::Video: + gtk_file_filter_add_mime_type(GTK_FILE_FILTER(filter.ptr()), "video/*"); + break; + } + }); + } + g_list_store_append(G_LIST_STORE(filters.ptr()), filter.ptr()); + } + GObjectPtr all_filter { gtk_file_filter_new() }; + gtk_file_filter_set_name(GTK_FILE_FILTER(all_filter.ptr()), "All files"); + gtk_file_filter_add_pattern(GTK_FILE_FILTER(all_filter.ptr()), "*"); + g_list_store_append(G_LIST_STORE(filters.ptr()), all_filter.ptr()); + gtk_file_dialog_set_filters(GTK_FILE_DIALOG(dialog.ptr()), G_LIST_MODEL(filters.ptr())); + + if (allow_multiple == Web::HTML::AllowMultipleFiles::Yes) { + gtk_file_dialog_open_multiple(GTK_FILE_DIALOG(dialog.ptr()), parent, nullptr, +[](GObject* source, GAsyncResult* result, gpointer user_data) { + auto* view = static_cast(user_data); + GError* error = nullptr; + auto* file_list = gtk_file_dialog_open_multiple_finish(GTK_FILE_DIALOG(source), result, &error); + if (error) { + view->file_picker_closed({}); + g_error_free(error); + return; + } + GObjectPtr owned_file_list { file_list }; + Vector selected; + auto n = g_list_model_get_n_items(G_LIST_MODEL(file_list)); + for (guint i = 0; i < n; i++) { + GObjectPtr file { g_list_model_get_item(G_LIST_MODEL(file_list), i) }; + g_autofree char* path = g_file_get_path(G_FILE(file.ptr())); + if (path) { + auto selected_file = Web::HTML::SelectedFile::from_file_path(ByteString(path)); + if (!selected_file.is_error()) + selected.append(selected_file.release_value()); + } + } + view->file_picker_closed(move(selected)); }, view); + } else { + gtk_file_dialog_open(GTK_FILE_DIALOG(dialog.ptr()), parent, nullptr, +[](GObject* source, GAsyncResult* result, gpointer user_data) { + auto* view = static_cast(user_data); + GError* error = nullptr; + auto* file = gtk_file_dialog_open_finish(GTK_FILE_DIALOG(source), result, &error); + if (error) { + view->file_picker_closed({}); + g_error_free(error); + return; + } + GObjectPtr owned_file { file }; + Vector selected; + g_autofree char* path = g_file_get_path(file); + if (path) { + auto selected_file = Web::HTML::SelectedFile::from_file_path(ByteString(path)); + if (!selected_file.is_error()) + selected.append(selected_file.release_value()); + } + view->file_picker_closed(move(selected)); }, view); + } +} + +} diff --git a/UI/Gtk/Dialogs.h b/UI/Gtk/Dialogs.h new file mode 100644 index 00000000000..20ef75ab29c --- /dev/null +++ b/UI/Gtk/Dialogs.h @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Johan Dahlin + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include + +#include + +namespace Ladybird { + +class WebContentView; + +namespace Dialogs { + +void show_error(GtkWindow* parent, StringView message); +void show_alert(GtkWindow* parent, WebContentView* view, String const& message); +void show_confirm(GtkWindow* parent, WebContentView* view, String const& message); +void show_prompt(GtkWindow* parent, WebContentView* view, String const& message, String const& default_value); +void show_color_picker(GtkWindow* parent, WebContentView* view, Color current_color); +void show_file_picker(GtkWindow* parent, WebContentView* view, Web::HTML::FileFilter const& accepted_file_types, Web::HTML::AllowMultipleFiles allow_multiple); + +} + +} diff --git a/UI/Gtk/Menu.cpp b/UI/Gtk/Menu.cpp new file mode 100644 index 00000000000..722d7bc0664 --- /dev/null +++ b/UI/Gtk/Menu.cpp @@ -0,0 +1,489 @@ +/* + * Copyright (c) 2026, Johan Dahlin + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include + +namespace Ladybird { + +class ActionObserver final : public WebView::Action::Observer { +public: + explicit ActionObserver(GSimpleAction* gaction) + : m_gaction(GObjectPtr { G_SIMPLE_ACTION(g_object_ref(gaction)) }) + { + } + + void on_enabled_state_changed(WebView::Action& action) override + { + if (m_gaction.ptr()) + g_simple_action_set_enabled(m_gaction, action.enabled()); + } + + void on_checked_state_changed(WebView::Action& action) override + { + if (m_gaction.ptr() && g_action_get_state_type(G_ACTION(m_gaction.ptr()))) + g_simple_action_set_state(m_gaction, g_variant_new_boolean(action.checked())); + } + +private: + GObjectPtr m_gaction; +}; + +static void set_menu_item_icon_name(GMenuItem* item, char const* icon_name) +{ + if (!icon_name) + return; + + GObjectPtr icon { g_themed_icon_new(icon_name) }; + g_menu_item_set_icon(item, G_ICON(icon.ptr())); +} + +static void set_menu_item_accel(GMenuItem* item, char const* accel) +{ + if (!accel) + return; + g_menu_item_set_attribute(item, "accel", "s", accel); +} + +static char const* primary_accelerator_for_action(WebView::ActionID id) +{ + switch (id) { + case WebView::ActionID::NavigateBack: + return "Left"; + case WebView::ActionID::NavigateForward: + return "Right"; + case WebView::ActionID::Reload: + return "r"; + case WebView::ActionID::CopySelection: + return "c"; + case WebView::ActionID::Paste: + return "v"; + case WebView::ActionID::SelectAll: + return "a"; + case WebView::ActionID::ToggleBookmark: + return "d"; + case WebView::ActionID::ToggleBookmarksBar: + return "b"; + case WebView::ActionID::OpenProcessesPage: + return "m"; + case WebView::ActionID::OpenSettingsPage: + return "comma"; + case WebView::ActionID::ToggleDevTools: + return "i"; + case WebView::ActionID::ViewSource: + return "u"; + case WebView::ActionID::ZoomIn: + return "equal"; + case WebView::ActionID::ZoomOut: + return "minus"; + case WebView::ActionID::ResetZoom: + case WebView::ActionID::ResetZoomViaToolbar: + return "0"; + case WebView::ActionID::CollectGarbage: + return "g"; + default: + return nullptr; + } +} + +static void initialize_native_control(WebView::Action& action, GSimpleAction* gaction, GMenuItem* menu_item) +{ + if (gaction) + g_simple_action_set_enabled(gaction, action.enabled()); + + auto set_icon = [&](char const* icon_name) { + if (menu_item) + set_menu_item_icon_name(menu_item, icon_name); + }; + auto set_accel = [&](char const* accel) { + if (menu_item) + set_menu_item_accel(menu_item, accel); + }; + + switch (action.id()) { + case WebView::ActionID::NavigateBack: + set_icon("go-previous-symbolic"); + set_accel("Left"); + break; + case WebView::ActionID::NavigateForward: + set_icon("go-next-symbolic"); + set_accel("Right"); + break; + case WebView::ActionID::Reload: + set_icon("view-refresh-symbolic"); + set_accel("r"); + break; + + case WebView::ActionID::CopySelection: + set_icon("edit-copy-symbolic"); + set_accel("c"); + break; + case WebView::ActionID::Paste: + set_icon("edit-paste-symbolic"); + set_accel("v"); + break; + case WebView::ActionID::SelectAll: + set_icon("edit-select-all-symbolic"); + set_accel("a"); + break; + + case WebView::ActionID::SearchSelectedText: + set_icon("edit-find-symbolic"); + break; + + case WebView::ActionID::TakeVisibleScreenshot: + case WebView::ActionID::TakeFullScreenshot: + set_icon("image-x-generic-symbolic"); + break; + + case WebView::ActionID::ToggleBookmark: + case WebView::ActionID::ToggleBookmarkViaToolbar: + set_icon(action.engaged() ? "starred-symbolic" : "non-starred-symbolic"); + set_accel("d"); + break; + case WebView::ActionID::ToggleBookmarksBar: + set_icon("user-bookmarks-symbolic"); + set_accel("b"); + break; + case WebView::ActionID::BookmarkItem: + set_icon("globe-symbolic"); + break; + + case WebView::ActionID::OpenAboutPage: + set_icon("help-about-symbolic"); + break; + case WebView::ActionID::OpenProcessesPage: + set_icon("utilities-system-monitor-symbolic"); + set_accel("m"); + break; + case WebView::ActionID::OpenSettingsPage: + set_icon("emblem-system-symbolic"); + set_accel("comma"); + break; + case WebView::ActionID::ToggleDevTools: + case WebView::ActionID::DumpDOMTree: + set_icon("applications-engineering-symbolic"); + set_accel("i"); + break; + case WebView::ActionID::ViewSource: + set_icon("text-html-symbolic"); + set_accel("u"); + break; + + case WebView::ActionID::OpenInNewTab: + set_icon("tab-new-symbolic"); + break; + case WebView::ActionID::CopyURL: + set_icon("edit-copy-symbolic"); + break; + + case WebView::ActionID::OpenImage: + set_icon("image-x-generic-symbolic"); + break; + case WebView::ActionID::SaveImage: + set_icon("download-symbolic"); + break; + case WebView::ActionID::CopyImage: + set_icon("edit-copy-symbolic"); + break; + + case WebView::ActionID::OpenAudio: + set_icon("audio-x-generic-symbolic"); + break; + case WebView::ActionID::OpenVideo: + set_icon("video-x-generic-symbolic"); + break; + case WebView::ActionID::PlayMedia: + set_icon("media-playback-start-symbolic"); + break; + case WebView::ActionID::PauseMedia: + set_icon("media-playback-pause-symbolic"); + break; + case WebView::ActionID::MuteMedia: + set_icon("audio-volume-muted-symbolic"); + break; + case WebView::ActionID::UnmuteMedia: + set_icon("audio-volume-high-symbolic"); + break; + case WebView::ActionID::ShowControls: + set_icon("view-visible-symbolic"); + break; + case WebView::ActionID::HideControls: + set_icon("view-hidden-symbolic"); + break; + case WebView::ActionID::ToggleMediaLoopState: + set_icon("view-refresh-symbolic"); + break; + case WebView::ActionID::EnterFullscreen: + case WebView::ActionID::ExitFullscreen: + set_icon("view-fullscreen-symbolic"); + break; + + case WebView::ActionID::ZoomIn: + set_icon("zoom-in-symbolic"); + set_accel("equal"); + break; + case WebView::ActionID::ZoomOut: + set_icon("zoom-out-symbolic"); + set_accel("minus"); + break; + case WebView::ActionID::ResetZoom: + case WebView::ActionID::ResetZoomViaToolbar: + set_icon("zoom-original-symbolic"); + set_accel("0"); + break; + + case WebView::ActionID::DumpSessionHistoryTree: + set_icon("document-open-recent-symbolic"); + break; + case WebView::ActionID::DumpLayoutTree: + case WebView::ActionID::DumpPaintTree: + case WebView::ActionID::DumpDisplayList: + set_icon("view-list-symbolic"); + break; + case WebView::ActionID::DumpStackingContextTree: + set_icon("view-grid-symbolic"); + break; + case WebView::ActionID::DumpStyleSheets: + case WebView::ActionID::DumpStyles: + set_icon("text-x-css-symbolic"); + break; + case WebView::ActionID::DumpCSSErrors: + set_icon("dialog-error-symbolic"); + break; + case WebView::ActionID::DumpCookies: + set_icon("preferences-web-browser-cookies-symbolic"); + break; + case WebView::ActionID::DumpLocalStorage: + set_icon("drive-harddisk-symbolic"); + break; + case WebView::ActionID::ShowLineBoxBorders: + set_icon("view-grid-symbolic"); + break; + case WebView::ActionID::CollectGarbage: + set_icon("user-trash-symbolic"); + set_accel("g"); + break; + + default: + break; + } +} + +static void add_items_to_menu(GMenu& menu, ReadonlySpan menu_items, Function const& detailed_action_name_for_action) +{ + GObjectPtr current_section { g_menu_new() }; + bool section_has_items = false; + + auto flush_section = [&] { + if (!section_has_items) + return; + g_menu_append_section(&menu, nullptr, G_MENU_MODEL(current_section.ptr())); + current_section = GObjectPtr { g_menu_new() }; + section_has_items = false; + }; + + for (auto& menu_item : menu_items) { + menu_item.visit( + [&](NonnullRefPtr const& action) { + if (!action->visible()) + return; + + auto label = action->text().to_byte_string(); + auto detailed_action_name = detailed_action_name_for_action(*action); + GObjectPtr gitem { g_menu_item_new(label.characters(), detailed_action_name.characters()) }; + initialize_native_control(*action, nullptr, G_MENU_ITEM(gitem.ptr())); + g_menu_append_item(G_MENU(current_section.ptr()), G_MENU_ITEM(gitem.ptr())); + section_has_items = true; + }, + [&](NonnullRefPtr const& submenu) { + GObjectPtr submenu_model { create_application_menu(*submenu, detailed_action_name_for_action) }; + auto title = submenu->title().to_byte_string(); + GObjectPtr gitem { g_menu_item_new_submenu(title.characters(), G_MENU_MODEL(submenu_model.ptr())) }; + + if (submenu->render_group_icon()) + set_menu_item_icon_name(G_MENU_ITEM(gitem.ptr()), "folder-symbolic"); + + g_menu_append_item(G_MENU(current_section.ptr()), G_MENU_ITEM(gitem.ptr())); + section_has_items = true; + }, + [&](WebView::Separator) { + flush_section(); + }); + } + + flush_section(); +} + +class ContextMenu final { +public: + ContextMenu(GtkWidget& parent, WebView::Menu& source) + : m_popover(GTK_POPOVER(gtk_popover_menu_new_from_model(nullptr))) + { + gtk_widget_set_parent(GTK_WIDGET(m_popover), &parent); + m_action_group = GObjectPtr { g_simple_action_group_new() }; + gtk_widget_insert_action_group(GTK_WIDGET(m_popover), "context", G_ACTION_GROUP(m_action_group.ptr())); + + size_t action_index = 0; + source.for_each_action([&](WebView::Action& action) { + auto action_name = ByteString::formatted("item-{}", action_index++); + m_action_names.set(&action, action_name); + add_action_to_map(G_ACTION_MAP(m_action_group.ptr()), action_name.characters(), action); + }); + } + + ~ContextMenu() + { + gtk_widget_unparent(GTK_WIDGET(m_popover)); + } + + void popup(WebContentView& view, WebView::Menu& source, Gfx::IntPoint position) + { + GObjectPtr menu_model { create_application_menu(source, [&](WebView::Action& action) { + return ByteString::formatted("context.{}", m_action_names.get(&action).value()); + }) }; + gtk_popover_menu_set_menu_model(GTK_POPOVER_MENU(m_popover), G_MENU_MODEL(menu_model.ptr())); + + auto device_pixel_ratio = view.device_pixel_ratio(); + GdkRectangle rect = { + static_cast(position.x() / device_pixel_ratio), + static_cast(position.y() / device_pixel_ratio), + 1, 1 + }; + gtk_popover_set_pointing_to(m_popover, &rect); + gtk_popover_popup(m_popover); + } + +private: + GtkPopover* m_popover { nullptr }; + GObjectPtr m_action_group; + HashMap m_action_names; +}; + +static GSimpleAction* create_application_action(char const* action_name, WebView::Action& action, bool observe_state) +{ + GSimpleAction* gaction = action.is_checkable() + ? g_simple_action_new_stateful(action_name, nullptr, g_variant_new_boolean(action.checked())) + : g_simple_action_new(action_name, nullptr); + + auto* weak_action = new WeakPtr(action.make_weak_ptr()); + g_signal_connect_data(gaction, "activate", G_CALLBACK(+[](GSimpleAction*, GVariant*, gpointer user_data) { + auto* weak = static_cast*>(user_data); + if (auto action = weak->strong_ref()) { + if (action->is_checkable()) + action->set_checked(!action->checked()); + action->activate(); + } }), weak_action, +[](gpointer data, GClosure*) { delete static_cast*>(data); }, static_cast(0)); + + initialize_native_control(action, gaction, nullptr); + if (observe_state) + action.add_observer(make(gaction)); + return gaction; +} + +void add_action_to_map(GActionMap* action_map, char const* action_name, WebView::Action& action, bool observe_state) +{ + GObjectPtr gaction { create_application_action(action_name, action, observe_state) }; + g_action_map_add_action(action_map, G_ACTION(gaction.ptr())); +} + +GMenu* create_application_menu(WebView::Menu& menu, Function const& detailed_action_name_for_action) +{ + auto* gmenu = g_menu_new(); + add_items_to_menu(*gmenu, menu.items(), detailed_action_name_for_action); + return gmenu; +} + +void create_context_menu(GtkWidget& parent, WebContentView& view, WebView::Menu& menu) +{ + auto context_menu = make(parent, menu); + menu.on_activation = [context_menu = move(context_menu), &view, weak_menu = menu.make_weak_ptr()](Gfx::IntPoint position) { + if (auto strong_menu = weak_menu.strong_ref()) + context_menu->popup(view, *strong_menu, position); + }; +} + +void add_menu_actions_to_map(GActionMap* action_map, WebView::Menu& menu, Function const& action_name_for_action) +{ + menu.for_each_action([&](WebView::Action& action) { + auto action_name = action_name_for_action(action); + add_action_to_map(action_map, action_name.characters(), action); + }); +} + +void install_action_accelerators(GtkApplication* application, char const* detailed_action_name, WebView::Action const& action) +{ + switch (action.id()) { + case WebView::ActionID::Reload: { + static constexpr char const* accels[] = { "r", "F5", nullptr }; + gtk_application_set_accels_for_action(application, detailed_action_name, accels); + break; + } + case WebView::ActionID::ToggleDevTools: { + static constexpr char const* accels[] = { "i", "c", "F12", nullptr }; + gtk_application_set_accels_for_action(application, detailed_action_name, accels); + break; + } + case WebView::ActionID::ZoomIn: { + static constexpr char const* accels[] = { "equal", "plus", nullptr }; + gtk_application_set_accels_for_action(application, detailed_action_name, accels); + break; + } + case WebView::ActionID::NavigateBack: + case WebView::ActionID::NavigateForward: + case WebView::ActionID::ToggleBookmark: + case WebView::ActionID::ToggleBookmarksBar: + case WebView::ActionID::OpenProcessesPage: + case WebView::ActionID::OpenSettingsPage: + case WebView::ActionID::ViewSource: + case WebView::ActionID::ZoomOut: + case WebView::ActionID::ResetZoom: + case WebView::ActionID::ResetZoomViaToolbar: + case WebView::ActionID::CollectGarbage: { + auto const* accel = primary_accelerator_for_action(action.id()); + if (!accel) + return; + char const* accels[] = { accel, nullptr }; + gtk_application_set_accels_for_action(application, detailed_action_name, accels); + break; + } + default: + break; + } +} + +void install_menu_action_accelerators(GtkApplication* application, char const* prefix, WebView::Menu& menu) +{ + menu.for_each_action([&](WebView::Action& action) { + auto detailed_action_name = ByteString::formatted("{}-{}", prefix, static_cast(action.id())); + install_action_accelerators(application, detailed_action_name.characters(), action); + }); +} + +void append_submenu_to_section_containing_action(GMenu* menu, char const* detailed_action_name, char const* submenu_label, GMenuModel* submenu_model) +{ + int n_items = g_menu_model_get_n_items(G_MENU_MODEL(menu)); + for (int i = 0; i < n_items; ++i) { + GObjectPtr section { g_menu_model_get_item_link(G_MENU_MODEL(menu), i, G_MENU_LINK_SECTION) }; + if (!section.ptr()) + continue; + + int section_items = g_menu_model_get_n_items(G_MENU_MODEL(section.ptr())); + for (int j = 0; j < section_items; ++j) { + g_autofree char* action = nullptr; + g_menu_model_get_item_attribute(G_MENU_MODEL(section.ptr()), j, G_MENU_ATTRIBUTE_ACTION, "s", &action); + if (action && g_strcmp0(action, detailed_action_name) == 0) { + g_menu_append_submenu(G_MENU(section.ptr()), submenu_label, submenu_model); + return; + } + } + } + + g_menu_append_submenu(menu, submenu_label, submenu_model); +} + +} diff --git a/UI/Gtk/Menu.h b/UI/Gtk/Menu.h new file mode 100644 index 00000000000..fcb30e8c93e --- /dev/null +++ b/UI/Gtk/Menu.h @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Johan Dahlin + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include + +#include +#include + +namespace Ladybird { + +class WebContentView; + +void add_action_to_map(GActionMap* action_map, char const* action_name, WebView::Action& action, bool observe_state = true); +GMenu* create_application_menu(WebView::Menu& menu, Function const& detailed_action_name_for_action); +void create_context_menu(GtkWidget& parent, WebContentView& view, WebView::Menu& menu); +void add_menu_actions_to_map(GActionMap* action_map, WebView::Menu& menu, Function const& action_name_for_action); +void install_action_accelerators(GtkApplication* application, char const* detailed_action_name, WebView::Action const& action); +void install_menu_action_accelerators(GtkApplication* application, char const* prefix, WebView::Menu& menu); +void append_submenu_to_section_containing_action(GMenu* menu, char const* detailed_action_name, char const* submenu_label, GMenuModel* submenu_model); + +} diff --git a/UI/Gtk/Resources/browser-window.ui b/UI/Gtk/Resources/browser-window.ui new file mode 100644 index 00000000000..f2e6b342334 --- /dev/null +++ b/UI/Gtk/Resources/browser-window.ui @@ -0,0 +1,252 @@ + + + + + + + + + +
+ + zoom + +
+
+ + tools + +
+
+ + New Tab + win.new-tab + + + New Window + win.new-window + +
+
+ + Preferences + win.preferences + + + About Ladybird + win.about + + + Quit + win.quit + +
+
+ + + + hamburger_menu + + + horizontal + true + true + + + + win.zoom-out + zoom-out-symbolic + Zoom Out + + + + + win.zoom-reset + Reset Zoom + + + 100% + 5 + + + + + + + + win.zoom-in + zoom-in-symbolic + Zoom In + + + + + + + horizontal + true + true + + + + win.find + system-search-symbolic + Find in Page + + + + + win.fullscreen + view-fullscreen-symbolic + Fullscreen + + + + + +
diff --git a/UI/Gtk/Resources/list-popover.ui b/UI/Gtk/Resources/list-popover.ui new file mode 100644 index 00000000000..1d6d41d1c73 --- /dev/null +++ b/UI/Gtk/Resources/list-popover.ui @@ -0,0 +1,21 @@ + + + + + + false + + + never + automatic + 300 + true + + + + + + + + + diff --git a/UI/Gtk/Resources/location-entry.ui b/UI/Gtk/Resources/location-entry.ui new file mode 100644 index 00000000000..da2fc16820b --- /dev/null +++ b/UI/Gtk/Resources/location-entry.ui @@ -0,0 +1,24 @@ + + + + + + false + false + bottom + start + + + never + automatic + 300 + true + + + browse + + + + + + diff --git a/UI/Gtk/Resources/resources.gresource.xml b/UI/Gtk/Resources/resources.gresource.xml new file mode 100644 index 00000000000..1ce39a6966f --- /dev/null +++ b/UI/Gtk/Resources/resources.gresource.xml @@ -0,0 +1,8 @@ + + + + browser-window.ui + list-popover.ui + location-entry.ui + + diff --git a/UI/Gtk/Tab.cpp b/UI/Gtk/Tab.cpp new file mode 100644 index 00000000000..b7ab32bff5b --- /dev/null +++ b/UI/Gtk/Tab.cpp @@ -0,0 +1,347 @@ +/* + * Copyright (c) 2026, Johan Dahlin + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Ladybird { + +struct ListPopoverShell { + GtkPopover* popover { nullptr }; + GtkListBox* list_box { nullptr }; +}; + +static ListPopoverShell create_list_popover_shell(GtkWidget* parent) +{ + GObjectPtr builder { gtk_builder_new_from_resource("/org/ladybird/Ladybird/gtk/list-popover.ui") }; + auto* popover = LadybirdWidgets::get_builder_object(builder, "popover"); + auto* list_box = LadybirdWidgets::get_builder_object(builder, "list_box"); + gtk_widget_set_parent(GTK_WIDGET(popover), parent); + return { popover, list_box }; +} + +static void append_select_option_row(GtkListBox* list_box, char const* label, bool selected, bool disabled, unsigned id, int margin_start) +{ + auto* row = gtk_list_box_row_new(); + auto* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_widget_set_margin_start(box, margin_start); + gtk_widget_set_margin_end(box, 8); + gtk_widget_set_margin_top(box, 4); + gtk_widget_set_margin_bottom(box, 4); + + if (selected) { + auto* check = gtk_image_new_from_icon_name("object-select-symbolic"); + gtk_box_append(GTK_BOX(box), check); + } else { + auto* spacer = gtk_image_new_from_icon_name("object-select-symbolic"); + gtk_widget_set_opacity(spacer, 0); + gtk_box_append(GTK_BOX(box), spacer); + } + + auto* label_widget = gtk_label_new(label); + gtk_label_set_xalign(GTK_LABEL(label_widget), 0.0); + gtk_box_append(GTK_BOX(box), label_widget); + + gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box); + gtk_widget_set_sensitive(row, !disabled); + g_object_set_data(G_OBJECT(row), "item-id", GUINT_TO_POINTER(id)); + gtk_list_box_append(GTK_LIST_BOX(list_box), row); +} + +Tab::Tab(BrowserWindow& window, URL::URL url) + : Tab(window, nullptr, 0) +{ + m_initial_url = move(url); + if (!m_initial_url.scheme().is_empty()) + navigate(m_initial_url); +} + +Tab::Tab(BrowserWindow& window, WebView::WebContentClient& parent_client, u64 page_index) + : Tab(window, &parent_client, page_index) +{ +} + +Tab::Tab(BrowserWindow& window, RefPtr parent_client, size_t page_index) + : m_window(window) +{ + m_web_view = ladybird_web_view_new(); + gtk_widget_set_vexpand(GTK_WIDGET(m_web_view), TRUE); + gtk_widget_set_hexpand(GTK_WIDGET(m_web_view), TRUE); + m_view = adopt_own(*new WebContentView(m_web_view, parent_client, page_index)); + + setup_callbacks(); +} + +Tab::~Tab() = default; + +void Tab::setup_callbacks() +{ + auto* root = GTK_WIDGET(m_web_view); + + m_view->on_title_change = [this](auto const& title) { + if (m_tab_page) { + auto utf8 = title.to_utf8(); + auto byte_str = ByteString(utf8.bytes_as_string_view()); + adw_tab_page_set_title(m_tab_page, byte_str.characters()); + } + }; + + m_view->on_url_change = [this](auto const& url) { + if (m_window.current_tab() != this) + return; + if (BrowserWindow::is_internal_url(url)) { + m_window.update_location_entry(""sv); + return; + } + auto url_string = url.serialize(); + m_window.update_location_entry(url_string.bytes_as_string_view()); + }; + + m_view->on_load_start = [this](auto const&, bool) { + if (m_tab_page) + adw_tab_page_set_loading(m_tab_page, TRUE); + }; + + m_view->on_load_finish = [this](auto const&) { + if (m_tab_page) + adw_tab_page_set_loading(m_tab_page, FALSE); + }; + + m_view->on_cursor_change = [root](auto const& cursor) { + auto cursor_name = cursor.visit( + [](Gfx::StandardCursor standard_cursor) -> StringView { + return standard_cursor_to_css_name(standard_cursor); + }, + [](Gfx::ImageCursor const&) -> StringView { + return "default"sv; + }); + auto cursor_name_str = ByteString(cursor_name); + GObjectPtr gdk_cursor { gdk_cursor_new_from_name(cursor_name_str.characters(), nullptr) }; + gtk_widget_set_cursor(root, GDK_CURSOR(gdk_cursor.ptr())); + }; + + m_view->on_enter_tooltip_area = [root](auto const& tooltip) { + auto text = ByteString(tooltip); + gtk_widget_set_tooltip_text(root, text.characters()); + }; + + m_view->on_leave_tooltip_area = [root]() { + gtk_widget_set_tooltip_text(root, nullptr); + }; + + m_view->on_link_hover = [root](auto const& url) { + auto url_string = url.serialize(); + auto byte_string = ByteString(url_string.bytes_as_string_view()); + gtk_widget_set_tooltip_text(root, byte_string.characters()); + }; + + m_view->on_link_unhover = [root]() { + gtk_widget_set_tooltip_text(root, nullptr); + }; + + m_view->on_new_web_view = [this](auto activate_tab, auto, auto page_index) -> String { + if (page_index.has_value()) { + auto& new_tab = m_window.create_child_tab(activate_tab, *this, page_index.value()); + return new_tab.view().handle(); + } + auto& new_tab = m_window.create_new_tab(activate_tab); + return new_tab.view().handle(); + }; + + m_view->on_activate_tab = [root]() { + gtk_widget_grab_focus(root); + }; + + m_view->on_close = [this]() { + m_window.close_tab(*this); + }; + + m_view->on_zoom_level_changed = [this]() { + m_window.update_zoom_label(); + }; + + // Dialogs + m_view->on_request_alert = [this](auto const& message) { + Dialogs::show_alert(m_window.gtk_window(), m_view.ptr(), message); + }; + + m_view->on_request_confirm = [this](auto const& message) { + Dialogs::show_confirm(m_window.gtk_window(), m_view.ptr(), message); + }; + + m_view->on_request_prompt = [this](auto const& message, auto const& default_value) { + Dialogs::show_prompt(m_window.gtk_window(), m_view.ptr(), message, default_value); + }; + + m_view->on_request_color_picker = [this](Color current_color) { + Dialogs::show_color_picker(m_window.gtk_window(), m_view.ptr(), current_color); + }; + + m_view->on_request_file_picker = [this](auto const& accepted_file_types, auto allow_multiple) { + Dialogs::show_file_picker(m_window.gtk_window(), m_view.ptr(), accepted_file_types, allow_multiple); + }; + + m_view->on_request_select_dropdown = [this](Gfx::IntPoint content_position, i32 minimum_width, Vector items) { + show_select_dropdown(content_position, minimum_width, move(items)); + }; + + m_view->on_find_in_page = [this](auto current_match_index, auto const& total_match_count) { + m_window.update_find_in_page_result(current_match_index, total_match_count); + }; + + m_view->on_fullscreen_window = [this]() { + gtk_window_fullscreen(m_window.gtk_window()); + }; + + m_view->on_exit_fullscreen_window = [this]() { + gtk_window_unfullscreen(m_window.gtk_window()); + }; + + m_view->on_restore_window = [this]() { + gtk_window_unmaximize(m_window.gtk_window()); + gtk_window_unfullscreen(m_window.gtk_window()); + }; + + m_view->on_maximize_window = [this]() { + gtk_window_maximize(m_window.gtk_window()); + }; + + m_view->on_minimize_window = [this]() { + gtk_window_minimize(m_window.gtk_window()); + }; + + m_view->on_reposition_window = [](auto) { + // GTK4 removed window repositioning APIs. On Wayland, clients cannot + // position their own top-level windows. On other backends, GTK4 chose + // not to expose it for portability. + }; + + m_view->on_resize_window = [this](auto size) { + gtk_window_set_default_size(m_window.gtk_window(), size.width(), size.height()); + }; + + m_view->on_favicon_change = [this](auto const& bitmap) { + if (!m_tab_page) + return; + g_autoptr(GBytes) bytes = g_bytes_new(bitmap.scanline_u8(0), bitmap.size_in_bytes()); + GObjectPtr texture { gdk_memory_texture_new(bitmap.width(), bitmap.height(), GDK_MEMORY_B8G8R8A8_PREMULTIPLIED, bytes, bitmap.pitch()) }; + adw_tab_page_set_icon(m_tab_page, G_ICON(texture.ptr())); + }; + + m_view->on_audio_play_state_changed = [this](auto play_state) { + if (m_tab_page) { + adw_tab_page_set_indicator_icon(m_tab_page, + play_state == Web::HTML::AudioPlayState::Playing + ? g_themed_icon_new("audio-volume-high-symbolic") + : nullptr); + } + }; + + // FIXME: Support non-modal JS dialogs (on_request_set_prompt_text, + // on_request_accept_dialog, on_request_dismiss_dialog) for WebDriver support. + + // Context menus + create_context_menu(*root, *m_view, m_view->page_context_menu()); + create_context_menu(*root, *m_view, m_view->link_context_menu()); + create_context_menu(*root, *m_view, m_view->image_context_menu()); + create_context_menu(*root, *m_view, m_view->media_context_menu()); +} + +void Tab::navigate(URL::URL const& url) +{ + m_view->load(url); +} + +void Tab::show_select_dropdown(Gfx::IntPoint content_position, i32 minimum_width, Vector items) +{ + auto* root = GTK_WIDGET(m_web_view); + auto shell = create_list_popover_shell(root); + auto* popover = shell.popover; + auto* list_box = shell.list_box; + gtk_widget_set_size_request(GTK_WIDGET(popover), minimum_width, -1); + + auto device_pixel_ratio = m_view->device_pixel_ratio(); + GdkRectangle rect = { + static_cast(content_position.x() / device_pixel_ratio), + static_cast(content_position.y() / device_pixel_ratio), + 1, 1 + }; + gtk_popover_set_pointing_to(GTK_POPOVER(popover), &rect); + gtk_list_box_set_selection_mode(GTK_LIST_BOX(list_box), GTK_SELECTION_NONE); + + for (auto const& item : items) { + item.visit( + [&](Web::HTML::SelectItemOption const& option) { + append_select_option_row(list_box, option.label.to_byte_string().characters(), option.selected, option.disabled, option.id, 8); + }, + [&](Web::HTML::SelectItemOptionGroup const& group) { + auto* header = gtk_label_new(group.label.to_byte_string().characters()); + gtk_label_set_xalign(GTK_LABEL(header), 0.0); + gtk_widget_add_css_class(header, "heading"); + gtk_widget_set_margin_start(header, 8); + gtk_widget_set_margin_top(header, 6); + gtk_widget_set_margin_bottom(header, 2); + auto* header_row = gtk_list_box_row_new(); + gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(header_row), header); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(header_row), FALSE); + gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(header_row), FALSE); + gtk_list_box_append(GTK_LIST_BOX(list_box), header_row); + + for (auto const& option : group.items) { + append_select_option_row(list_box, option.label.to_byte_string().characters(), option.selected, option.disabled, option.id, 16); + } + }, + [&](Web::HTML::SelectItemSeparator const&) { + auto* sep_row = gtk_list_box_row_new(); + gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(sep_row), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(sep_row), FALSE); + gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(sep_row), FALSE); + gtk_list_box_append(GTK_LIST_BOX(list_box), sep_row); + }, + [&](auto const&) {}); + } + + struct DropdownState { + WebContentView* view; + GtkPopover* popover; + bool selected { false }; + }; + auto* dropdown_state = new DropdownState { m_view.ptr(), GTK_POPOVER(popover) }; + + g_signal_connect(list_box, "row-activated", G_CALLBACK(+[](GtkListBox*, GtkListBoxRow* row, gpointer user_data) { + auto* state = static_cast(user_data); + auto item_id = GPOINTER_TO_UINT(g_object_get_data(G_OBJECT(row), "item-id")); + state->selected = true; + state->view->select_dropdown_closed(item_id); + gtk_popover_popdown(state->popover); + }), + dropdown_state); + + g_signal_connect(popover, "closed", G_CALLBACK(+[](GtkPopover* popover, gpointer user_data) { + auto* state = static_cast(user_data); + if (!state->selected) + state->view->select_dropdown_closed({}); + delete state; + gtk_widget_unparent(GTK_WIDGET(popover)); + }), + dropdown_state); + + gtk_popover_popup(GTK_POPOVER(popover)); +} + +} diff --git a/UI/Gtk/Tab.h b/UI/Gtk/Tab.h new file mode 100644 index 00000000000..978606f4643 --- /dev/null +++ b/UI/Gtk/Tab.h @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026, Johan Dahlin + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace Ladybird { + +class BrowserWindow; +class WebContentView; + +class Tab { +public: + Tab(BrowserWindow& window, URL::URL url = {}); + Tab(BrowserWindow& window, WebView::WebContentClient& parent_client, u64 page_index); + ~Tab(); + + GtkWidget* widget() const { return GTK_WIDGET(m_web_view); } + WebContentView& view() { return *m_view; } + WebContentView const& view() const { return *m_view; } + + void navigate(URL::URL const& url); + + AdwTabPage* tab_page() const { return m_tab_page; } + void set_tab_page(AdwTabPage* page) { m_tab_page = page; } + +private: + Tab(BrowserWindow& window, RefPtr parent_client, size_t page_index); + void setup_callbacks(); + void show_select_dropdown(Gfx::IntPoint content_position, i32 minimum_width, Vector items); + + BrowserWindow& m_window; + OwnPtr m_view; + + LadybirdWebView* m_web_view { nullptr }; + AdwTabPage* m_tab_page { nullptr }; + + URL::URL m_initial_url; +}; + +} diff --git a/UI/Gtk/Widgets/LadybirdBrowserWindow.cpp b/UI/Gtk/Widgets/LadybirdBrowserWindow.cpp new file mode 100644 index 00000000000..e0270a6a4e2 --- /dev/null +++ b/UI/Gtk/Widgets/LadybirdBrowserWindow.cpp @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026, Johan Dahlin + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include + +struct LadybirdBrowserWindow { + AdwApplicationWindow parent_instance; + AdwToolbarView* toolbar_view { nullptr }; + AdwHeaderBar* header_bar { nullptr }; + GtkButton* back_button { nullptr }; + GtkButton* forward_button { nullptr }; + GtkButton* reload_button { nullptr }; + GtkButton* restore_button { nullptr }; + GtkMenuButton* menu_button { nullptr }; + AdwTabView* tab_view { nullptr }; + AdwTabBar* tab_bar { nullptr }; + AdwToastOverlay* toast_overlay { nullptr }; + GtkRevealer* find_bar_revealer { nullptr }; + GtkSearchEntry* find_entry { nullptr }; + GtkLabel* find_result_label { nullptr }; + GtkButton* zoom_reset_button { nullptr }; + GtkLabel* zoom_label { nullptr }; + AdwBanner* devtools_banner { nullptr }; + GMenu* hamburger_menu { nullptr }; +}; + +struct LadybirdBrowserWindowClass { + AdwApplicationWindowClass parent_class; +}; + +GType ladybird_browser_window_get_type(void); +G_DEFINE_FINAL_TYPE(LadybirdBrowserWindow, ladybird_browser_window, ADW_TYPE_APPLICATION_WINDOW) + +static void ladybird_browser_window_class_init(LadybirdBrowserWindowClass* klass) +{ + auto* widget_class = GTK_WIDGET_CLASS(klass); + gtk_widget_class_set_template_from_resource(widget_class, "/org/ladybird/Ladybird/gtk/browser-window.ui"); + + gtk_widget_class_bind_template_child(widget_class, LadybirdBrowserWindow, toolbar_view); + gtk_widget_class_bind_template_child(widget_class, LadybirdBrowserWindow, header_bar); + gtk_widget_class_bind_template_child(widget_class, LadybirdBrowserWindow, back_button); + gtk_widget_class_bind_template_child(widget_class, LadybirdBrowserWindow, forward_button); + gtk_widget_class_bind_template_child(widget_class, LadybirdBrowserWindow, reload_button); + gtk_widget_class_bind_template_child(widget_class, LadybirdBrowserWindow, restore_button); + gtk_widget_class_bind_template_child(widget_class, LadybirdBrowserWindow, menu_button); + gtk_widget_class_bind_template_child(widget_class, LadybirdBrowserWindow, tab_view); + gtk_widget_class_bind_template_child(widget_class, LadybirdBrowserWindow, tab_bar); + gtk_widget_class_bind_template_child(widget_class, LadybirdBrowserWindow, toast_overlay); + gtk_widget_class_bind_template_child(widget_class, LadybirdBrowserWindow, find_bar_revealer); + gtk_widget_class_bind_template_child(widget_class, LadybirdBrowserWindow, find_entry); + gtk_widget_class_bind_template_child(widget_class, LadybirdBrowserWindow, find_result_label); + gtk_widget_class_bind_template_child(widget_class, LadybirdBrowserWindow, zoom_reset_button); + gtk_widget_class_bind_template_child(widget_class, LadybirdBrowserWindow, zoom_label); + gtk_widget_class_bind_template_child(widget_class, LadybirdBrowserWindow, devtools_banner); + gtk_widget_class_bind_template_child(widget_class, LadybirdBrowserWindow, hamburger_menu); +} + +static void ladybird_browser_window_init(LadybirdBrowserWindow* self) +{ + gtk_widget_init_template(GTK_WIDGET(self)); +} + +namespace LadybirdWidgets { + +LadybirdBrowserWindow* create_browser_window_widget(AdwApplication* app) +{ + return reinterpret_cast(g_object_new(ladybird_browser_window_get_type(), + "application", app, + nullptr)); +} + +AdwHeaderBar* browser_window_header_bar(LadybirdBrowserWindow* window) { return window->header_bar; } +AdwTabView* browser_window_tab_view(LadybirdBrowserWindow* window) { return window->tab_view; } +GtkButton* browser_window_restore_button(LadybirdBrowserWindow* window) { return window->restore_button; } +GtkLabel* browser_window_zoom_label(LadybirdBrowserWindow* window) { return window->zoom_label; } +AdwBanner* browser_window_devtools_banner(LadybirdBrowserWindow* window) { return window->devtools_banner; } +GtkRevealer* browser_window_find_bar_revealer(LadybirdBrowserWindow* window) { return window->find_bar_revealer; } +GtkSearchEntry* browser_window_find_entry(LadybirdBrowserWindow* window) { return window->find_entry; } +GtkLabel* browser_window_find_result_label(LadybirdBrowserWindow* window) { return window->find_result_label; } +GMenu* browser_window_hamburger_menu(LadybirdBrowserWindow* window) { return window->hamburger_menu; } +AdwToastOverlay* browser_window_toast_overlay(LadybirdBrowserWindow* window) { return window->toast_overlay; } +GtkMenuButton* browser_window_menu_button(LadybirdBrowserWindow* window) { return window->menu_button; } + +} diff --git a/UI/Gtk/Widgets/LadybirdBrowserWindow.h b/UI/Gtk/Widgets/LadybirdBrowserWindow.h new file mode 100644 index 00000000000..a112f4bf319 --- /dev/null +++ b/UI/Gtk/Widgets/LadybirdBrowserWindow.h @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Johan Dahlin + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include + +struct LadybirdBrowserWindow; + +namespace LadybirdWidgets { + +LadybirdBrowserWindow* create_browser_window_widget(AdwApplication* app); + +AdwHeaderBar* browser_window_header_bar(LadybirdBrowserWindow*); +AdwTabView* browser_window_tab_view(LadybirdBrowserWindow*); +GtkButton* browser_window_restore_button(LadybirdBrowserWindow*); +GtkLabel* browser_window_zoom_label(LadybirdBrowserWindow*); +AdwBanner* browser_window_devtools_banner(LadybirdBrowserWindow*); +GtkRevealer* browser_window_find_bar_revealer(LadybirdBrowserWindow*); +GtkSearchEntry* browser_window_find_entry(LadybirdBrowserWindow*); +GtkLabel* browser_window_find_result_label(LadybirdBrowserWindow*); +GMenu* browser_window_hamburger_menu(LadybirdBrowserWindow*); +AdwToastOverlay* browser_window_toast_overlay(LadybirdBrowserWindow*); +GtkMenuButton* browser_window_menu_button(LadybirdBrowserWindow*); + +} diff --git a/UI/Gtk/Widgets/LadybirdLocationEntry.cpp b/UI/Gtk/Widgets/LadybirdLocationEntry.cpp new file mode 100644 index 00000000000..82e35735a45 --- /dev/null +++ b/UI/Gtk/Widgets/LadybirdLocationEntry.cpp @@ -0,0 +1,368 @@ +/* + * Copyright (c) 2026, Johan Dahlin + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct LocationEntryState { + NonnullOwnPtr autocomplete; + Vector suggestions; + int selected_index { -1 }; + String user_text; + bool is_focused { false }; + bool updating_text { false }; + Function on_navigate; +}; + +#define LADYBIRD_LOCATION_ENTRY(obj) (reinterpret_cast(obj)) +#define LADYBIRD_TYPE_LOCATION_ENTRY (ladybird_location_entry_get_type()) + +struct LadybirdLocationEntry { + GtkEntry parent_instance; + + GtkPopover* popover { nullptr }; + GtkListBox* list_box { nullptr }; + // GObject allocates this struct with g_malloc0, which zero-fills without + // calling C++ constructors. OwnPtr is safe here because zero-initialized + // OwnPtr is equivalent to nullptr (empty state). + OwnPtr state; +}; + +struct LadybirdLocationEntryClass { + GtkEntryClass parent_class; +}; + +G_DEFINE_FINAL_TYPE(LadybirdLocationEntry, ladybird_location_entry, GTK_TYPE_ENTRY) + +static void ladybird_location_entry_update_display_attributes(LadybirdLocationEntry* self); +static void ladybird_location_entry_show_completions(LadybirdLocationEntry* self); +static void ladybird_location_entry_hide_completions(LadybirdLocationEntry* self); +static void ladybird_location_entry_navigate(LadybirdLocationEntry* self); +static void ladybird_location_entry_move_selection(LadybirdLocationEntry* self, int delta); +static void ladybird_location_entry_apply_selected_suggestion(LadybirdLocationEntry* self); + +static void set_entry_text_suppressed(LadybirdLocationEntry* self, char const* text, bool move_cursor_to_end = false) +{ + self->state->updating_text = true; + gtk_editable_set_text(GTK_EDITABLE(self), text); + if (move_cursor_to_end) + gtk_editable_set_position(GTK_EDITABLE(self), -1); + self->state->updating_text = false; +} + +static void ladybird_location_entry_finalize(GObject* object) +{ + auto* self = LADYBIRD_LOCATION_ENTRY(object); + if (self->popover) { + gtk_popover_popdown(self->popover); + gtk_widget_unparent(GTK_WIDGET(self->popover)); + self->popover = nullptr; + } + self->state.clear(); + G_OBJECT_CLASS(ladybird_location_entry_parent_class)->finalize(object); +} + +static void ladybird_location_entry_class_init(LadybirdLocationEntryClass* klass) +{ + auto* object_class = G_OBJECT_CLASS(klass); + object_class->finalize = ladybird_location_entry_finalize; +} + +static void ladybird_location_entry_init(LadybirdLocationEntry* self) +{ + self->state = adopt_own(*new LocationEntryState { .autocomplete = make(), .suggestions = {}, .user_text = {}, .on_navigate = {} }); + + gtk_widget_set_hexpand(GTK_WIDGET(self), TRUE); + + if (auto const& search_engine = WebView::Application::settings().search_engine(); search_engine.has_value()) { + auto placeholder = ByteString::formatted("Search with {} or enter URL", search_engine->name); + gtk_entry_set_placeholder_text(GTK_ENTRY(self), placeholder.characters()); + } else { + gtk_entry_set_placeholder_text(GTK_ENTRY(self), "Enter URL or search..."); + } + + // Load completion popover from resource + Ladybird::GObjectPtr builder { gtk_builder_new_from_resource("/org/ladybird/Ladybird/gtk/location-entry.ui") }; + self->popover = LadybirdWidgets::get_builder_object(builder, "completion_popover"); + self->list_box = LadybirdWidgets::get_builder_object(builder, "completion_list_box"); + gtk_widget_set_parent(GTK_WIDGET(self->popover), GTK_WIDGET(self)); + + // Clicking a suggestion navigates to it + g_signal_connect_swapped(self->list_box, "row-activated", G_CALLBACK(+[](LadybirdLocationEntry* self, GtkListBoxRow* row) { + auto index = gtk_list_box_row_get_index(row); + if (index >= 0 && static_cast(index) < self->state->suggestions.size()) { + set_entry_text_suppressed(self, self->state->suggestions[index].text.to_byte_string().characters()); + ladybird_location_entry_hide_completions(self); + ladybird_location_entry_navigate(self); + } + }), + self); + + // Autocomplete results callback + self->state->autocomplete->on_autocomplete_query_complete = [self](auto suggestions, auto) { + if (suggestions.is_empty() || !self->state->is_focused) { + ladybird_location_entry_hide_completions(self); + return; + } + self->state->suggestions = move(suggestions); + self->state->selected_index = -1; + ladybird_location_entry_show_completions(self); + }; + + // Text changed -> query autocomplete + g_signal_connect_swapped(self, "changed", G_CALLBACK(+[](LadybirdLocationEntry* self, GtkEditable*) { + if (!self->state->is_focused || self->state->updating_text) + return; + auto* text = gtk_editable_get_text(GTK_EDITABLE(self)); + if (!text || text[0] == '\0') { + ladybird_location_entry_hide_completions(self); + return; + } + self->state->user_text = MUST(String::from_utf8(StringView { text, strlen(text) })); + self->state->autocomplete->query_autocomplete_engine(self->state->user_text); + }), + self); + + // Enter navigates + g_signal_connect_swapped(self, "activate", G_CALLBACK(+[](LadybirdLocationEntry* self, GtkEntry*) { + ladybird_location_entry_hide_completions(self); + ladybird_location_entry_navigate(self); + }), + self); + + // Key controller for Up/Down/Escape + auto* key_controller = gtk_event_controller_key_new(); + g_signal_connect_swapped(key_controller, "key-pressed", G_CALLBACK(+[](LadybirdLocationEntry* self, guint keyval, guint, GdkModifierType) -> gboolean { + if (!gtk_widget_get_visible(GTK_WIDGET(self->popover))) + return GDK_EVENT_PROPAGATE; + + switch (keyval) { + case GDK_KEY_Down: + ladybird_location_entry_move_selection(self, 1); + return GDK_EVENT_STOP; + case GDK_KEY_Up: + ladybird_location_entry_move_selection(self, -1); + return GDK_EVENT_STOP; + case GDK_KEY_Escape: + ladybird_location_entry_hide_completions(self); + if (!self->state->user_text.is_empty()) + set_entry_text_suppressed(self, self->state->user_text.to_byte_string().characters()); + return GDK_EVENT_STOP; + default: + return GDK_EVENT_PROPAGATE; + } + }), + self); + gtk_widget_add_controller(GTK_WIDGET(self), GTK_EVENT_CONTROLLER(key_controller)); + + // Focus tracking + auto* focus_controller = gtk_event_controller_focus_new(); + g_signal_connect_swapped(focus_controller, "enter", G_CALLBACK(+[](LadybirdLocationEntry* self, GtkEventControllerFocus*) { + self->state->is_focused = true; + gtk_entry_set_attributes(GTK_ENTRY(self), nullptr); + }), + self); + g_signal_connect_swapped(focus_controller, "leave", G_CALLBACK(+[](LadybirdLocationEntry* self, GtkEventControllerFocus*) { + self->state->is_focused = false; + ladybird_location_entry_hide_completions(self); + ladybird_location_entry_update_display_attributes(self); + }), + self); + gtk_widget_add_controller(GTK_WIDGET(self), GTK_EVENT_CONTROLLER(focus_controller)); +} + +// Public API + +LadybirdLocationEntry* ladybird_location_entry_new(void) +{ + return LADYBIRD_LOCATION_ENTRY(g_object_new(LADYBIRD_TYPE_LOCATION_ENTRY, nullptr)); +} + +void ladybird_location_entry_set_url(LadybirdLocationEntry* self, char const* url) +{ + set_entry_text_suppressed(self, url ? url : ""); + + // Extract scheme for security icon + if (url) { + auto sv = StringView(url, strlen(url)); + auto colon = sv.find(':'); + if (colon.has_value()) { + auto scheme = sv.substring_view(0, *colon); + auto scheme_bs = ByteString(scheme); + ladybird_location_entry_set_security_icon(self, scheme_bs.characters()); + } else { + ladybird_location_entry_set_security_icon(self, nullptr); + } + } else { + ladybird_location_entry_set_security_icon(self, nullptr); + } + + if (!self->state->is_focused) + ladybird_location_entry_update_display_attributes(self); +} + +void ladybird_location_entry_set_text(LadybirdLocationEntry* self, char const* text) +{ + set_entry_text_suppressed(self, text ? text : ""); + gtk_entry_set_attributes(GTK_ENTRY(self), nullptr); + ladybird_location_entry_set_security_icon(self, nullptr); +} + +void ladybird_location_entry_set_security_icon(LadybirdLocationEntry* self, char const* scheme) +{ + if (!scheme) { + gtk_entry_set_icon_from_icon_name(GTK_ENTRY(self), GTK_ENTRY_ICON_PRIMARY, nullptr); + return; + } + + auto sv = StringView(scheme, strlen(scheme)); + if (sv == "https"sv) { + gtk_entry_set_icon_from_icon_name(GTK_ENTRY(self), GTK_ENTRY_ICON_PRIMARY, "channel-secure-symbolic"); + gtk_entry_set_icon_tooltip_text(GTK_ENTRY(self), GTK_ENTRY_ICON_PRIMARY, "Secure connection"); + } else if (sv == "file"sv || sv == "resource"sv || sv == "about"sv || sv == "data"sv) { + gtk_entry_set_icon_from_icon_name(GTK_ENTRY(self), GTK_ENTRY_ICON_PRIMARY, nullptr); + } else { + gtk_entry_set_icon_from_icon_name(GTK_ENTRY(self), GTK_ENTRY_ICON_PRIMARY, "channel-insecure-symbolic"); + gtk_entry_set_icon_tooltip_text(GTK_ENTRY(self), GTK_ENTRY_ICON_PRIMARY, "Insecure connection"); + } +} + +void ladybird_location_entry_focus_and_select_all(LadybirdLocationEntry* self) +{ + gtk_widget_grab_focus(GTK_WIDGET(self)); + gtk_editable_select_region(GTK_EDITABLE(self), 0, -1); +} + +void ladybird_location_entry_set_on_navigate(LadybirdLocationEntry* self, Function callback) +{ + self->state->on_navigate = move(callback); +} + +// Internal helpers + +static void ladybird_location_entry_update_display_attributes(LadybirdLocationEntry* self) +{ + auto* text = gtk_editable_get_text(GTK_EDITABLE(self)); + if (!text || text[0] == '\0') { + gtk_entry_set_attributes(GTK_ENTRY(self), nullptr); + return; + } + + auto url_str = StringView(text, strlen(text)); + auto url_parts = WebView::break_url_into_parts(url_str); + + auto* attrs = pango_attr_list_new(); + + if (url_parts.has_value()) { + auto* dim = pango_attr_foreground_alpha_new(40000); + pango_attr_list_insert(attrs, dim); + + auto highlight_start = url_parts->scheme_and_subdomain.length(); + auto highlight_end = highlight_start + url_parts->effective_tld_plus_one.length(); + + if (highlight_start < highlight_end) { + auto* domain_alpha = pango_attr_foreground_alpha_new(65535); + domain_alpha->start_index = highlight_start; + domain_alpha->end_index = highlight_end; + pango_attr_list_insert(attrs, domain_alpha); + + auto* semi_bold = pango_attr_weight_new(PANGO_WEIGHT_MEDIUM); + semi_bold->start_index = highlight_start; + semi_bold->end_index = highlight_end; + pango_attr_list_insert(attrs, semi_bold); + } + } + + gtk_entry_set_attributes(GTK_ENTRY(self), attrs); + pango_attr_list_unref(attrs); +} + +static void ladybird_location_entry_navigate(LadybirdLocationEntry* self) +{ + auto* text = gtk_editable_get_text(GTK_EDITABLE(self)); + if (!text || text[0] == '\0') + return; + auto query = MUST(String::from_utf8(StringView { text, strlen(text) })); + if (auto url = WebView::sanitize_url(query, WebView::Application::settings().search_engine()); url.has_value()) { + if (self->state->on_navigate) + self->state->on_navigate(url->serialize()); + } +} + +static void ladybird_location_entry_show_completions(LadybirdLocationEntry* self) +{ + GtkWidget* child; + while ((child = gtk_widget_get_first_child(GTK_WIDGET(self->list_box))) != nullptr) + gtk_list_box_remove(self->list_box, child); + + for (auto const& suggestion : self->state->suggestions) { + auto byte_str = suggestion.text.to_byte_string(); + auto* label = gtk_label_new(byte_str.characters()); + gtk_label_set_xalign(GTK_LABEL(label), 0.0); + gtk_label_set_ellipsize(GTK_LABEL(label), PANGO_ELLIPSIZE_END); + gtk_widget_set_margin_start(label, 8); + gtk_widget_set_margin_end(label, 8); + gtk_widget_set_margin_top(label, 4); + gtk_widget_set_margin_bottom(label, 4); + gtk_list_box_append(self->list_box, label); + } + + gtk_list_box_unselect_all(self->list_box); + + auto entry_width = gtk_widget_get_width(GTK_WIDGET(self)); + if (entry_width > 0) + gtk_widget_set_size_request(GTK_WIDGET(self->popover), entry_width, -1); + + gtk_popover_popup(self->popover); +} + +static void ladybird_location_entry_hide_completions(LadybirdLocationEntry* self) +{ + self->state->suggestions.clear(); + self->state->selected_index = -1; + gtk_popover_popdown(self->popover); +} + +static void ladybird_location_entry_move_selection(LadybirdLocationEntry* self, int delta) +{ + auto& state = *self->state; + if (state.suggestions.is_empty()) + return; + + auto new_index = state.selected_index + delta; + if (new_index < -1) + new_index = static_cast(state.suggestions.size()) - 1; + if (new_index >= static_cast(state.suggestions.size())) + new_index = -1; + + state.selected_index = new_index; + + if (state.selected_index >= 0) { + auto* row = gtk_list_box_get_row_at_index(self->list_box, state.selected_index); + gtk_list_box_select_row(self->list_box, row); + ladybird_location_entry_apply_selected_suggestion(self); + } else { + gtk_list_box_unselect_all(self->list_box); + set_entry_text_suppressed(self, state.user_text.to_byte_string().characters(), true); + } +} + +static void ladybird_location_entry_apply_selected_suggestion(LadybirdLocationEntry* self) +{ + auto& state = *self->state; + if (state.selected_index < 0 || static_cast(state.selected_index) >= state.suggestions.size()) + return; + + set_entry_text_suppressed(self, state.suggestions[state.selected_index].text.to_byte_string().characters(), true); +} diff --git a/UI/Gtk/Widgets/LadybirdLocationEntry.h b/UI/Gtk/Widgets/LadybirdLocationEntry.h new file mode 100644 index 00000000000..626ebd195b1 --- /dev/null +++ b/UI/Gtk/Widgets/LadybirdLocationEntry.h @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026, Johan Dahlin + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include + +struct LadybirdLocationEntry; + +GType ladybird_location_entry_get_type(void); +LadybirdLocationEntry* ladybird_location_entry_new(void); + +void ladybird_location_entry_set_url(LadybirdLocationEntry* self, char const* url); +void ladybird_location_entry_set_text(LadybirdLocationEntry* self, char const* text); +void ladybird_location_entry_set_security_icon(LadybirdLocationEntry* self, char const* scheme); +void ladybird_location_entry_focus_and_select_all(LadybirdLocationEntry* self); +void ladybird_location_entry_set_on_navigate(LadybirdLocationEntry* self, Function callback); diff --git a/UI/Gtk/main.cpp b/UI/Gtk/main.cpp index 9c59ad48a29..e0d924bf50f 100644 --- a/UI/Gtk/main.cpp +++ b/UI/Gtk/main.cpp @@ -7,6 +7,7 @@ #include #include #include +#include ErrorOr ladybird_main(Main::Arguments arguments) { @@ -14,5 +15,12 @@ ErrorOr ladybird_main(Main::Arguments arguments) auto app = TRY(Ladybird::Application::create(arguments)); + if (auto const& browser_options = Ladybird::Application::browser_options(); !browser_options.headless_mode.has_value()) { + // Single-instance is handled via D-Bus in Application::create_platform_event_loop(). + // If this is a remote instance, it already forwarded URLs and exited. + auto& window = app->new_window(browser_options.urls); + (void)window; + } + return app->execute(); }