UI/AppKit: Create custom popup views for bookmarks bar folders

Unfortunately, the NSMenu-based bookmarks implementation cannot support
context menus within arbitrary folders. The NSMenu consumes the right-
click events during its own menu tracking, and we are not able to see
those events from our interface.

This patch replaces the NSMenu for folders with a custom NSPopup. This
implementation makes it much easier to handle context menus.
This commit is contained in:
Timothy Flynn
2026-03-31 07:20:28 -04:00
committed by Alexander Kalenik
parent d8fb0ed59a
commit 81c2426b03
Notes: github-actions[bot] 2026-04-01 02:58:29 +00:00
7 changed files with 484 additions and 11 deletions

View File

@@ -3,6 +3,7 @@ add_library(ladybird_impl STATIC
Application/ApplicationDelegate.mm
Application/EventLoopImplementationMacOS.mm
Interface/Autocomplete.mm
Interface/BookmarkFolder.mm
Interface/BookmarksBar.mm
Interface/Event.mm
Interface/InfoBar.mm

View File

@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026, Tim Flynn <trflynn89@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <LibWebView/Forward.h>
#import <Cocoa/Cocoa.h>
@class BookmarksBar;
@interface BookmarkFolderPopover : NSPopover
- (instancetype)init:(WebView::Menu&)menu
bookmarksBar:(BookmarksBar*)bookmarks_bar
parentFolder:(BookmarkFolderPopover*)parent_folder;
- (void)showRelativeToView:(NSView*)view preferredEdge:(NSRectEdge)preferred_edge;
- (void)openChildFolder:(WebView::Menu&)menu relativeToView:(NSView*)view;
- (void)closeChildFolder;
- (void)close;
@end

View File

@@ -0,0 +1,358 @@
/*
* Copyright (c) 2026, Tim Flynn <trflynn89@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/Math.h>
#include <LibWebView/Menu.h>
#import <Interface/BookmarkFolder.h>
#import <Interface/BookmarksBar.h>
#import <Interface/Menu.h>
#import <Utilities/Conversions.h>
#import <objc/runtime.h>
#if !__has_feature(objc_arc)
# error "This project requires ARC"
#endif
static constexpr CGFloat const BOOKMARK_FOLDER_WIDTH = 200;
static constexpr CGFloat const BOOKMARK_FOLDER_MAX_HEIGHT = 360;
static constexpr CGFloat const BOOKMARK_FOLDER_ROW_HEIGHT = 26;
static constexpr CGFloat const BOOKMARK_FOLDER_ICON_SIZE = 16;
static constexpr CGFloat const BOOKMARK_FOLDER_CHEVRON_WIDTH = 12;
static constexpr CGFloat const BOOKMARK_FOLDER_INSET = 8;
static constexpr CGFloat const BOOKMARK_FOLDER_HORIZONTAL_PADDING = 4;
static constexpr CGFloat const BOOKMARK_FOLDER_VERTICAL_PADDING = 6;
static constexpr CGFloat const BOOKMARK_FOLDER_HORIZONTAL_OVERLAP = 24;
static constexpr CGFloat const BOOKMARK_FOLDER_SUBMENU_LEFT_SHIFT = 18;
static constexpr CGFloat const BOOKMARK_FOLDER_ROOT_VERTICAL_SHIFT = 10;
@interface BookmarkFolderItemView : NSView
@property (nonatomic, weak) BookmarksBar* bookmarks_bar;
@property (nonatomic, weak) BookmarkFolderPopover* parent_folder;
@property (nonatomic, strong) NSImageView* icon_view;
@property (nonatomic, strong) NSTextField* title_label;
@property (nonatomic, strong) NSImageView* chevron_view;
@property (nonatomic, strong) NSTrackingArea* tracking_area;
@end
@implementation BookmarkFolderItemView
{
WeakPtr<WebView::Action> m_action;
WeakPtr<WebView::Menu> m_menu;
BOOL m_hovered;
}
- (instancetype)initForBookmark:(WebView::Action&)action
bookmarksBar:(BookmarksBar*)bookmarks_bar
parentFolder:(BookmarkFolderPopover*)parent_folder
{
if (self = [super initWithFrame:NSZeroRect]) {
self.bookmarks_bar = bookmarks_bar;
self.parent_folder = parent_folder;
m_action = action.make_weak_ptr();
m_hovered = NO;
[self setToolTip:Ladybird::string_to_ns_string(action.tooltip())];
self.icon_view = Ladybird::create_application_icon(action);
[self addSubview:self.icon_view];
self.title_label = [NSTextField labelWithString:Ladybird::string_to_ns_string(action.text())];
[self.title_label setFont:[NSFont menuFontOfSize:0]];
[[self.title_label cell] setLineBreakMode:NSLineBreakByTruncatingTail];
[self addSubview:self.title_label];
}
return self;
}
- (instancetype)initForSubfolder:(WebView::Menu&)menu
bookmarksBar:(BookmarksBar*)bookmarks_bar
parentFolder:(BookmarkFolderPopover*)parent_folder
{
if (self = [super initWithFrame:NSZeroRect]) {
self.bookmarks_bar = bookmarks_bar;
self.parent_folder = parent_folder;
m_menu = menu.make_weak_ptr();
m_hovered = NO;
self.icon_view = [[NSImageView alloc] initWithFrame:NSZeroRect];
[self.icon_view setImage:[NSImage imageWithSystemSymbolName:@"folder" accessibilityDescription:@""]];
[self addSubview:self.icon_view];
self.title_label = [NSTextField labelWithString:Ladybird::string_to_ns_string(menu.title())];
[self.title_label setFont:[NSFont menuFontOfSize:0]];
[[self.title_label cell] setLineBreakMode:NSLineBreakByTruncatingTail];
[self addSubview:self.title_label];
self.chevron_view = [[NSImageView alloc] initWithFrame:NSZeroRect];
[self.chevron_view setImage:[NSImage imageWithSystemSymbolName:@"chevron.right" accessibilityDescription:@""]];
[self.chevron_view setAutoresizingMask:NSViewMinXMargin];
[self addSubview:self.chevron_view];
}
return self;
}
- (void)setHovered:(BOOL)hovered
{
if (m_hovered == hovered)
return;
m_hovered = hovered;
auto* text_color = m_hovered ? [NSColor alternateSelectedControlTextColor] : [NSColor controlTextColor];
[self.title_label setTextColor:text_color];
[self setNeedsDisplay:YES];
}
#pragma mark - NSView
- (void)mouseEntered:(NSEvent*)event
{
[self setHovered:YES];
if (auto submenu = m_menu.strong_ref())
[self.parent_folder openChildFolder:*submenu relativeToView:self];
else
[self.parent_folder closeChildFolder];
}
- (void)mouseExited:(NSEvent*)event
{
[self setHovered:NO];
}
- (void)mouseDown:(NSEvent*)event
{
if (auto submenu = m_menu.strong_ref(); submenu && submenu->size() > 0) {
[self.parent_folder openChildFolder:*submenu relativeToView:self];
} else if (auto action = m_action.strong_ref()) {
[self.bookmarks_bar closeBookmarkFolders];
action->activate();
}
}
- (void)updateTrackingAreas
{
[super updateTrackingAreas];
auto mouse_location = [self convertPoint:[[self window] mouseLocationOutsideOfEventStream] fromView:nil];
[self setHovered:CGRectContainsPoint([self bounds], mouse_location)];
if (self.tracking_area)
[self removeTrackingArea:self.tracking_area];
self.tracking_area = [[NSTrackingArea alloc] initWithRect:NSZeroRect
options:NSTrackingMouseEnteredAndExited | NSTrackingActiveInKeyWindow | NSTrackingInVisibleRect
owner:self
userInfo:nil];
[self addTrackingArea:self.tracking_area];
}
- (void)drawRect:(NSRect)dirtyRect
{
[super drawRect:dirtyRect];
if (!m_hovered)
return;
auto* selection_path = [NSBezierPath bezierPathWithRoundedRect:NSInsetRect(self.bounds, 4, 1) xRadius:8 yRadius:8];
[[NSColor selectedContentBackgroundColor] setFill];
[selection_path fill];
}
- (void)layout
{
[super layout];
auto [frame_width, frame_height] = [self bounds].size;
auto icon_size = AK::min(BOOKMARK_FOLDER_ICON_SIZE, frame_height);
auto icon_y = AK::floor((frame_height - icon_size) / 2.0);
auto label_height = AK::min([self.title_label intrinsicContentSize].height, frame_height);
auto label_y = AK::floor((frame_height - label_height) / 2.0);
auto icon_frame = NSMakeRect(BOOKMARK_FOLDER_INSET, icon_y, icon_size, icon_size);
[self.icon_view setFrame:icon_frame];
auto trailing_width = self.chevron_view
? BOOKMARK_FOLDER_HORIZONTAL_PADDING + BOOKMARK_FOLDER_CHEVRON_WIDTH + BOOKMARK_FOLDER_INSET
: 0;
auto title_frame = NSMakeRect(
BOOKMARK_FOLDER_INSET + icon_size + BOOKMARK_FOLDER_HORIZONTAL_PADDING,
label_y,
frame_width - (BOOKMARK_FOLDER_HORIZONTAL_PADDING * 2) - icon_size - trailing_width,
label_height);
[self.title_label setFrame:title_frame];
if (self.chevron_view) {
auto chevron_frame = NSMakeRect(
frame_width - BOOKMARK_FOLDER_INSET - BOOKMARK_FOLDER_CHEVRON_WIDTH,
icon_y,
BOOKMARK_FOLDER_CHEVRON_WIDTH,
icon_size);
[self.chevron_view setFrame:chevron_frame];
}
}
@end
@interface BookmarkFolderPopover () <NSPopoverDelegate>
@property (nonatomic, strong) BookmarkFolderPopover* child_folder;
@property (nonatomic, weak) BookmarkFolderPopover* parent_folder;
@property (nonatomic, weak) BookmarksBar* bookmarks_bar;
@end
@implementation BookmarkFolderPopover
{
WeakPtr<WebView::Menu> m_menu;
}
- (instancetype)init:(WebView::Menu&)menu
bookmarksBar:(BookmarksBar*)bookmarks_bar
parentFolder:(BookmarkFolderPopover*)parent_folder
{
if (self = [super init]) {
self.bookmarks_bar = bookmarks_bar;
self.parent_folder = parent_folder;
self.delegate = self;
m_menu = menu.make_weak_ptr();
[self setAnimates:NO];
[self setBehavior:NSPopoverBehaviorTransient];
[self setValue:[NSNumber numberWithBool:YES] forKeyPath:@"shouldHideAnchor"];
auto* content_view = [[NSView alloc] initWithFrame:NSZeroRect];
auto* scroll_view = [[NSScrollView alloc] initWithFrame:NSZeroRect];
[scroll_view setHasVerticalScroller:YES];
[scroll_view setDrawsBackground:NO];
[scroll_view setBorderType:NSNoBorder];
auto width = BOOKMARK_FOLDER_WIDTH;
auto height = (BOOKMARK_FOLDER_VERTICAL_PADDING * 2) + (BOOKMARK_FOLDER_ROW_HEIGHT * menu.size());
auto* items_view = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, width, height)];
auto y = height - BOOKMARK_FOLDER_VERTICAL_PADDING;
for (auto const& item : menu.items()) {
auto* item_view = item.visit(
[&](NonnullRefPtr<WebView::Action> const& action) -> NSView* {
return [[BookmarkFolderItemView alloc] initForBookmark:*action
bookmarksBar:self.bookmarks_bar
parentFolder:self];
},
[&](NonnullRefPtr<WebView::Menu> const& submenu) -> NSView* {
return [[BookmarkFolderItemView alloc] initForSubfolder:*submenu
bookmarksBar:self.bookmarks_bar
parentFolder:self];
},
[&](WebView::Separator) -> NSView* {
VERIFY_NOT_REACHED();
});
y -= BOOKMARK_FOLDER_ROW_HEIGHT;
[item_view setFrame:NSMakeRect(0, y, width, BOOKMARK_FOLDER_ROW_HEIGHT)];
[items_view addSubview:item_view];
}
auto visible_height = AK::min(height, BOOKMARK_FOLDER_MAX_HEIGHT);
[content_view setFrame:NSMakeRect(0, 0, width, visible_height)];
[scroll_view setFrame:NSMakeRect(0, 0, width, visible_height)];
[scroll_view setHasVerticalScroller:(height > visible_height)];
[scroll_view setDocumentView:items_view];
[content_view addSubview:scroll_view];
auto* controller = [[NSViewController alloc] init];
[controller setView:content_view];
[self setContentViewController:controller];
[self setContentSize:NSMakeSize(width, visible_height)];
}
return self;
}
- (void)showRelativeToView:(NSView*)view preferredEdge:(NSRectEdge)preferred_edge
{
auto rect = [view bounds];
if (preferred_edge == NSRectEdgeMaxX) {
rect = NSMakeRect(
NSWidth(rect) - BOOKMARK_FOLDER_HORIZONTAL_OVERLAP - BOOKMARK_FOLDER_SUBMENU_LEFT_SHIFT,
0,
BOOKMARK_FOLDER_HORIZONTAL_OVERLAP,
NSHeight(rect));
}
[self showRelativeToRect:rect ofView:view preferredEdge:preferred_edge];
if (preferred_edge == NSRectEdgeMaxY) {
if (auto* window = [self.contentViewController.view window]) {
auto origin = [window frame].origin;
origin.y += BOOKMARK_FOLDER_ROOT_VERTICAL_SHIFT;
[window setFrameOrigin:origin];
}
}
}
- (void)openChildFolder:(WebView::Menu&)menu relativeToView:(NSView*)view
{
if (menu.size() == 0) {
[self closeChildFolder];
return;
}
[self.child_folder close];
self.child_folder = [[BookmarkFolderPopover alloc] init:menu bookmarksBar:self.bookmarks_bar parentFolder:self];
[self.child_folder showRelativeToView:view preferredEdge:NSRectEdgeMaxX];
}
- (void)closeChildFolder
{
[self.child_folder close];
self.child_folder = nil;
}
- (void)close
{
[self.child_folder close];
self.child_folder = nil;
[super close];
}
#pragma mark - NSPopoverDelegate
- (void)popoverDidClose:(NSNotification*)notification
{
[self.child_folder close];
self.child_folder = nil;
if (self.parent_folder)
self.parent_folder.child_folder = nil;
else
[self.bookmarks_bar bookmarkFolderDidClose:self];
}
@end

View File

@@ -8,10 +8,15 @@
#import <Cocoa/Cocoa.h>
@class BookmarkFolderPopover;
@interface BookmarksBar : NSView
- (instancetype)init;
- (void)rebuild;
- (void)closeBookmarkFolders;
- (void)bookmarkFolderDidClose:(BookmarkFolderPopover*)folder;
@end

View File

@@ -5,11 +5,12 @@
*/
#include <LibWebView/Application.h>
#include <LibWebView/Menu.h>
#import <Interface/BookmarkFolder.h>
#import <Interface/BookmarksBar.h>
#import <Interface/Menu.h>
#import <Utilities/Conversions.h>
#import <objc/runtime.h>
#if !__has_feature(objc_arc)
# error "This project requires ARC"
@@ -20,11 +21,31 @@ static constexpr CGFloat const BOOKMARK_ITEM_SPACING = 2;
static constexpr CGFloat const BOOKMARK_LEADING_INSET = 8;
static constexpr CGFloat const OVERFLOW_TRAILING_INSET = 4;
static char BOOKMARK_FOLDER_KEY = 0;
static Optional<WebView::Menu&> find_bookmark_folder_by_id(WebView::Menu& menu, StringView id)
{
for (auto& item : menu.items()) {
auto* submenu_ptr = item.get_pointer<NonnullRefPtr<WebView::Menu>>();
if (!submenu_ptr)
continue;
auto& submenu = **submenu_ptr;
if (auto submenu_id = submenu.properties().get("id"sv); submenu_id.has_value() && *submenu_id == id)
return submenu;
if (auto descendant = find_bookmark_folder_by_id(submenu, id); descendant.has_value())
return descendant;
}
return {};
}
@interface BookmarksBar ()
@property (nonatomic, strong) NSStackView* bookmark_items;
@property (nonatomic, strong) BookmarkFolderPopover* bookmark_folder_popover;
@property (nonatomic, weak) NSButton* active_bookmark_folder_button;
@property (nonatomic, strong) NSButton* overflow_button;
@property (nonatomic, strong) NSMenu* overflow_menu;
@@ -86,6 +107,7 @@ static char BOOKMARK_FOLDER_KEY = 0;
- (void)rebuild
{
[self closeBookmarkFolders];
[self.bookmark_items setSubviews:@[]];
auto set_button_properties = [](NSButton* button, StringView title) {
@@ -119,9 +141,6 @@ static char BOOKMARK_FOLDER_KEY = 0;
action:@selector(openFolder:)];
set_button_properties(button, folder->title());
auto* submenu = Ladybird::create_application_menu(folder);
objc_setAssociatedObject(button, &BOOKMARK_FOLDER_KEY, submenu, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
return button;
},
[](WebView::Separator) -> NSButton* {
@@ -189,13 +208,61 @@ static char BOOKMARK_FOLDER_KEY = 0;
- (void)openFolder:(NSButton*)sender
{
NSMenu* folder = objc_getAssociatedObject(sender, &BOOKMARK_FOLDER_KEY);
if (!folder)
auto* item_id = Ladybird::get_control_property(sender, @"id");
if (!item_id)
return;
[folder popUpMenuPositioningItem:nil
atLocation:NSMakePoint(0, [sender bounds].size.height)
inView:sender];
auto id = Ladybird::ns_string_to_string(item_id);
auto folder = find_bookmark_folder_by_id(WebView::Application::the().bookmarks_menu(), id);
if (!folder.has_value())
return;
[self openFolderMenu:*folder anchoredToView:sender preferredEdge:NSRectEdgeMaxY];
}
- (void)openFolderMenu:(WebView::Menu&)menu
anchoredToView:(NSView*)view
preferredEdge:(NSRectEdge)preferredEdge
{
if (menu.size() == 0)
return;
[self closeBookmarkFolders];
if ([view isKindOfClass:[NSButton class]]) {
self.active_bookmark_folder_button = (NSButton*)view;
[self.active_bookmark_folder_button setShowsBorderOnlyWhileMouseInside:NO];
[self.active_bookmark_folder_button highlight:YES];
}
self.bookmark_folder_popover = [[BookmarkFolderPopover alloc] init:menu bookmarksBar:self parentFolder:nil];
[self.bookmark_folder_popover showRelativeToView:view preferredEdge:preferredEdge];
}
- (void)closeBookmarkFolders
{
[self.bookmark_folder_popover close];
self.bookmark_folder_popover = nil;
[self clearActiveBookmarkFolder];
}
- (void)bookmarkFolderDidClose:(BookmarkFolderPopover*)folder
{
if (self.bookmark_folder_popover == folder)
self.bookmark_folder_popover = nil;
[self clearActiveBookmarkFolder];
}
- (void)clearActiveBookmarkFolder
{
if (!self.active_bookmark_folder_button)
return;
[self.active_bookmark_folder_button highlight:NO];
[self.active_bookmark_folder_button setShowsBorderOnlyWhileMouseInside:YES];
self.active_bookmark_folder_button = nil;
}
- (void)layout

View File

@@ -22,6 +22,7 @@ NSMenu* create_context_menu(LadybirdWebView*, WebView::Menu&);
NSMenuItem* create_application_menu_item(WebView::Action&);
NSButton* create_application_button(WebView::Action&);
NSImageView* create_application_icon(WebView::Action&);
void set_control_image(id control, NSString*);

View File

@@ -167,7 +167,7 @@ static NSImage* image_from_base64_png(StringView favicon_base64_png)
return image;
}
static void initialize_native_control(WebView::Action& action, id control)
static void initialize_native_icon(WebView::Action& action, id control)
{
switch (action.id()) {
case WebView::ActionID::NavigateBack:
@@ -306,6 +306,11 @@ static void initialize_native_control(WebView::Action& action, id control)
default:
break;
}
}
static void initialize_native_control(WebView::Action& action, id control)
{
initialize_native_icon(action, control);
auto observer = ActionObserver::create(action, control);
@@ -391,9 +396,17 @@ NSButton* create_application_button(WebView::Action& action)
{
auto* button = [[NSButton alloc] init];
initialize_native_control(action, button);
set_properties(button, action);
return button;
}
NSImageView* create_application_icon(WebView::Action& action)
{
auto* icon = [[NSImageView alloc] initWithFrame:NSZeroRect];
initialize_native_icon(action, icon);
return icon;
}
void set_control_image(id control, NSString* image)
{
// System symbols are distributed with the San Fransisco (SF) Symbols font. To see all SF Symbols and their names,