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