Files
ladybird/UI/Gtk/BrowserWindow.cpp
Johan Dahlin 0a00a5d61a 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.
2026-04-17 11:17:56 -04:00

502 lines
18 KiB
C++

/*
* 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);
}
}