Files
ladybird/UI/Gtk/Menu.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

490 lines
17 KiB
C++

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