ladybird/UI/Gtk/BrowserWindow.cpp
Andreas Kling 24f37c6732 LibWebView: Keep browser history in the UI process
Use the LibWebView history mirror to preserve traversable session
history across WebContent process swaps. WebContent reports snapshots to
the UI process, and new renderers can be seeded from the mirror.

Browser back and forward now resolve through the UI-owned used history
steps. WebContent still runs the spec traversal path when the current
renderer has enough matching state to do so.

Handle canceled and no-op UI navigations without leaving speculative
history entries or pending WebDriver waits behind. Preserve traversal
precheck state across synchronous IPC shutdown, and avoid overwriting a
restored target entry's persisted scroll state before the document has
adopted that entry.
2026-06-14 17:38:44 +02:00

509 lines
19 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/InternalURLs.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())
(void)tab->view().traverse_the_history_by_delta(-1); }, false);
add_action("go-forward", [](BrowserWindow& self) {
if (auto* tab = self.current_tab())
(void)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", "<Ctrl>KP_Add" });
set_accels("win.zoom-out", { "<Ctrl>minus", "<Ctrl>KP_Subtract" });
set_accels("win.zoom-reset", { "<Ctrl>0", "<Ctrl>KP_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();
for (auto& existing_tab : m_tabs) {
existing_tab->view().set_system_visibility_state(existing_tab.ptr() == tab
? Web::HTML::VisibilityState::Visible
: Web::HTML::VisibilityState::Hidden);
}
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_location_favicon(tab->favicon());
update_location_loading(tab->is_loading());
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);
m_tabs.append(move(tab));
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);
}
}
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);
m_tabs.append(move(tab));
if (activate_tab == Web::HTML::ActivateTab::Yes)
adw_tab_view_set_selected_page(m_tab_view, page);
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::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::update_location_favicon(GdkPaintable* favicon)
{
ladybird_location_entry_set_favicon(m_location_entry, favicon);
}
void BrowserWindow::update_location_loading(bool is_loading)
{
ladybird_location_entry_set_loading(m_location_entry, is_loading);
}
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);
}
}