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.
This commit is contained in:
Johan Dahlin
2026-04-08 13:31:51 +02:00
committed by Tim Flynn
parent 2573aff090
commit 0a00a5d61a
Notes: github-actions[bot] 2026-04-17 15:19:11 +00:00
20 changed files with 2886 additions and 12 deletions

View File

@@ -4,14 +4,21 @@
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibURL/Parser.h>
#include <LibWebView/URL.h>
#include <UI/Gtk/Application.h>
#include <UI/Gtk/BrowserWindow.h>
#include <UI/Gtk/Dialogs.h>
#include <UI/Gtk/EventLoopImplementationGtk.h>
#include <UI/Gtk/Tab.h>
#include <UI/Gtk/WebContentView.h>
namespace Ladybird {
Application::Application() = default;
Application::~Application()
{
m_windows.clear();
g_clear_object(&m_adw_application);
}
@@ -28,6 +35,11 @@ NonnullOwnPtr<Core::EventLoop> 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<Core::EventLoop> Application::create_platform_event_loop()
return event_loop;
}
Optional<WebView::ViewImplementation&> Application::active_web_view() const { return {}; }
Optional<WebView::ViewImplementation&> Application::open_blank_new_tab(Web::HTML::ActivateTab) const { return {}; }
Optional<ByteString> 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<Web::Clipboard::SystemClipboardRepresentation> 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<GObjectPtr<GFile>> files;
for (auto const& url : raw_urls)
files.append(GObjectPtr<GFile> { g_file_new_for_commandline_arg(url.characters()) });
Vector<GFile*> raw_files;
for (auto& file : files)
raw_files.append(file.ptr());
g_application_open(G_APPLICATION(m_adw_application), raw_files.data(), static_cast<int>(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<URL::URL> 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<URL::URL> 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<URL::URL> const& initial_urls)
{
auto window = make<BrowserWindow>(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<WebView::ViewImplementation&> Application::active_web_view() const
{
if (auto* tab = active_tab())
return static_cast<WebView::ViewImplementation&>(tab->view());
return {};
}
Optional<WebView::ViewImplementation&> 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<WebView::ViewImplementation&>(tab.view());
}
Optional<ByteString> 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<ByteString> 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<Optional<ByteString>*>(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<ByteString> read_clipboard_text_sync()
{
auto* clipboard = gdk_display_get_clipboard(gdk_display_get_default());
Optional<ByteString> 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<Optional<ByteString>*>(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<Web::Clipboard::SystemClipboardRepresentation> Application::clipboard_entries() const
{
if (browser_options().headless_mode.has_value())
return WebView::Application::clipboard_entries();
Vector<Web::Clipboard::SystemClipboardRepresentation> 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();
}
}

View File

@@ -6,20 +6,41 @@
#pragma once
#include <AK/Function.h>
#include <AK/Vector.h>
#include <LibURL/URL.h>
#include <LibWebView/Application.h>
#include <adwaita.h>
namespace Ladybird {
class BrowserWindow;
class Tab;
class Application : public WebView::Application {
WEB_VIEW_APPLICATION(Application)
public:
virtual ~Application() override;
BrowserWindow& new_window(Vector<URL::URL> 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<typename Callback>
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<URL::URL> urls);
void on_activate();
AdwApplication* m_adw_application { nullptr };
Vector<NonnullOwnPtr<BrowserWindow>> m_windows;
BrowserWindow* m_active_window { nullptr };
};
}

501
UI/Gtk/BrowserWindow.cpp Normal file
View File

@@ -0,0 +1,501 @@
/*
* Copyright (c) 2026, Johan Dahlin <jdahlin@gmail.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/Format.h>
#include <LibCore/EventLoop.h>
#include <LibURL/Parser.h>
#include <LibWebView/Menu.h>
#include <LibWebView/URL.h>
#include <UI/Gtk/Application.h>
#include <UI/Gtk/BrowserWindow.h>
#include <UI/Gtk/GLibPtr.h>
#include <UI/Gtk/Menu.h>
#include <UI/Gtk/Tab.h>
#include <UI/Gtk/WebContentView.h>
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<URL::URL> 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<ActionData*>(user_data);
data->callback(*data->window); }), new ActionData { this, callback }, +[](gpointer data, GClosure*) { delete static_cast<ActionData*>(data); }, static_cast<GConnectFlags>(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<int>(action.id()));
}) };
GObjectPtr debug_gmenu { create_application_menu(WebView::Application::the().debug_menu(), [](WebView::Action& action) {
return ByteString::formatted("win.debug-{}", static_cast<int>(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<int>(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<int>(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<char const*> accels) {
Vector<char const*> 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", { "<Ctrl>t" });
set_accels("win.close-tab", { "<Ctrl>w" });
set_accels("win.focus-location", { "<Ctrl>l" });
set_accels("win.find", { "<Ctrl>f" });
set_accels("win.find-close", { "Escape" });
set_accels("win.go-back", { "<Alt>Left" });
set_accels("win.go-forward", { "<Alt>Right" });
set_accels("win.zoom-in", { "<Ctrl>equal", "<Ctrl>plus" });
set_accels("win.zoom-out", { "<Ctrl>minus" });
set_accels("win.zoom-reset", { "<Ctrl>0" });
set_accels("win.fullscreen", { "F11" });
set_accels("win.quit", { "<Ctrl>q" });
set_accels("win.new-window", { "<Ctrl>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<Tab>(*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<Tab>(*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<NavActionObserver>(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<size_t> 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<int>(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);
}
}

86
UI/Gtk/BrowserWindow.h Normal file
View File

@@ -0,0 +1,86 @@
/*
* Copyright (c) 2026, Johan Dahlin <jdahlin@gmail.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <AK/Vector.h>
#include <LibURL/URL.h>
#include <LibWeb/HTML/ActivateTab.h>
#include <LibWebView/Menu.h>
#include <UI/Gtk/Widgets/LadybirdBrowserWindow.h>
#include <UI/Gtk/Widgets/LadybirdLocationEntry.h>
#include <adwaita.h>
namespace Ladybird {
class Tab;
class WebContentView;
class BrowserWindow {
public:
BrowserWindow(AdwApplication* app, Vector<URL::URL> 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<size_t> 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<NonnullOwnPtr<Tab>> 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;
};
}

View File

@@ -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)

206
UI/Gtk/Dialogs.cpp Normal file
View File

@@ -0,0 +1,206 @@
/*
* Copyright (c) 2026, Johan Dahlin <jdahlin@gmail.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <UI/Gtk/Dialogs.h>
#include <UI/Gtk/GLibPtr.h>
#include <UI/Gtk/WebContentView.h>
#include <adwaita.h>
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<int>(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<PromptData*>(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<float>(current_color.red()) / 255.0f,
static_cast<float>(current_color.green()) / 255.0f,
static_cast<float>(current_color.blue()) / 255.0f,
static_cast<float>(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<WebContentView*>(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<u8>(color->red * 255),
static_cast<u8>(color->green * 255),
static_cast<u8>(color->blue * 255),
static_cast<u8>(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<WebContentView*>(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<Web::HTML::SelectedFile> 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<WebContentView*>(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<Web::HTML::SelectedFile> 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);
}
}
}

31
UI/Gtk/Dialogs.h Normal file
View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026, Johan Dahlin <jdahlin@gmail.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <AK/String.h>
#include <LibGfx/Color.h>
#include <LibWeb/HTML/FileFilter.h>
#include <LibWeb/HTML/SelectedFile.h>
#include <gtk/gtk.h>
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);
}
}

489
UI/Gtk/Menu.cpp Normal file
View File

@@ -0,0 +1,489 @@
/*
* Copyright (c) 2026, Johan Dahlin <jdahlin@gmail.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <UI/Gtk/GLibPtr.h>
#include <UI/Gtk/Menu.h>
#include <UI/Gtk/WebContentView.h>
namespace Ladybird {
class ActionObserver final : public WebView::Action::Observer {
public:
explicit ActionObserver(GSimpleAction* gaction)
: m_gaction(GObjectPtr<GSimpleAction> { 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<GSimpleAction> 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 "<Alt>Left";
case WebView::ActionID::NavigateForward:
return "<Alt>Right";
case WebView::ActionID::Reload:
return "<Ctrl>r";
case WebView::ActionID::CopySelection:
return "<Ctrl>c";
case WebView::ActionID::Paste:
return "<Ctrl>v";
case WebView::ActionID::SelectAll:
return "<Ctrl>a";
case WebView::ActionID::ToggleBookmark:
return "<Ctrl>d";
case WebView::ActionID::ToggleBookmarksBar:
return "<Ctrl><Shift>b";
case WebView::ActionID::OpenProcessesPage:
return "<Ctrl><Shift>m";
case WebView::ActionID::OpenSettingsPage:
return "<Ctrl>comma";
case WebView::ActionID::ToggleDevTools:
return "<Ctrl><Shift>i";
case WebView::ActionID::ViewSource:
return "<Ctrl>u";
case WebView::ActionID::ZoomIn:
return "<Ctrl>equal";
case WebView::ActionID::ZoomOut:
return "<Ctrl>minus";
case WebView::ActionID::ResetZoom:
case WebView::ActionID::ResetZoomViaToolbar:
return "<Ctrl>0";
case WebView::ActionID::CollectGarbage:
return "<Ctrl><Shift>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("<Alt>Left");
break;
case WebView::ActionID::NavigateForward:
set_icon("go-next-symbolic");
set_accel("<Alt>Right");
break;
case WebView::ActionID::Reload:
set_icon("view-refresh-symbolic");
set_accel("<Ctrl>r");
break;
case WebView::ActionID::CopySelection:
set_icon("edit-copy-symbolic");
set_accel("<Ctrl>c");
break;
case WebView::ActionID::Paste:
set_icon("edit-paste-symbolic");
set_accel("<Ctrl>v");
break;
case WebView::ActionID::SelectAll:
set_icon("edit-select-all-symbolic");
set_accel("<Ctrl>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("<Ctrl>d");
break;
case WebView::ActionID::ToggleBookmarksBar:
set_icon("user-bookmarks-symbolic");
set_accel("<Ctrl><Shift>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("<Ctrl><Shift>m");
break;
case WebView::ActionID::OpenSettingsPage:
set_icon("emblem-system-symbolic");
set_accel("<Ctrl>comma");
break;
case WebView::ActionID::ToggleDevTools:
case WebView::ActionID::DumpDOMTree:
set_icon("applications-engineering-symbolic");
set_accel("<Ctrl><Shift>i");
break;
case WebView::ActionID::ViewSource:
set_icon("text-html-symbolic");
set_accel("<Ctrl>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("<Ctrl>equal");
break;
case WebView::ActionID::ZoomOut:
set_icon("zoom-out-symbolic");
set_accel("<Ctrl>minus");
break;
case WebView::ActionID::ResetZoom:
case WebView::ActionID::ResetZoomViaToolbar:
set_icon("zoom-original-symbolic");
set_accel("<Ctrl>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("<Ctrl><Shift>g");
break;
default:
break;
}
}
static void add_items_to_menu(GMenu& menu, ReadonlySpan<WebView::Menu::MenuItem> menu_items, Function<ByteString(WebView::Action&)> 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<GMenu> { g_menu_new() };
section_has_items = false;
};
for (auto& menu_item : menu_items) {
menu_item.visit(
[&](NonnullRefPtr<WebView::Action> 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<WebView::Menu> 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<GSimpleActionGroup> { 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<int>(position.x() / device_pixel_ratio),
static_cast<int>(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<GSimpleActionGroup> m_action_group;
HashMap<WebView::Action const*, ByteString> 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<WebView::Action>(action.make_weak_ptr());
g_signal_connect_data(gaction, "activate", G_CALLBACK(+[](GSimpleAction*, GVariant*, gpointer user_data) {
auto* weak = static_cast<WeakPtr<WebView::Action>*>(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<WeakPtr<WebView::Action>*>(data); }, static_cast<GConnectFlags>(0));
initialize_native_control(action, gaction, nullptr);
if (observe_state)
action.add_observer(make<ActionObserver>(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<ByteString(WebView::Action&)> 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<ContextMenu>(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<ByteString(WebView::Action&)> 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[] = { "<Ctrl>r", "F5", nullptr };
gtk_application_set_accels_for_action(application, detailed_action_name, accels);
break;
}
case WebView::ActionID::ToggleDevTools: {
static constexpr char const* accels[] = { "<Ctrl><Shift>i", "<Ctrl><Shift>c", "F12", nullptr };
gtk_application_set_accels_for_action(application, detailed_action_name, accels);
break;
}
case WebView::ActionID::ZoomIn: {
static constexpr char const* accels[] = { "<Ctrl>equal", "<Ctrl>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<int>(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);
}
}

27
UI/Gtk/Menu.h Normal file
View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2026, Johan Dahlin <jdahlin@gmail.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <AK/Function.h>
#include <LibWebView/Menu.h>
#include <gio/gio.h>
#include <gtk/gtk.h>
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<ByteString(WebView::Action&)> 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<ByteString(WebView::Action&)> 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);
}

View File

@@ -0,0 +1,252 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.4"/>
<template class="LadybirdBrowserWindow" parent="AdwApplicationWindow">
<property name="title">Ladybird</property>
<property name="default-width">1024</property>
<property name="default-height">768</property>
<property name="content">
<object class="AdwToolbarView" id="toolbar_view">
<!-- Top bar: Header -->
<child type="top">
<object class="AdwHeaderBar" id="header_bar">
<child type="start">
<object class="GtkBox" id="nav_box">
<style><class name="linked"/></style>
<child>
<object class="GtkButton" id="back_button">
<property name="action-name">win.go-back</property>
<property name="icon-name">go-previous-symbolic</property>
<property name="tooltip-text">Back (Alt+Left)</property>
<style><class name="flat"/></style>
</object>
</child>
<child>
<object class="GtkButton" id="forward_button">
<property name="action-name">win.go-forward</property>
<property name="icon-name">go-next-symbolic</property>
<property name="tooltip-text">Forward (Alt+Right)</property>
<style><class name="flat"/></style>
</object>
</child>
<child>
<object class="GtkButton" id="reload_button">
<property name="action-name">win.reload</property>
<property name="icon-name">view-refresh-symbolic</property>
<property name="tooltip-text">Reload (Ctrl+R)</property>
<style><class name="flat"/></style>
</object>
</child>
</object>
</child>
<child type="start">
<object class="GtkButton" id="new_tab_button">
<property name="action-name">win.new-tab</property>
<property name="icon-name">tab-new-symbolic</property>
<property name="tooltip-text">New Tab (Ctrl+T)</property>
<style><class name="flat"/></style>
</object>
</child>
<child type="end">
<object class="GtkButton" id="restore_button">
<property name="icon-name">view-restore-symbolic</property>
<property name="tooltip-text">Exit Fullscreen</property>
<property name="action-name">win.fullscreen</property>
<property name="visible">false</property>
</object>
</child>
<child type="end">
<object class="GtkMenuButton" id="menu_button">
<property name="icon-name">open-menu-symbolic</property>
<property name="tooltip-text">Menu</property>
<property name="popover">hamburger_popover</property>
</object>
</child>
</object>
</child>
<!-- Top bar: Find bar -->
<child type="top">
<object class="GtkRevealer" id="find_bar_revealer">
<property name="transition-type">slide-down</property>
<property name="reveal-child">false</property>
<property name="child">
<object class="GtkBox" id="find_bar">
<property name="orientation">horizontal</property>
<property name="spacing">4</property>
<property name="margin-start">8</property>
<property name="margin-end">8</property>
<property name="margin-top">4</property>
<property name="margin-bottom">4</property>
<style><class name="toolbar"/></style>
<child>
<object class="GtkSearchEntry" id="find_entry">
<property name="hexpand">true</property>
</object>
</child>
<child>
<object class="GtkLabel" id="find_result_label">
<style><class name="dim-label"/></style>
</object>
</child>
<child>
<object class="GtkButton" id="find_prev_button">
<property name="action-name">win.find-previous</property>
<property name="icon-name">go-up-symbolic</property>
<property name="tooltip-text">Previous Match (Shift+Enter)</property>
<style><class name="flat"/></style>
</object>
</child>
<child>
<object class="GtkButton" id="find_next_button">
<property name="action-name">win.find-next</property>
<property name="icon-name">go-down-symbolic</property>
<property name="tooltip-text">Next Match (Enter)</property>
<style><class name="flat"/></style>
</object>
</child>
<child>
<object class="GtkButton" id="find_close_button">
<property name="action-name">win.find-close</property>
<property name="icon-name">window-close-symbolic</property>
<property name="tooltip-text">Close (Escape)</property>
<style><class name="flat"/></style>
</object>
</child>
</object>
</property>
</object>
</child>
<!-- Top bar: Tab bar -->
<child type="top">
<object class="AdwTabBar" id="tab_bar">
<property name="autohide">true</property>
<property name="view">tab_view</property>
</object>
</child>
<!-- Content: Toast overlay wrapping tab view -->
<property name="content">
<object class="AdwToastOverlay" id="toast_overlay">
<property name="child">
<object class="AdwTabView" id="tab_view">
<property name="vexpand">true</property>
</object>
</property>
</object>
</property>
<!-- Bottom bar: DevTools banner -->
<child type="bottom">
<object class="AdwBanner" id="devtools_banner">
<property name="revealed">false</property>
<property name="button-label">Disable</property>
</object>
</child>
</object>
</property>
</template>
<!-- Hamburger menu model -->
<menu id="hamburger_menu">
<section>
<item>
<attribute name="custom">zoom</attribute>
</item>
</section>
<section>
<item>
<attribute name="custom">tools</attribute>
</item>
</section>
<section>
<item>
<attribute name="label">New Tab</attribute>
<attribute name="action">win.new-tab</attribute>
</item>
<item>
<attribute name="label">New Window</attribute>
<attribute name="action">win.new-window</attribute>
</item>
</section>
<section>
<item>
<attribute name="label">Preferences</attribute>
<attribute name="action">win.preferences</attribute>
</item>
<item>
<attribute name="label">About Ladybird</attribute>
<attribute name="action">win.about</attribute>
</item>
<item>
<attribute name="label">Quit</attribute>
<attribute name="action">win.quit</attribute>
</item>
</section>
</menu>
<!-- Hamburger popover with custom widget slots -->
<object class="GtkPopoverMenu" id="hamburger_popover">
<property name="menu-model">hamburger_menu</property>
<child type="zoom">
<object class="GtkBox" id="zoom_box">
<property name="orientation">horizontal</property>
<property name="hexpand">true</property>
<property name="homogeneous">true</property>
<style><class name="linked"/></style>
<child>
<object class="GtkButton" id="zoom_out_button">
<property name="action-name">win.zoom-out</property>
<property name="icon-name">zoom-out-symbolic</property>
<property name="tooltip-text">Zoom Out</property>
</object>
</child>
<child>
<object class="GtkButton" id="zoom_reset_button">
<property name="action-name">win.zoom-reset</property>
<property name="tooltip-text">Reset Zoom</property>
<property name="child">
<object class="GtkLabel" id="zoom_label">
<property name="label">100%</property>
<property name="width-chars">5</property>
<style><class name="numeric"/></style>
</object>
</property>
</object>
</child>
<child>
<object class="GtkButton" id="zoom_in_button">
<property name="action-name">win.zoom-in</property>
<property name="icon-name">zoom-in-symbolic</property>
<property name="tooltip-text">Zoom In</property>
</object>
</child>
</object>
</child>
<child type="tools">
<object class="GtkBox" id="tools_box">
<property name="orientation">horizontal</property>
<property name="hexpand">true</property>
<property name="homogeneous">true</property>
<style><class name="linked"/></style>
<child>
<object class="GtkButton" id="find_menu_button">
<property name="action-name">win.find</property>
<property name="icon-name">system-search-symbolic</property>
<property name="tooltip-text">Find in Page</property>
</object>
</child>
<child>
<object class="GtkButton" id="fullscreen_menu_button">
<property name="action-name">win.fullscreen</property>
<property name="icon-name">view-fullscreen-symbolic</property>
<property name="tooltip-text">Fullscreen</property>
</object>
</child>
</object>
</child>
</object>
</interface>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<object class="GtkPopover" id="popover">
<property name="has-arrow">false</property>
<child>
<object class="GtkScrolledWindow" id="scrolled_window">
<property name="hscrollbar-policy">never</property>
<property name="vscrollbar-policy">automatic</property>
<property name="max-content-height">300</property>
<property name="propagate-natural-height">true</property>
<property name="child">
<object class="GtkListBox" id="list_box">
<style><class name="boxed-list"/></style>
</object>
</property>
</object>
</child>
</object>
</interface>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<object class="GtkPopover" id="completion_popover">
<property name="autohide">false</property>
<property name="has-arrow">false</property>
<property name="position">bottom</property>
<property name="halign">start</property>
<property name="child">
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="vscrollbar-policy">automatic</property>
<property name="max-content-height">300</property>
<property name="propagate-natural-height">true</property>
<property name="child">
<object class="GtkListBox" id="completion_list_box">
<property name="selection-mode">browse</property>
</object>
</property>
</object>
</property>
</object>
</interface>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/org/ladybird/Ladybird/gtk">
<file preprocess="xml-stripblanks">browser-window.ui</file>
<file preprocess="xml-stripblanks">list-popover.ui</file>
<file preprocess="xml-stripblanks">location-entry.ui</file>
</gresource>
</gresources>

347
UI/Gtk/Tab.cpp Normal file
View File

@@ -0,0 +1,347 @@
/*
* Copyright (c) 2026, Johan Dahlin <jdahlin@gmail.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibGfx/Bitmap.h>
#include <LibGfx/Cursor.h>
#include <LibURL/URL.h>
#include <LibWeb/HTML/SelectItem.h>
#include <LibWebView/Menu.h>
#include <LibWebView/URL.h>
#include <UI/Gtk/BrowserWindow.h>
#include <UI/Gtk/Dialogs.h>
#include <UI/Gtk/Events.h>
#include <UI/Gtk/GLibPtr.h>
#include <UI/Gtk/Menu.h>
#include <UI/Gtk/Tab.h>
#include <UI/Gtk/WebContentView.h>
#include <UI/Gtk/Widgets/Builder.h>
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<GtkPopover>(builder, "popover");
auto* list_box = LadybirdWidgets::get_builder_object<GtkListBox>(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<WebView::WebContentClient> 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<Web::HTML::SelectItem> 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<Web::HTML::SelectItem> 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<int>(content_position.x() / device_pixel_ratio),
static_cast<int>(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<DropdownState*>(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<DropdownState*>(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));
}
}

55
UI/Gtk/Tab.h Normal file
View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2026, Johan Dahlin <jdahlin@gmail.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <AK/NonnullOwnPtr.h>
#include <AK/OwnPtr.h>
#include <AK/RefPtr.h>
#include <LibGfx/Point.h>
#include <LibURL/URL.h>
#include <LibWeb/HTML/ActivateTab.h>
#include <LibWeb/HTML/SelectItem.h>
#include <LibWebView/Forward.h>
#include <UI/Gtk/Widgets/LadybirdWebView.h>
#include <adwaita.h>
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<WebView::WebContentClient> parent_client, size_t page_index);
void setup_callbacks();
void show_select_dropdown(Gfx::IntPoint content_position, i32 minimum_width, Vector<Web::HTML::SelectItem> items);
BrowserWindow& m_window;
OwnPtr<WebContentView> m_view;
LadybirdWebView* m_web_view { nullptr };
AdwTabPage* m_tab_page { nullptr };
URL::URL m_initial_url;
};
}

View File

@@ -0,0 +1,88 @@
/*
* Copyright (c) 2026, Johan Dahlin <jdahlin@gmail.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/Assertions.h>
#include <UI/Gtk/Widgets/LadybirdBrowserWindow.h>
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<LadybirdBrowserWindow*>(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; }
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026, Johan Dahlin <jdahlin@gmail.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <adwaita.h>
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*);
}

View File

@@ -0,0 +1,368 @@
/*
* Copyright (c) 2026, Johan Dahlin <jdahlin@gmail.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/NonnullOwnPtr.h>
#include <AK/OwnPtr.h>
#include <AK/String.h>
#include <AK/Vector.h>
#include <LibWebView/Application.h>
#include <LibWebView/Autocomplete.h>
#include <LibWebView/URL.h>
#include <UI/Gtk/GLibPtr.h>
#include <UI/Gtk/Widgets/Builder.h>
#include <UI/Gtk/Widgets/LadybirdLocationEntry.h>
struct LocationEntryState {
NonnullOwnPtr<WebView::Autocomplete> autocomplete;
Vector<WebView::AutocompleteSuggestion> suggestions;
int selected_index { -1 };
String user_text;
bool is_focused { false };
bool updating_text { false };
Function<void(String)> on_navigate;
};
#define LADYBIRD_LOCATION_ENTRY(obj) (reinterpret_cast<LadybirdLocationEntry*>(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<LocationEntryState> 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<WebView::Autocomplete>(), .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<GtkPopover>(builder, "completion_popover");
self->list_box = LadybirdWidgets::get_builder_object<GtkListBox>(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<size_t>(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<void(String)> 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<int>(state.suggestions.size()) - 1;
if (new_index >= static_cast<int>(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<size_t>(state.selected_index) >= state.suggestions.size())
return;
set_entry_text_suppressed(self, state.suggestions[state.selected_index].text.to_byte_string().characters(), true);
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2026, Johan Dahlin <jdahlin@gmail.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <AK/Function.h>
#include <gtk/gtk.h>
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<void(String)> callback);

View File

@@ -7,6 +7,7 @@
#include <LibMain/Main.h>
#include <LibWebView/Application.h>
#include <UI/Gtk/Application.h>
#include <UI/Gtk/BrowserWindow.h>
ErrorOr<int> ladybird_main(Main::Arguments arguments)
{
@@ -14,5 +15,12 @@ ErrorOr<int> 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();
}