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&); + +}