ladybird/UI/Gtk/BrowserWindow.cpp
Andreas Kling 0a70dadfb1 UI: Show page loading state in the location field
Use the leading icon in the location field as a contextual page
indicator. While a page is loading, the field shows an indeterminate
activity indicator. Once loading finishes, it shows the page favicon
when available and falls back to the generic page icon where the toolkit
has one.

Keep the GTK implementation aligned with the other frontends by using
that slot only for loading and favicon state, without a separate
security icon.

This keeps single-tab windows from losing all visual feedback when the
tab bar is hidden, without adding a fake progress bar.
2026-05-20 09:37:33 +02:00

513 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/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", "<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();
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);
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::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);
}
}