From a5be0f0a18e6b77219edd64c18e221a57ab3c6ea Mon Sep 17 00:00:00 2001 From: Timothy Flynn Date: Sat, 30 Aug 2025 13:11:21 -0400 Subject: [PATCH] LibWebView+UI: Add structures to hold context menu and action data We currently duplicate a lot of code to handle application/context menus and actions. The goal here is to hold the data for the menus and actions in LibWebView. Each UI will then be able to generate menus from the data on-the-fly. The structures added here are meant to support generic and checkable actions, action groups, submenus, etc. --- Libraries/LibWebView/CMakeLists.txt | 1 + Libraries/LibWebView/Forward.h | 2 + Libraries/LibWebView/Menu.cpp | 126 +++++++++++++++++++++ Libraries/LibWebView/Menu.h | 142 +++++++++++++++++++++++ UI/AppKit/CMakeLists.txt | 1 + UI/AppKit/Interface/Menu.h | 24 ++++ UI/AppKit/Interface/Menu.mm | 168 ++++++++++++++++++++++++++++ UI/Qt/CMakeLists.txt | 3 +- UI/Qt/Menu.cpp | 130 +++++++++++++++++++++ UI/Qt/Menu.h | 23 ++++ 10 files changed, 619 insertions(+), 1 deletion(-) create mode 100644 Libraries/LibWebView/Menu.cpp create mode 100644 Libraries/LibWebView/Menu.h create mode 100644 UI/AppKit/Interface/Menu.h create mode 100644 UI/AppKit/Interface/Menu.mm create mode 100644 UI/Qt/Menu.cpp create mode 100644 UI/Qt/Menu.h diff --git a/Libraries/LibWebView/CMakeLists.txt b/Libraries/LibWebView/CMakeLists.txt index 8b8a43cf0a5..532347e2f47 100644 --- a/Libraries/LibWebView/CMakeLists.txt +++ b/Libraries/LibWebView/CMakeLists.txt @@ -11,6 +11,7 @@ set(SOURCES DOMNodeProperties.cpp HeadlessWebView.cpp HelperProcess.cpp + Menu.cpp Mutation.cpp Plugins/FontPlugin.cpp Plugins/ImageCodecPlugin.cpp diff --git a/Libraries/LibWebView/Forward.h b/Libraries/LibWebView/Forward.h index bac362d1ec4..1e5f7bad587 100644 --- a/Libraries/LibWebView/Forward.h +++ b/Libraries/LibWebView/Forward.h @@ -12,10 +12,12 @@ namespace WebView { +class Action; class Application; class Autocomplete; class CookieJar; class Database; +class Menu; class OutOfProcessWebView; class ProcessManager; class Settings; diff --git a/Libraries/LibWebView/Menu.cpp b/Libraries/LibWebView/Menu.cpp new file mode 100644 index 00000000000..1ac790142f2 --- /dev/null +++ b/Libraries/LibWebView/Menu.cpp @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include + +namespace WebView { + +NonnullRefPtr Action::create(Variant text, ActionID id, Function action) +{ + return adopt_ref(*new Action { move(text), id, move(action) }); +} + +NonnullRefPtr Action::create_checkable(Variant text, ActionID id, Function action) +{ + auto checkable = create(move(text), id, move(action)); + checkable->m_checked = false; + return checkable; +} + +void Action::set_text(Variant text) +{ + if (text.visit([&](auto const& text) { return text == this->text(); })) + return; + m_text = move(text); + + for (auto& observer : m_observers) + observer->on_text_changed(*this); +} + +void Action::set_tooltip(StringView tooltip) +{ + if (m_tooltip == tooltip) + return; + m_tooltip = tooltip; + + for (auto& observer : m_observers) + observer->on_tooltip_changed(*this); +} + +void Action::set_enabled(bool enabled) +{ + if (m_enabled == enabled) + return; + m_enabled = enabled; + + for (auto& observer : m_observers) + observer->on_enabled_state_changed(*this); +} + +void Action::set_visible(bool visible) +{ + if (m_visible == visible) + return; + m_visible = visible; + + for (auto& observer : m_observers) + observer->on_visible_state_changed(*this); +} + +void Action::set_checked(bool checked) +{ + set_checked_internal(checked); + + if (auto group = m_group.strong_ref()) { + group->for_each_action([&](Action& action) { + if (action.is_checkable() && &action != this) + action.set_checked_internal(false); + }); + } +} + +void Action::set_checked_internal(bool checked) +{ + VERIFY(is_checkable()); + + if (m_checked == checked) + return; + m_checked = checked; + + for (auto& observer : m_observers) + observer->on_checked_state_changed(*this); +} + +void Action::add_observer(NonnullOwnPtr observer) +{ + observer->on_text_changed(*this); + if (m_tooltip.has_value()) + observer->on_tooltip_changed(*this); + observer->on_enabled_state_changed(*this); + observer->on_visible_state_changed(*this); + if (is_checkable()) + observer->on_checked_state_changed(*this); + + m_observers.append(move(observer)); +} + +void Action::remove_observer(Observer const& observer) +{ + m_observers.remove_first_matching([&](auto const& candidate) { + return candidate.ptr() == &observer; + }); +} + +NonnullRefPtr Menu::create(StringView name) +{ + return adopt_ref(*new Menu { name }); +} + +NonnullRefPtr Menu::create_group(StringView name) +{ + auto menu = create(name); + menu->m_is_group = true; + return menu; +} + +void Menu::add_action(NonnullRefPtr action) +{ + if (m_is_group) + action->set_group({}, *this); + m_items.append(move(action)); +} + +} diff --git a/Libraries/LibWebView/Menu.h b/Libraries/LibWebView/Menu.h new file mode 100644 index 00000000000..7c5da0c34b4 --- /dev/null +++ b/Libraries/LibWebView/Menu.h @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2025, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace WebView { + +enum class ActionID { +}; + +class WEBVIEW_API Action + : public RefCounted + , public Weakable { +public: + static NonnullRefPtr create(Variant text, ActionID id, Function action); + static NonnullRefPtr create_checkable(Variant text, ActionID id, Function action); + + void activate() { m_action(); } + + StringView text() const + { + return m_text.visit([](auto const& text) -> StringView { return text; }); + } + void set_text(Variant); + + StringView tooltip() const { return *m_tooltip; } + void set_tooltip(StringView); + + ActionID id() const { return m_id; } + + bool enabled() const { return m_enabled; } + void set_enabled(bool); + + bool visible() const { return m_visible; } + void set_visible(bool); + + bool is_checkable() const { return m_checked.has_value(); } + bool checked() const { return *m_checked; } + void set_checked(bool); + + struct Observer { + virtual ~Observer() = default; + + virtual void on_text_changed(Action&) { } + virtual void on_tooltip_changed(Action&) { } + virtual void on_enabled_state_changed(Action&) { } + virtual void on_visible_state_changed(Action&) { } + virtual void on_checked_state_changed(Action&) { } + }; + + void add_observer(NonnullOwnPtr); + void remove_observer(Observer const& observer); + + void set_group(Badge, Menu& group) { m_group = group; } + +private: + Action(Variant text, ActionID id, Function action) + : m_text(move(text)) + , m_id(id) + , m_action(move(action)) + { + } + + void set_checked_internal(bool checked); + + Variant m_text; + Optional m_tooltip; + ActionID m_id; + + bool m_enabled { true }; + bool m_visible { true }; + Optional m_checked; + + Function m_action; + Vector, 1> m_observers; + + WeakPtr m_group; +}; + +struct WEBVIEW_API Separator { }; + +class WEBVIEW_API Menu + : public RefCounted + , public Weakable { +public: + using MenuItem = Variant, NonnullRefPtr, Separator>; + + static NonnullRefPtr create(StringView name); + static NonnullRefPtr create_group(StringView name); + + void add_action(NonnullRefPtr action); + void add_submenu(NonnullRefPtr submenu) { m_items.append(move(submenu)); } + void add_separator() { m_items.append(Separator {}); } + + StringView title() const { return m_title; } + + Span items() { return m_items; } + ReadonlySpan items() const { return m_items; } + + template + void for_each_action(Callback const& callback) + { + for (auto& item : m_items) { + item.visit( + [&](NonnullRefPtr& action) { callback(*action); }, + [&](NonnullRefPtr& submenu) { submenu->for_each_action(callback); }, + [&](Separator) {}); + } + } + + Function on_activation; + +private: + explicit Menu(StringView title) + : m_title(title) + { + } + + StringView m_title; + Vector m_items; + + bool m_is_group { false }; +}; + +} diff --git a/UI/AppKit/CMakeLists.txt b/UI/AppKit/CMakeLists.txt index 508f12f6fe9..9e9349db53d 100644 --- a/UI/AppKit/CMakeLists.txt +++ b/UI/AppKit/CMakeLists.txt @@ -7,6 +7,7 @@ add_library(ladybird_impl STATIC Interface/LadybirdWebView.mm Interface/LadybirdWebViewBridge.cpp Interface/LadybirdWebViewWindow.mm + Interface/Menu.mm Interface/Palette.mm Interface/SearchPanel.mm Interface/Tab.mm diff --git a/UI/AppKit/Interface/Menu.h b/UI/AppKit/Interface/Menu.h new file mode 100644 index 00000000000..f27b847d93d --- /dev/null +++ b/UI/AppKit/Interface/Menu.h @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include + +#import + +@class LadybirdWebView; + +namespace Ladybird { + +NSMenu* create_application_menu(WebView::Menu&); +NSMenu* create_context_menu(LadybirdWebView*, WebView::Menu&); + +NSMenuItem* create_application_menu_item(WebView::Action&); +NSButton* create_application_button(WebView::Action&, NSImageName); + +} diff --git a/UI/AppKit/Interface/Menu.mm b/UI/AppKit/Interface/Menu.mm new file mode 100644 index 00000000000..d8f8cdbbe55 --- /dev/null +++ b/UI/AppKit/Interface/Menu.mm @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2025, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#import +#import +#import +#import +#import + +@interface ActionExecutor : NSObject +{ + WeakPtr m_action; +} +@end + +@implementation ActionExecutor + ++ (instancetype)attachToNativeControl:(WebView::Action const&)action + control:(id)control +{ + auto* executor = [[ActionExecutor alloc] init]; + [control setAction:@selector(execute:)]; + [control setTarget:executor]; + + static char executor_key = 0; + objc_setAssociatedObject(control, &executor_key, executor, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + + executor->m_action = action.make_weak_ptr(); + return executor; +} + +- (void)execute:(id)sender +{ + auto action = m_action.strong_ref(); + if (!action) + return; + + if (action->is_checkable()) + action->set_checked(!action->checked()); + action->activate(); +} + +@end + +namespace Ladybird { + +class ActionObserver final : public WebView::Action::Observer { +public: + static NonnullOwnPtr create(WebView::Action& action, id control) + { + return adopt_own(*new ActionObserver(action, control)); + } + + virtual void on_text_changed(WebView::Action& action) override + { + if ([m_control isKindOfClass:[NSButton class]] && [m_control image] != nil) + [m_control setToolTip:string_to_ns_string(action.text())]; + else + [m_control setTitle:string_to_ns_string(action.text())]; + } + + virtual void on_tooltip_changed(WebView::Action& action) override + { + [m_control setToolTip:string_to_ns_string(action.tooltip())]; + } + + virtual void on_enabled_state_changed(WebView::Action& action) override + { + [m_control setEnabled:action.enabled()]; + } + + virtual void on_visible_state_changed(WebView::Action& action) override + { + [m_control setHidden:!action.visible()]; + } + + virtual void on_checked_state_changed(WebView::Action& action) override + { + [m_control setState:action.checked() ? NSControlStateValueOn : NSControlStateValueOff]; + } + +private: + ActionObserver(WebView::Action& action, id control) + : m_control(control) + { + [ActionExecutor attachToNativeControl:action control:control]; + } + + __weak id m_control { nil }; +}; + +static void initialize_native_control(WebView::Action& action, id control) +{ + action.add_observer(ActionObserver::create(action, control)); +} + +static void add_items_to_menu(NSMenu* menu, Span menu_items) +{ + for (auto& menu_item : menu_items) { + menu_item.visit( + [&](NonnullRefPtr& action) { + [menu addItem:create_application_menu_item(action)]; + }, + [&](NonnullRefPtr const& submenu) { + auto* application_submenu = [[NSMenu alloc] init]; + add_items_to_menu(application_submenu, submenu->items()); + + auto* item = [[NSMenuItem alloc] initWithTitle:string_to_ns_string(submenu->title()) + action:nil + keyEquivalent:@""]; + [item setSubmenu:application_submenu]; + + [menu addItem:item]; + }, + [&](WebView::Separator) { + [menu addItem:[NSMenuItem separatorItem]]; + }); + } +} + +NSMenu* create_application_menu(WebView::Menu& menu) +{ + auto* application_menu = [[NSMenu alloc] initWithTitle:string_to_ns_string(menu.title())]; + add_items_to_menu(application_menu, menu.items()); + return application_menu; +} + +NSMenu* create_context_menu(LadybirdWebView* view, WebView::Menu& menu) +{ + auto* application_menu = create_application_menu(menu); + + __weak LadybirdWebView* weak_view = view; + __weak NSMenu* weak_application_menu = application_menu; + + menu.on_activation = [weak_view, weak_application_menu](Gfx::IntPoint position) { + LadybirdWebView* view = weak_view; + NSMenu* application_menu = weak_application_menu; + + if (view && application_menu) { + auto* event = create_context_menu_mouse_event(view, position); + [NSMenu popUpContextMenu:application_menu withEvent:event forView:view]; + } + }; + + return application_menu; +} + +NSMenuItem* create_application_menu_item(WebView::Action& action) +{ + auto* item = [[NSMenuItem alloc] init]; + initialize_native_control(action, item); + return item; +} + +NSButton* create_application_button(WebView::Action& action, NSImageName image) +{ + auto* button = [[NSButton alloc] init]; + if (image) + [button setImage:[NSImage imageNamed:image]]; + + initialize_native_control(action, button); + return button; +} + +} diff --git a/UI/Qt/CMakeLists.txt b/UI/Qt/CMakeLists.txt index 1e33be0365d..404cdff5c41 100644 --- a/UI/Qt/CMakeLists.txt +++ b/UI/Qt/CMakeLists.txt @@ -6,11 +6,12 @@ target_sources(ladybird PRIVATE FindInPageWidget.cpp Icon.cpp LocationEdit.cpp + Menu.cpp Settings.cpp + StringUtils.cpp Tab.cpp TabBar.cpp TVGIconEngine.cpp - StringUtils.cpp WebContentView.cpp ladybird.qrc ) diff --git a/UI/Qt/Menu.cpp b/UI/Qt/Menu.cpp new file mode 100644 index 00000000000..669aa8d05d5 --- /dev/null +++ b/UI/Qt/Menu.cpp @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace Ladybird { + +class ActionObserver final : public WebView::Action::Observer { +public: + static NonnullOwnPtr create(WebView::Action& action, QAction& qaction) + { + return adopt_own(*new ActionObserver(action, qaction)); + } + + virtual void on_text_changed(WebView::Action& action) override + { + if (m_action) + m_action->setText(qstring_from_ak_string(action.text())); + } + + virtual void on_tooltip_changed(WebView::Action& action) override + { + if (m_action) + m_action->setToolTip(qstring_from_ak_string(action.tooltip())); + } + + virtual void on_enabled_state_changed(WebView::Action& action) override + { + if (m_action) + m_action->setEnabled(action.enabled()); + } + + virtual void on_visible_state_changed(WebView::Action& action) override + { + if (m_action) + m_action->setVisible(action.visible()); + } + + virtual void on_checked_state_changed(WebView::Action& action) override + { + if (m_action) + m_action->setChecked(action.checked()); + } + +private: + ActionObserver(WebView::Action& action, QAction& qaction) + : m_action(&qaction) + { + QObject::connect(m_action, &QAction::triggered, [weak_action = action.make_weak_ptr()](bool checked) { + if (auto action = weak_action.strong_ref()) { + if (action->is_checkable()) + action->set_checked(checked); + action->activate(); + } + }); + QObject::connect(m_action->parent(), &QObject::destroyed, [this, weak_action = action.make_weak_ptr()]() { + if (auto action = weak_action.strong_ref()) + action->remove_observer(*this); + }); + } + + QPointer m_action; +}; + +static void initialize_native_control(WebView::Action& action, QAction& qaction) +{ + if (action.is_checkable()) + qaction.setCheckable(true); + + action.add_observer(ActionObserver::create(action, qaction)); +} + +static void add_items_to_menu(QMenu& menu, QWidget& parent, Span menu_items) +{ + for (auto& menu_item : menu_items) { + menu_item.visit( + [&](NonnullRefPtr& action) { + auto* qaction = create_application_action(parent, action); + menu.addAction(qaction); + }, + [&](NonnullRefPtr const& submenu) { + auto* qsubmenu = new QMenu(qstring_from_ak_string(submenu->title()), &menu); + add_items_to_menu(*qsubmenu, parent, submenu->items()); + + menu.addMenu(qsubmenu); + }, + [&](WebView::Separator) { + menu.addSeparator(); + }); + } +} + +QMenu* create_application_menu(QWidget& parent, WebView::Menu& menu) +{ + auto* application_menu = new QMenu(qstring_from_ak_string(menu.title()), &parent); + add_items_to_menu(*application_menu, parent, menu.items()); + return application_menu; +} + +QMenu* create_context_menu(QWidget& parent, WebContentView& view, WebView::Menu& menu) +{ + auto* application_menu = create_application_menu(parent, menu); + + menu.on_activation = [view = QPointer { &view }, application_menu = QPointer { application_menu }](Gfx::IntPoint position) { + if (view && application_menu) + application_menu->exec(view->map_point_to_global_position(position)); + }; + + return application_menu; +} + +QAction* create_application_action(QWidget& parent, WebView::Action& action) +{ + auto* qaction = new QAction(&parent); + initialize_native_control(action, *qaction); + return qaction; +} + +} diff --git a/UI/Qt/Menu.h b/UI/Qt/Menu.h new file mode 100644 index 00000000000..c0356803bf7 --- /dev/null +++ b/UI/Qt/Menu.h @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include + +class QAction; +class QMenu; +class QWidget; + +namespace Ladybird { + +class WebContentView; + +QMenu* create_application_menu(QWidget& parent, WebView::Menu&); +QMenu* create_context_menu(QWidget& parent, WebContentView&, WebView::Menu&); +QAction* create_application_action(QWidget& parent, WebView::Action&); + +}