diff --git a/UI/AppKit/CMakeLists.txt b/UI/AppKit/CMakeLists.txt index 2e34f45219..7264966cdd 100644 --- a/UI/AppKit/CMakeLists.txt +++ b/UI/AppKit/CMakeLists.txt @@ -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 diff --git a/UI/AppKit/Interface/BookmarkFolder.h b/UI/AppKit/Interface/BookmarkFolder.h new file mode 100644 index 0000000000..1d677e4fd8 --- /dev/null +++ b/UI/AppKit/Interface/BookmarkFolder.h @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include + +#import + +@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 diff --git a/UI/AppKit/Interface/BookmarkFolder.mm b/UI/AppKit/Interface/BookmarkFolder.mm new file mode 100644 index 0000000000..e4b5b2684a --- /dev/null +++ b/UI/AppKit/Interface/BookmarkFolder.mm @@ -0,0 +1,358 @@ +/* + * Copyright (c) 2026, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include + +#import +#import +#import +#import +#import + +#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 m_action; + WeakPtr 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 () + +@property (nonatomic, strong) BookmarkFolderPopover* child_folder; +@property (nonatomic, weak) BookmarkFolderPopover* parent_folder; +@property (nonatomic, weak) BookmarksBar* bookmarks_bar; + +@end + +@implementation BookmarkFolderPopover +{ + WeakPtr 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 const& action) -> NSView* { + return [[BookmarkFolderItemView alloc] initForBookmark:*action + bookmarksBar:self.bookmarks_bar + parentFolder:self]; + }, + [&](NonnullRefPtr 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 diff --git a/UI/AppKit/Interface/BookmarksBar.h b/UI/AppKit/Interface/BookmarksBar.h index adeb878629..903398e165 100644 --- a/UI/AppKit/Interface/BookmarksBar.h +++ b/UI/AppKit/Interface/BookmarksBar.h @@ -8,10 +8,15 @@ #import +@class BookmarkFolderPopover; + @interface BookmarksBar : NSView - (instancetype)init; - (void)rebuild; +- (void)closeBookmarkFolders; +- (void)bookmarkFolderDidClose:(BookmarkFolderPopover*)folder; + @end diff --git a/UI/AppKit/Interface/BookmarksBar.mm b/UI/AppKit/Interface/BookmarksBar.mm index 2efd1200d3..84246cb34e 100644 --- a/UI/AppKit/Interface/BookmarksBar.mm +++ b/UI/AppKit/Interface/BookmarksBar.mm @@ -5,11 +5,12 @@ */ #include +#include +#import #import #import #import -#import #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 find_bookmark_folder_by_id(WebView::Menu& menu, StringView id) +{ + for (auto& item : menu.items()) { + auto* submenu_ptr = item.get_pointer>(); + 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 diff --git a/UI/AppKit/Interface/Menu.h b/UI/AppKit/Interface/Menu.h index f4926aa3b9..0b565a01f2 100644 --- a/UI/AppKit/Interface/Menu.h +++ b/UI/AppKit/Interface/Menu.h @@ -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*); diff --git a/UI/AppKit/Interface/Menu.mm b/UI/AppKit/Interface/Menu.mm index a93a9cd9ad..639d346b40 100644 --- a/UI/AppKit/Interface/Menu.mm +++ b/UI/AppKit/Interface/Menu.mm @@ -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,