From 35db02f5540433c1309e91701c43abed16b1a4eb Mon Sep 17 00:00:00 2001 From: Jose Rago <13522179+ragojose@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:46:08 -0300 Subject: [PATCH 1/3] =?UTF-8?q?Add=20=E2=8C=98K=20command=20palette=20with?= =?UTF-8?q?=20Liquid=20Glass,=20replacing=20the=20tab=20search=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ⇧⌘A tab search dropdown becomes a centered cmdk-style palette (src/bro_palette.mm): one fuzzy-ranked list over open tabs, recently closed tabs, browsing history, and app commands derived from the main menu, with a pinned search-the-web / go-to-URL fallback row reusing the address bar's heuristic (extracted as BroResolveQueryToURL). ⌘K opens the full palette; ⇧⌘A and the strip's search button open it scoped to tabs. Tab/Shift-Tab cycle the selection and keyboard moves are announced to VoiceOver. The backdrop is NSGlassEffectView tinted near #000 on macOS 26+, with an NSVisualEffectView approximation below. The strip search button matches the toolbar buttons (28pt, magnifying glass) and the four trailing icons share equal spacing; search and downloads hide at rest and reveal on hover over their zone, with the download pulse routed through the reveal. Also lands the motion/design-system pass: BroTextMorphView animated tab titles, shared BroHoverHighlightGroup hover treatment across toolbar and downloads rows, BroApplyElevation/BroOverlayShow surfaces, and unified tab text metrics. Co-Authored-By: Claude Fable 5 --- CMakeLists.txt | 4 +- src/bro_downloads.mm | 110 +++- src/bro_mac.mm | 65 ++- src/bro_mac_internal.h | 52 +- src/bro_palette.mm | 1204 ++++++++++++++++++++++++++++++++++++++++ src/bro_tabsearch.mm | 622 --------------------- src/bro_tabstrip.mm | 249 +++++---- src/bro_text_morph.h | 19 + src/bro_text_morph.mm | 592 ++++++++++++++++++++ src/bro_toolbar.mm | 294 ++++++++-- 10 files changed, 2355 insertions(+), 856 deletions(-) create mode 100644 src/bro_palette.mm delete mode 100644 src/bro_tabsearch.mm create mode 100644 src/bro_text_morph.h create mode 100644 src/bro_text_morph.mm diff --git a/CMakeLists.txt b/CMakeLists.txt index 403e573..742d0c8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -72,8 +72,10 @@ set(BRO_SRCS src/bro_motion.mm src/bro_persist.h src/bro_persist.mm - src/bro_tabsearch.mm + src/bro_palette.mm src/bro_tabstrip.mm + src/bro_text_morph.h + src/bro_text_morph.mm src/bro_toolbar.mm src/bro_updater.h src/bro_updater.mm diff --git a/src/bro_downloads.mm b/src/bro_downloads.mm index 07a952c..6d56da3 100644 --- a/src/bro_downloads.mm +++ b/src/bro_downloads.mm @@ -7,6 +7,7 @@ #include "bro_handler.h" #import "bro_mac_internal.h" +#import "bro_motion.h" #import "radix_icons.h" #pragma mark - BroDownloadEntry @@ -52,6 +53,46 @@ @implementation BroDownloadEntry static const CGFloat kDownloadsFooterHeight = 36.0; static const CGFloat kDownloadsEmptyHeight = 52.0; +@interface BroDownloadRow : NSView +@property(nonatomic, assign) BOOL interactive; +@property(nonatomic, weak) BroHoverHighlightGroup* highlightGroup; +@property(nonatomic, weak) id target; +@property(nonatomic, assign) SEL action; +@property(nonatomic, assign) NSUInteger downloadIndex; +@end + +@implementation BroDownloadRow + +- (void)mouseEntered:(NSEvent*)event { + if (_interactive) { + [_highlightGroup hoverOnView:self]; + } +} + +- (void)mouseExited:(NSEvent*)event { + if (_interactive) { + [_highlightGroup hoverOffView:self]; + } +} + +- (void)resetCursorRects { + if (_interactive) { + [self addCursorRect:self.bounds cursor:[NSCursor pointingHandCursor]]; + } +} + +- (void)mouseDown:(NSEvent*)event { +} + +- (void)mouseUp:(NSEvent*)event { + NSPoint point = [self convertPoint:event.locationInWindow fromView:nil]; + if (_interactive && NSPointInRect(point, self.bounds)) { + [NSApp sendAction:_action to:_target from:self]; + } +} + +@end + @interface BroDownloadsPopover : NSView // Rebuilds the rows from the downloads list and resizes self to fit, showing // at most as many rows as fit in |maxHeight| (newest kept, oldest dropped). @@ -68,6 +109,7 @@ @implementation BroDownloadsPopover { // Entries backing the visible rows; magnifier buttons index into this. NSMutableArray* rowEntries_; CGFloat lastMaxHeight_; + BroHoverHighlightGroup* rowHighlightGroup_; } static NSAttributedString* BroDownloadsButtonTitle(NSString* text, @@ -92,11 +134,9 @@ - (instancetype)initWithFrame:(NSRect)frame { if (self) { self.wantsLayer = YES; self.layer.cornerRadius = 8.0; - self.layer.backgroundColor = - [NSColor colorWithWhite:0.18 alpha:0.96].CGColor; - self.layer.borderWidth = 1.0; - self.layer.borderColor = - [NSColor colorWithWhite:1.0 alpha:kWindowBorderAlpha].CGColor; + BroApplyElevation(self, BroElevationPanel); + rowHighlightGroup_ = + [[BroHoverHighlightGroup alloc] initWithContainerView:self]; headerLabel_ = BroHoverCardLabel(BroUIFontBold(10.0), 0.55); headerLabel_.attributedStringValue = [[NSAttributedString alloc] @@ -153,6 +193,7 @@ - (void)mouseDown:(NSEvent*)event { - (void)reloadWithMaxHeight:(CGFloat)maxHeight { lastMaxHeight_ = maxHeight; + [rowHighlightGroup_ dismissImmediately]; for (NSView* row in rowViews_) { [row removeFromSuperview]; } @@ -224,10 +265,28 @@ - (void)reloadWithMaxHeight:(CGFloat)maxHeight { } - (NSView*)makeRowForEntry:(BroDownloadEntry*)entry index:(NSUInteger)index { - NSView* row = [[NSView alloc] initWithFrame:NSZeroRect]; + BroDownloadRow* row = [[BroDownloadRow alloc] initWithFrame:NSZeroRect]; BOOL fileExists = entry.path.length > 0 && [[NSFileManager defaultManager] fileExistsAtPath:entry.path]; + row.interactive = entry.state == BroDownloadStateComplete && fileExists; + row.highlightGroup = rowHighlightGroup_; + row.target = self; + row.action = @selector(openEntry:); + row.downloadIndex = index; + if (row.interactive) { + row.accessibilityElement = YES; + row.accessibilityRole = NSAccessibilityButtonRole; + row.accessibilityLabel = + [NSString stringWithFormat:@"Open %@", entry.name ?: @"download"]; + NSTrackingArea* trackingArea = [[NSTrackingArea alloc] + initWithRect:NSZeroRect + options:NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways | + NSTrackingInVisibleRect + owner:row + userInfo:nil]; + [row addTrackingArea:trackingArea]; + } NSImageView* icon = [[NSImageView alloc] initWithFrame:NSMakeRect(12.0, 8.0, 28.0, 28.0)]; @@ -297,6 +356,20 @@ - (NSView*)makeRowForEntry:(BroDownloadEntry*)entry index:(NSUInteger)index { return row; } +- (void)openEntry:(BroDownloadRow*)sender { + NSUInteger index = sender.downloadIndex; + if (index >= rowEntries_.count) { + return; + } + BroDownloadEntry* entry = rowEntries_[index]; + if (entry.state == BroDownloadStateComplete && entry.path.length > 0 && + [[NSFileManager defaultManager] fileExistsAtPath:entry.path]) { + [[NSWorkspace sharedWorkspace] openURL: + [NSURL fileURLWithPath:entry.path]]; + BroHideDownloadsPopover(); + } +} + - (NSString*)subtitleForEntry:(BroDownloadEntry*)entry fileExists:(BOOL)fileExists { switch (entry.state) { @@ -421,7 +494,7 @@ static void BroRemoveDownloadsMonitors(void) { // BroHideDownloadsPopover is declared extern in bro_mac_internal.h. void BroHideDownloadsPopover(void) { BroRemoveDownloadsMonitors(); - g_downloads_popover.hidden = YES; + BroOverlayHide(g_downloads_popover); } static void BroShowDownloadsPopover(void) { @@ -432,6 +505,7 @@ static void BroShowDownloadsPopover(void) { if (!g_downloads_popover) { g_downloads_popover = [[BroDownloadsPopover alloc] initWithFrame:NSZeroRect]; + g_downloads_popover.hidden = YES; // Tracks the top-right corner across window resizes. g_downloads_popover.autoresizingMask = NSViewMinXMargin | NSViewMinYMargin; @@ -441,7 +515,7 @@ static void BroShowDownloadsPopover(void) { [container addSubview:g_downloads_popover]; [g_downloads_popover reloadWithMaxHeight:NSHeight(container.bounds) - 12.0]; BroPositionDownloadsPopover(); - g_downloads_popover.hidden = NO; + BroOverlayShow(g_downloads_popover); if (!g_downloads_click_monitor) { g_downloads_click_monitor = BroInstallOutsideDismissMonitor( @@ -483,22 +557,12 @@ static void BroRefreshDownloadsPopover(void) { BroPositionDownloadsPopover(); } -// Brief dip-and-restore of the button's opacity when a download starts or -// finishes; enough of a nudge without stealing the pointer like an auto-shown -// panel would. +// Nudge when a download starts or finishes; enough without stealing the +// pointer like an auto-shown panel would. The toolbar owns the animation: +// the button hides at rest, so the pulse is a brief reveal (or an opacity +// dip when already revealed by hover). static void BroPulseDownloadsButton(void) { - BroHoverButton* button = g_toolbar.downloadsButton; - if (!button) { - return; - } - if ([NSWorkspace sharedWorkspace].accessibilityDisplayShouldReduceMotion) { - return; - } - button.alphaValue = 0.25; - [NSAnimationContext runAnimationGroup:^(NSAnimationContext* ctx) { - ctx.duration = 0.5; - button.animator.alphaValue = 1.0; - }]; + [g_toolbar pulseDownloadsButton]; } #pragma mark - Downloads model diff --git a/src/bro_mac.mm b/src/bro_mac.mm index 61cb3cd..f0cdc5f 100644 --- a/src/bro_mac.mm +++ b/src/bro_mac.mm @@ -21,6 +21,7 @@ #import "bro_mac_internal.h" #import "bro_closed_tabs.h" #import "bro_history.h" +#import "bro_motion.h" #import "bro_persist.h" // Forward declarations @@ -90,10 +91,10 @@ // of resetting to 50/50. Session-only. static CGFloat g_last_split_ratio = 0.5; -// CreateNewBrowserTab(WithURL), ToggleTabSearchPanel, and HideTabSearchPanel +// CreateNewBrowserTab(WithURL), ToggleCommandPalette, and HideCommandPalette // are declared extern in bro_mac_internal.h. -// Transient overlays (the tab search panel, the downloads popover) must not +// Transient overlays (the command palette, the downloads popover) must not // outlive a command that changes what's underneath them -- otherwise they're // left floating over content or chrome they no longer describe. Each overlay // used to hand-roll its own dismissal at a handful of call sites and they @@ -101,7 +102,7 @@ // changes page or layout state should dismiss through this one helper // instead of picking and choosing which overlay to hide. static void BroDismissTransientOverlays(void) { - HideTabSearchPanel(); + HideCommandPalette(); BroHideDownloadsPopover(); } @@ -160,13 +161,15 @@ BOOL BroURLIsBlank(NSString* url) { NSDictionary* BroLayerTransitionActions(void) { NSMutableDictionary* actions = [NSMutableDictionary dictionary]; for (NSString* key in - @[ @"borderColor", @"backgroundColor", @"borderWidth", @"transform" ]) { + @[ @"borderColor", @"backgroundColor", @"borderWidth" ]) { CABasicAnimation* fade = [CABasicAnimation animationWithKeyPath:key]; fade.duration = 0.15; fade.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseOut]; actions[key] = fade; } + actions[@"transform"] = + BroSpringForKeyPath(@"transform", BroSpringInteractive); return actions; } @@ -265,12 +268,18 @@ - (instancetype)init { // Tab strip inline in the toolbar row: after the nav buttons, before the // right-aligned viewport toggles. CGFloat stripX = kTrafficLightInset + 3 * (kButtonSize + kButtonSpacing) + 8.0; - CGFloat stripRight = frame.size.width - 12.0 - kButtonSize - kButtonSpacing - - kButtonSize - kButtonSpacing - 8.0 - kButtonSize - 16.0; + // The strip's right edge (where its pinned search button sits) keeps the + // same kButtonSpacing gap to the downloads/desktop/mobile buttons, so all + // four trailing icons are evenly spaced. + CGFloat stripRight = + frame.size.width - 12.0 - 3.0 * (kButtonSize + kButtonSpacing); _tabBar = [[BroTabBar alloc] initWithFrame:NSMakeRect(stripX, 0, stripRight - stripX, kToolbarHeight)]; [_navToolbar addSubview:_tabBar]; g_tab_bar = _tabBar; + // The toolbar's search/downloads reveal zone spans a tab-bar subview, so + // recompute it now that the tab bar exists. + [_navToolbar updateTrackingAreas]; // Create container for browser views (below chrome) _browserContainer = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, frame.size.width, toolbarY)]; @@ -926,8 +935,8 @@ static void ShowZoomHUD(int percent) { g_zoom_hud = [[NSView alloc] initWithFrame:NSZeroRect]; g_zoom_hud.wantsLayer = YES; g_zoom_hud.layer.cornerRadius = 8.0; - g_zoom_hud.layer.backgroundColor = - [NSColor colorWithWhite:0.15 alpha:0.92].CGColor; + BroApplyElevation(g_zoom_hud, BroElevationOverlay); + g_zoom_hud.hidden = YES; g_zoom_hud_label = [[NSTextField alloc] initWithFrame:NSZeroRect]; g_zoom_hud_label.editable = NO; @@ -960,8 +969,7 @@ static void ShowZoomHUD(int percent) { g_zoom_hud_label.frame = NSMakeRect(padX, padY, labelSize.width, labelSize.height); - g_zoom_hud.hidden = NO; - g_zoom_hud.alphaValue = 1.0; + BroOverlayShow(g_zoom_hud); [g_zoom_hud_timer invalidate]; g_zoom_hud_timer = @@ -969,17 +977,7 @@ static void ShowZoomHUD(int percent) { repeats:NO block:^(NSTimer* timer) { g_zoom_hud_timer = nil; - [NSAnimationContext - runAnimationGroup:^(NSAnimationContext* ctx) { - ctx.duration = 0.25; - g_zoom_hud.animator.alphaValue = 0.0; - } - completionHandler:^{ - // Skip hiding if another press restarted the HUD mid-fade. - if (!g_zoom_hud_timer) { - g_zoom_hud.hidden = YES; - } - }]; + BroOverlayHide(g_zoom_hud); }]; } @@ -1327,6 +1325,9 @@ - (void)setupMainMenu { [fileMenu addItemWithTitle:@"Open Location..." action:@selector(focusAddressBar:) keyEquivalent:@"l"]; + [fileMenu addItemWithTitle:@"Command Palette…" + action:@selector(showCommandPalette:) + keyEquivalent:@"k"]; fileMenuItem.submenu = fileMenu; [mainMenu addItem:fileMenuItem]; @@ -1666,8 +1667,14 @@ - (void)toggleSplitScreen:(id)sender { [g_tab_bar splitActiveTabWithNextTab]; } +- (void)showCommandPalette:(id)sender { + ToggleCommandPalette(BroPaletteScopeAll); +} + +// ⇧⌘A and the tab strip's chevron keep their tab-search identity; they open +// the same palette pre-scoped to tabs. - (void)showTabSearch:(id)sender { - ToggleTabSearchPanel(); + ToggleCommandPalette(BroPaletteScopeTabs); } // The delegate is the responder-chain target for every no-target menu item, @@ -1787,10 +1794,10 @@ - (BOOL)windowShouldClose:(NSWindow*)sender { } - (void)windowWillClose:(NSNotification*)notification { - HideTabSearchPanel(); - // The retained panel belongs to the dying window's view tree; rebuild it + HideCommandPalette(); + // The retained palette belongs to the dying window's view tree; rebuild it // fresh for any future window. - TeardownTabSearchPanel(); + TeardownCommandPalette(); g_main_window = nil; g_toolbar = nil; g_tab_bar = nil; @@ -2053,10 +2060,10 @@ void OnTabLoadingChanged(int browser_id, bool is_loading) { void OnTabClosed(int browser_id) { dispatch_async(dispatch_get_main_queue(), ^{ - // The panel's open-tab rows are now stale; it also self-hides after + // The palette's open-tab rows are now stale; it also self-hides after // reopening a closed tab (which lands here for the departing tab's id // only on real closes, not reopens — either way hiding is safe). - HideTabSearchPanel(); + HideCommandPalette(); // Remove from tab bar if (g_tab_bar) { // Record the tab for Reopen Closed Tab and the tab search panel's @@ -2109,10 +2116,10 @@ void OnTabClosed(int browser_id) { void OnActiveTabChanged(int browser_id) { BroRunOnMain(^{ - // Any tab switch dismisses the panel: switches from inside it hide + // Any tab switch dismisses the palette: switches from inside it hide // explicitly too (idempotent), and outside ones (⌘1-9, Ctrl+Tab) would // otherwise leave it floating over the new page. - HideTabSearchPanel(); + HideCommandPalette(); // Selecting the right pane swaps panes instead of ending the split: the // outgoing active tab takes the right slot and the clicked tab becomes // the active/left one, so the address bar and shortcuts follow it. diff --git a/src/bro_mac_internal.h b/src/bro_mac_internal.h index 67dc14b..be7a218 100644 --- a/src/bro_mac_internal.h +++ b/src/bro_mac_internal.h @@ -5,7 +5,7 @@ // Private shared header for the bro_mac.mm split family (bro_mac.mm, // bro_favicon.mm, bro_downloads.mm, bro_toolbar.mm, bro_closed_tabs.mm, -// bro_tabsearch.mm, bro_tabstrip.mm). Declares the cross-file globals, the +// bro_palette.mm, bro_tabstrip.mm). Declares the cross-file globals, the // cross-file free functions, and the @class/@interface declarations that // must be visible across the family. Not a public API -- nothing outside // this file family should include it. @@ -21,6 +21,8 @@ @class BroTabView; @class BroTabBar; @class BroHoverButton; +@class BroHoverHighlightGroup; +@class BroTextMorphView; // Layout/timing constants shared across the split family. Each file gets // its own copy (harmless for compile-time constants); kWindowBorderAlpha is @@ -41,6 +43,11 @@ static const CGFloat kTabPillTextMinWidth = 64.0; // favicon and title. static const CGFloat kTabPillCloseMinWidth = 80.0; static const CGFloat kTabPillHeight = 28.0; +// Every tab-text state shares these metrics: inactive morph, active resting +// morph, and the editable address field. Keeping them tokenized prevents a +// tab switch from changing either font size or baseline geometry. +static const CGFloat kTabTextFontSize = 10.0; +static const CGFloat kTabTextFrameHeight = 18.0; // A collapsed pill is exactly square. static const CGFloat kTabPillSquareWidth = kTabPillHeight; // The "+" button is half the pill's size, centered on the same midline. @@ -104,8 +111,8 @@ extern NSView* BroBrowserContainerView(void); extern void BroRunOnMain(void (^block)(void)); // Installs a local mouse-down monitor that hides `panel` on any click outside -// both `panel` and its owner button. See bro_mac.mm's prior definition site -// for the full contract; behavior is unchanged by the move. +// both `panel` and its owner button. See the definition in bro_palette.mm +// for the full contract. extern id BroInstallOutsideDismissMonitor(NSView* panel, BOOL (^isVisible)(void), NSView* (^ownerButton)(void), @@ -113,7 +120,7 @@ extern id BroInstallOutsideDismissMonitor(NSView* panel, // Fetches `urlString`'s favicon and hands the result to `applyImage`, but // only if `stillCurrent` still says `generation` is current when the fetch -// completes. Shared by BroTabView and BroTabSearchRow, which each bump their +// completes. Shared by BroTabView and BroPaletteRow, which each bump their // own generation counter before calling this (a slow response for an // already-superseded favicon must not overwrite a newer one). extern void BroFetchFaviconGuarded(NSString* urlString, @@ -131,6 +138,12 @@ extern void BroToggleDownloadsPopover(void); // state, and never show the raw "about:blank" in the chrome. extern BOOL BroURLIsBlank(NSString* url); +// Resolves user input into a loadable URL: safe schemes and about: pass +// through, a dotted word gets https://, anything else becomes a Google +// search URL. |query| must be trimmed; returns nil when empty. Defined in +// bro_toolbar.mm; shared with the command palette's fallback row. +extern NSString* BroResolveQueryToURL(NSString* query); + // Display-only host for a URL: the host with any leading "www." removed. // Falls back to the raw string when the URL has no parseable host. Never used // for navigation; BroToolbar.fullURL / BroTabView.tabURL keep the canonical @@ -172,12 +185,17 @@ extern void ClearSplit(void); extern void JoinTabsInSplit(int dragged_id, int target_id); extern NSUInteger BroTabStripIndex(int browser_id); -// Tab search panel (⇧⌘A / the tab strip's chevron): toggle shows or hides -// the dropdown; hide is safe to call any time. Defined with the panel class. -extern void ToggleTabSearchPanel(void); -extern void HideTabSearchPanel(void); -// Releases the retained panel; called when the main window is torn down. -extern void TeardownTabSearchPanel(void); +// Command palette (⌘K; ⇧⌘A and the tab strip's chevron open it scoped to +// tabs): toggle shows or hides the centered panel; hide is safe to call any +// time. Defined with the panel class in bro_palette.mm. +typedef NS_ENUM(NSInteger, BroPaletteScope) { + BroPaletteScopeAll, // tabs + history + commands + web-search fallback + BroPaletteScopeTabs, // open + recently closed tabs only +}; +extern void ToggleCommandPalette(BroPaletteScope scope); +extern void HideCommandPalette(void); +// Releases the retained palette; called when the main window is torn down. +extern void TeardownCommandPalette(void); // Implicit-animation actions so state changes (hover/focus/active) fade // instead of snapping. Backing layers suppress implicit animations by @@ -207,6 +225,9 @@ extern NSDictionary* BroLayerTransitionActions(void); // Resting border, restored when the white keyboard-focus ring goes away. @property (nonatomic, assign) CGFloat baseBorderWidth; @property (nonatomic, strong) NSColor* baseBorderColor; +// Optional shared hover preview. Grouped buttons keep pressed/selected +// feedback but let the group own hover fill and hover motion. +@property (nonatomic, weak) BroHoverHighlightGroup* highlightGroup; @end // Builds a dimmed, non-editable NSTextField used by the hover card, the @@ -227,7 +248,7 @@ extern NSTextField* BroHoverCardLabel(NSFont* font, CGFloat whiteAlpha); @property (nonatomic, assign) int browserId; @property (nonatomic, strong) NSImageView* faviconView; @property (nonatomic, strong) NSProgressIndicator* loadingSpinner; -@property (nonatomic, strong) NSTextField* titleLabel; +@property (nonatomic, strong) BroTextMorphView* titleLabel; @property (nonatomic, strong) NSButton* closeButton; @property (nonatomic, assign) BOOL isActive; @property (nonatomic, assign) BOOL isLoading; @@ -340,10 +361,19 @@ extern BroTabBar* g_tab_bar; @property (nonatomic, strong) BroHoverButton* mobileButton; @property (nonatomic, strong) BroHoverButton* downloadsButton; @property (nonatomic, copy) NSString* fullURL; +@property (nonatomic, strong) BroHoverHighlightGroup* navigationHighlightGroup; +@property (nonatomic, strong) BroHoverHighlightGroup* trailingHighlightGroup; - (void)addressFieldDidFocus; - (void)setViewportMode:(BOOL)mobile; - (void)updateURL:(NSString*)url; - (void)focusAddressField; +// Resolves |urlString| via BroResolveQueryToURL and loads it in the active +// browser; also used by the command palette's fallback row. +- (void)navigateToURL:(NSString*)urlString; +// Download started/finished nudge: reveals the hidden search + downloads +// icons briefly (they hide at rest and show on hover; the toolbar owns the +// shared hover zone). Called from bro_downloads.mm. +- (void)pulseDownloadsButton; // Menu-action forwarders; called on g_toolbar from BroWindow/BroAppDelegate // and the UI-update bridge functions. - (void)goBack:(id)sender; diff --git a/src/bro_palette.mm b/src/bro_palette.mm new file mode 100644 index 0000000..f6889f7 --- /dev/null +++ b/src/bro_palette.mm @@ -0,0 +1,1204 @@ +// Copyright (c) 2013 The Chromium Embedded Framework Authors. +// Portions copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// The command palette (⌘K, or ⇧⌘A / the tab strip's chevron for the +// tabs-only scope): a centered floating panel with a search field over one +// mixed, fuzzy-filtered list — open tabs, recently closed tabs, app +// commands (derived from the main menu), browsing history, and a pinned +// "search the web / go to URL" fallback row. Same overlay recipe as the +// downloads popover: hand-rolled, re-added last to the browser container on +// every show, retained and hidden rather than destroyed. + +#include "bro_handler.h" +#import "bro_mac_internal.h" +#import "bro_closed_tabs.h" +#import "bro_history.h" +#import "bro_motion.h" +#import "radix_icons.h" + +#include + +#pragma mark - Layout constants + +static const CGFloat kPaletteWidth = 560.0; +static const CGFloat kPaletteRowHeight = 40.0; +static const CGFloat kPaletteHeaderHeight = 24.0; +// The list scrolls past this; the search row above never scrolls away. +static const CGFloat kPaletteListMaxHeight = 400.0; +static const CGFloat kPaletteFieldRowHeight = 48.0; +static const CGFloat kPalettePadX = 8.0; +// Distance from the top of the browser container to the panel's top edge, +// which stays fixed while filtering changes the height below it. +static const CGFloat kPaletteTopOffset = 48.0; + +// Per-section result caps while a query is active. Tabs are never capped: +// every open tab that matches is reachable. +static const NSUInteger kPaletteMaxClosed = 5; +static const NSUInteger kPaletteMaxCommands = 6; +static const NSUInteger kPaletteMaxHistory = 8; +// Tighter history cap for the browse state shown before any typing. +static const NSUInteger kPaletteMaxHistoryEmptyQuery = 5; + +#pragma mark - Fuzzy matcher + +// Lowercased UTF-16 code units for the matcher. Built once per item at +// snapshot time so per-keystroke scoring never touches NSString. +static std::u16string BroLowercaseUTF16(NSString* string) { + NSString* lower = string.lowercaseString; + std::u16string out; + out.resize(lower.length); + if (!out.empty()) { + [lower getCharacters:reinterpret_cast(&out[0]) + range:NSMakeRange(0, lower.length)]; + } + return out; +} + +static inline bool BroIsWordSeparator(char16_t c) { + return c == u' ' || c == u'/' || c == u'.' || c == u'-' || c == u'_' || + c == u':'; +} + +// Case-insensitive subsequence match of |needle| in |haystack| (both +// pre-lowercased). Greedy single forward pass: at these string lengths a +// full alignment search (Smith-Waterman style) rarely ranks differently and +// costs far more. Returns false on a miss; on a hit writes a score that +// rewards start-of-string and word-boundary hits and consecutive runs, and +// mildly penalizes gaps. +static bool BroFuzzyMatch(const std::u16string& needle, + const std::u16string& haystack, + double* outScore) { + if (needle.empty()) { + *outScore = 0.0; + return true; + } + double score = 0.0; + double gapPenalty = 0.0; + double leadPenalty = 0.0; + size_t searchFrom = 0; + size_t prevMatch = std::u16string::npos; + for (char16_t c : needle) { + size_t found = haystack.find(c, searchFrom); + if (found == std::u16string::npos) { + return false; + } + score += 1.0; + if (found == 0) { + score += 8.0; + } else if (BroIsWordSeparator(haystack[found - 1])) { + score += 6.0; + } + if (prevMatch == std::u16string::npos) { + leadPenalty = 0.02 * (double)found; + } else if (found == prevMatch + 1) { + score += 5.0; + } else { + gapPenalty += 0.05 * (double)(found - prevMatch - 1); + } + prevMatch = found; + searchFrom = found + 1; + } + score -= MIN(gapPenalty, 6.0); + score -= MIN(leadPenalty, 6.0); + *outScore = score; + return true; +} + +#pragma mark - BroPaletteItem + +typedef NS_ENUM(NSInteger, BroPaletteItemKind) { + BroPaletteItemTab, + BroPaletteItemClosedTab, + BroPaletteItemCommand, + BroPaletteItemHistory, + BroPaletteItemFallback, +}; + +// One palette candidate. Snapshotted on show; keystrokes only re-score and +// re-sort, so the haystacks are cached lowercased UTF-16 built exactly once. +@interface BroPaletteItem : NSObject { + @public + std::u16string titleHaystack_; + std::u16string urlHaystack_; // empty for commands +} +@property (nonatomic, assign) BroPaletteItemKind kind; +@property (nonatomic, copy) NSString* title; +@property (nonatomic, copy) NSString* subtitle; // URL host, or ⌘-hint +// Match score plus the item's constant boost; set by scoring each keystroke. +@property (nonatomic, assign) double score; +// Constant part of the score: kind boost plus (for history) recency and +// visit-count bonuses. Computed once at snapshot time. +@property (nonatomic, assign) double boost; +// Payloads — exactly one is meaningful per kind. +@property (nonatomic, assign) int browserId; // tab +@property (nonatomic, strong) BroClosedTabEntry* closedEntry; +@property (nonatomic, strong) NSMenuItem* menuItem; // command +@property (nonatomic, copy) NSString* historyURL; +@property (nonatomic, copy) NSString* fallbackQuery; +// Icon: a static template icon, a favicon URL to fetch, or a live pill's +// image view to copy from. At most one is set. +@property (nonatomic, strong) NSImage* icon; +@property (nonatomic, copy) NSString* faviconURL; +@property (nonatomic, weak) NSImageView* adoptFaviconFrom; +@end + +@implementation BroPaletteItem +- (instancetype)init { + self = [super init]; + if (self) { + _browserId = -1; + } + return self; +} + +- (void)cacheHaystacksWithURL:(NSString*)url { + titleHaystack_ = BroLowercaseUTF16(_title); + urlHaystack_ = BroLowercaseUTF16(url); +} +@end + +// Scores |item| against |needle|, writing the combined score. Returns false +// when neither the title nor the URL matches. The URL match is discounted so +// a title hit of equal quality wins. +static bool BroPaletteScoreItem(BroPaletteItem* item, + const std::u16string& needle) { + double titleScore = 0.0; + double urlScore = 0.0; + bool titleHit = BroFuzzyMatch(needle, item->titleHaystack_, &titleScore); + bool urlHit = !item->urlHaystack_.empty() && + BroFuzzyMatch(needle, item->urlHaystack_, &urlScore); + if (!titleHit && !urlHit) { + return false; + } + double match = MAX(titleHit ? titleScore : -HUGE_VAL, + urlHit ? 0.9 * urlScore : -HUGE_VAL); + item.score = match + item.boost; + return true; +} + +#pragma mark - Snapshot builders + +static NSString* BroPaletteTabTitle(BroTabView* tab) { + if (tab.pageTitle.length > 0) { + return tab.pageTitle; + } + if (!BroURLIsBlank(tab.tabURL)) { + return BroDisplayHostForURL(tab.tabURL); + } + return kBroBlankTabTitle; +} + +static NSArray* BroPaletteTabItems(void) { + NSMutableArray* items = [NSMutableArray array]; + for (BroTabView* tab in g_tab_bar.tabs) { + BroPaletteItem* item = [[BroPaletteItem alloc] init]; + item.kind = BroPaletteItemTab; + item.title = BroPaletteTabTitle(tab); + item.subtitle = BroURLIsBlank(tab.tabURL) + ? @"" + : BroDisplayHostForURL(tab.tabURL); + item.browserId = tab.browserId; + item.adoptFaviconFrom = tab.faviconView; + item.boost = 100.0; + [item cacheHaystacksWithURL:tab.tabURL]; + [items addObject:item]; + } + return items; +} + +static NSArray* BroPaletteClosedItems(void) { + NSMutableArray* items = [NSMutableArray array]; + for (BroClosedTabEntry* entry in + [BroClosedTabsList() reverseObjectEnumerator]) { // newest first + BroPaletteItem* item = [[BroPaletteItem alloc] init]; + item.kind = BroPaletteItemClosedTab; + item.title = entry.title.length > 0 ? entry.title + : BroDisplayHostForURL(entry.url); + item.subtitle = BroDisplayHostForURL(entry.url); + item.closedEntry = entry; + item.faviconURL = entry.faviconURL; + item.boost = 60.0; + [item cacheHaystacksWithURL:entry.url]; + [items addObject:item]; + } + return items; +} + +// Selectors whose menu items make no sense as palette commands: app/window +// bookkeeping, text-editing actions that target whatever field is focused, +// tab switching (the tabs section already covers it), the History menu's +// dynamic URL tail (the history section covers it), and the palette's own +// launchers. +static NSSet* BroPaletteExcludedSelectors(void) { + static NSSet* excluded = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + excluded = [NSSet setWithArray:@[ + @"terminate:", @"orderFrontStandardAboutPanel:", + @"performMiniaturize:", @"performZoom:", + @"undo:", @"redo:", @"cut:", @"copy:", @"paste:", @"selectAll:", + // AppKit injects these into the Edit menu automatically. + @"startDictation:", @"orderFrontCharacterPalette:", + @"selectTabAtIndex:", @"selectNextTab:", @"selectPreviousTab:", + @"historyItemSelected:", + @"showCommandPalette:", @"showTabSearch:", + ]]; + }); + return excluded; +} + +static NSImage* BroPaletteCommandIcon(SEL action) { + RadixIcon icon = RadixIconMagnifyingGlass; + if (action == @selector(newTab:)) { + icon = RadixIconPlus; + } else if (action == @selector(closeTab:)) { + icon = RadixIconCross2; + } else if (action == @selector(reloadPage:) || + action == @selector(hardReload:)) { + icon = RadixIconReload; + } else if (action == @selector(goBack:)) { + icon = RadixIconArrowLeft; + } else if (action == @selector(goForward:)) { + icon = RadixIconArrowRight; + } else if (action == @selector(toggleSplitScreen:)) { + icon = RadixIconViewVertical; + } else if (action == @selector(focusAddressBar:)) { + icon = RadixIconGlobe; + } + return RadixIconImage(icon, 15); +} + +// "⇧⌘A"-style display string for a menu item's key equivalent. +static NSString* BroPaletteKeyHint(NSMenuItem* item) { + if (item.keyEquivalent.length == 0) { + return @""; + } + NSMutableString* hint = [NSMutableString string]; + NSEventModifierFlags mask = item.keyEquivalentModifierMask; + if (mask & NSEventModifierFlagControl) [hint appendString:@"⌃"]; + if (mask & NSEventModifierFlagOption) [hint appendString:@"⌥"]; + if (mask & NSEventModifierFlagShift) [hint appendString:@"⇧"]; + if (mask & NSEventModifierFlagCommand) [hint appendString:@"⌘"]; + [hint appendString:item.keyEquivalent.uppercaseString]; + return hint; +} + +static void BroPaletteCollectCommands(NSMenu* menu, + NSMutableArray* out) { + for (NSMenuItem* menuItem in menu.itemArray) { + if (menuItem.submenu) { + BroPaletteCollectCommands(menuItem.submenu, out); + continue; + } + if (menuItem.separatorItem || menuItem.hidden || !menuItem.action) { + continue; + } + if ([BroPaletteExcludedSelectors() + containsObject:NSStringFromSelector(menuItem.action)]) { + continue; + } + id target = [NSApp targetForAction:menuItem.action + to:menuItem.target + from:nil]; + if (!target) { + continue; + } + // validateMenuItem: both filters and retitles in place (Pin/Unpin Tab, + // Split/Exit Split Screen), so it must run before the title is read. A + // target without it counts as enabled, matching NSMenu. + if ([target respondsToSelector:@selector(validateMenuItem:)] && + ![target validateMenuItem:menuItem]) { + continue; + } + BroPaletteItem* item = [[BroPaletteItem alloc] init]; + item.kind = BroPaletteItemCommand; + item.title = menuItem.title; + item.subtitle = BroPaletteKeyHint(menuItem); + item.menuItem = menuItem; + item.icon = BroPaletteCommandIcon(menuItem.action); + item.boost = 50.0; + [item cacheHaystacksWithURL:nil]; + [out addObject:item]; + } +} + +static NSArray* BroPaletteCommandItems(void) { + NSMutableArray* items = [NSMutableArray array]; + NSArray* topLevel = [NSApp mainMenu].itemArray; + // Walk the app menu (index 0: Check for Updates…) last so the browse state + // before typing leads with everyday commands (New Tab, Reload, ...). The + // Edit menu is skipped wholesale: its native items are all excluded + // text-editing actions, and macOS injects AutoFill/Dictation/Emoji entries + // there (in submenus, under private selectors) that make no sense as + // palette commands. + for (NSUInteger i = 1; i < topLevel.count; i++) { + if (topLevel[i].submenu && ![topLevel[i].title isEqualToString:@"Edit"]) { + BroPaletteCollectCommands(topLevel[i].submenu, items); + } + } + if (topLevel.count > 0 && topLevel[0].submenu) { + BroPaletteCollectCommands(topLevel[0].submenu, items); + } + return items; +} + +static NSArray* BroPaletteHistoryItems(void) { + // Skip URLs already offered by the tabs section. + NSMutableSet* openURLs = [NSMutableSet set]; + for (BroTabView* tab in g_tab_bar.tabs) { + if (tab.tabURL.length > 0) { + [openURLs addObject:tab.tabURL]; + } + } + NSTimeInterval now = [NSDate date].timeIntervalSince1970; + NSMutableArray* items = [NSMutableArray array]; + for (BroHistoryEntry* entry in + [BroHistoryEntries() reverseObjectEnumerator]) { // most recent first + if ([openURLs containsObject:entry.url]) { + continue; + } + BroPaletteItem* item = [[BroPaletteItem alloc] init]; + item.kind = BroPaletteItemHistory; + item.title = entry.title.length > 0 ? entry.title + : BroDisplayHostForURL(entry.url); + item.subtitle = BroDisplayHostForURL(entry.url); + item.historyURL = entry.url; + item.faviconURL = entry.faviconURL; + NSTimeInterval age = now - entry.lastVisit; + double recency = age < 24 * 3600 ? 15.0 : (age < 7 * 24 * 3600 ? 8.0 : 0.0); + double frequency = MIN(8.0, 2.0 * log2((double)entry.visitCount + 1.0)); + item.boost = recency + frequency; + [item cacheHaystacksWithURL:entry.url]; + [items addObject:item]; + } + return items; +} + +#pragma mark - BroPaletteRow + +@class BroPaletteRow; + +@protocol BroPaletteRowHoverDelegate +- (void)rowHoverBegan:(BroPaletteRow*)row; +@end + +// One row: icon/favicon + title, right-aligned dimmed subtitle, hover +// highlight, and a selection highlight driven by the panel's ↑/↓ navigation. +// A plain view rather than BroHoverButton: the hover scale reads wrong on a +// wide list row. +@interface BroPaletteRow : NSView +@property (nonatomic, strong) BroPaletteItem* item; +@property (nonatomic, assign) BOOL selected; +@property (nonatomic, weak) id hoverDelegate; +@property (nonatomic, weak) id target; +@property (nonatomic, assign) SEL action; +- (void)configureWithItem:(BroPaletteItem*)item; +@end + +@implementation BroPaletteRow { + NSImageView* iconView_; + NSTextField* titleLabel_; + NSTextField* subtitleLabel_; + // Same guard as BroTabView: a stale fetch must not overwrite the favicon + // this row was last configured with. Main-thread only. + NSUInteger faviconGeneration_; +} + +- (instancetype)initWithFrame:(NSRect)frame { + self = [super initWithFrame:frame]; + if (self) { + self.wantsLayer = YES; + self.layer.cornerRadius = 6.0; + + iconView_ = [[NSImageView alloc] + initWithFrame:NSMakeRect(12, (frame.size.height - 15) / 2.0, 15, 15)]; + iconView_.imageScaling = NSImageScaleProportionallyUpOrDown; + [self addSubview:iconView_]; + + titleLabel_ = BroHoverCardLabel(BroUIFont(13.0), 0.9); + [self addSubview:titleLabel_]; + + subtitleLabel_ = BroHoverCardLabel(BroUIFont(11.0), 0.4); + subtitleLabel_.alignment = NSTextAlignmentRight; + [self addSubview:subtitleLabel_]; + + self.accessibilityElement = YES; + self.accessibilityRole = NSAccessibilityButtonRole; + + NSTrackingArea* trackingArea = [[NSTrackingArea alloc] + initWithRect:NSZeroRect + options:NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways | + NSTrackingInVisibleRect + owner:self + userInfo:nil]; + [self addTrackingArea:trackingArea]; + } + return self; +} + +- (void)showPlaceholderFavicon { + iconView_.image = RadixIconImage(RadixIconGlobe, 15); + iconView_.contentTintColor = [NSColor colorWithWhite:0x33 / 255.0 alpha:1.0]; +} + +- (void)setFaviconURLString:(NSString*)urlString { + NSUInteger generation = ++faviconGeneration_; + [self showPlaceholderFavicon]; + if (urlString.length == 0) { + return; + } + __weak BroPaletteRow* weakSelf = self; + BroFetchFaviconGuarded( + urlString, generation, + ^BOOL(NSUInteger g) { + BroPaletteRow* strongSelf = weakSelf; + return strongSelf && strongSelf->faviconGeneration_ == g; + }, + ^(NSImage* image) { + BroPaletteRow* strongSelf = weakSelf; + strongSelf->iconView_.image = image; + strongSelf->iconView_.contentTintColor = nil; + }); +} + +- (void)configureWithItem:(BroPaletteItem*)item { + _item = item; + titleLabel_.stringValue = item.title ?: @""; + subtitleLabel_.stringValue = item.subtitle ?: @""; + // VoiceOver reads the subtitle (host or shortcut hint) with the title. + self.accessibilityLabel = + item.subtitle.length > 0 + ? [NSString stringWithFormat:@"%@, %@", item.title, item.subtitle] + : titleLabel_.stringValue; + + faviconGeneration_++; + if (item.icon) { + iconView_.image = item.icon; + iconView_.contentTintColor = [NSColor colorWithWhite:1.0 alpha:0.55]; + } else if (item.adoptFaviconFrom.image) { + iconView_.image = item.adoptFaviconFrom.image; + iconView_.contentTintColor = item.adoptFaviconFrom.contentTintColor; + } else if (item.faviconURL.length > 0) { + [self setFaviconURLString:item.faviconURL]; + } else { + [self showPlaceholderFavicon]; + } + + // Subtitle gets the width it needs (capped), title truncates around it. + [subtitleLabel_ sizeToFit]; + CGFloat rowWidth = NSWidth(self.frame); + CGFloat rowHeight = NSHeight(self.frame); + CGFloat subtitleWidth = MIN(NSWidth(subtitleLabel_.frame), 200.0); + subtitleLabel_.frame = + NSMakeRect(rowWidth - subtitleWidth - 12, + (rowHeight - 15) / 2.0, subtitleWidth, 15); + CGFloat titleX = 38; + CGFloat titleRightPad = subtitleWidth > 0 ? subtitleWidth + 20 : 12; + titleLabel_.frame = NSMakeRect(titleX, (rowHeight - 17) / 2.0, + rowWidth - titleX - titleRightPad, 17); +} + +- (void)setSelected:(BOOL)selected { + _selected = selected; + self.accessibilitySelected = selected; +} + +- (void)mouseEntered:(NSEvent*)event { + [_hoverDelegate rowHoverBegan:self]; +} + +- (void)mouseExited:(NSEvent*)event { +} + +- (void)resetCursorRects { + [self addCursorRect:self.bounds cursor:[NSCursor pointingHandCursor]]; +} + +- (void)mouseDown:(NSEvent*)event { + // Claim the click; activation fires on mouse-up inside, like a button. +} + +- (void)mouseUp:(NSEvent*)event { + NSPoint point = [self convertPoint:event.locationInWindow fromView:nil]; + if (NSPointInRect(point, self.bounds)) { + [NSApp sendAction:_action to:_target from:self]; + } +} + +@end + +// Flipped host for the row list so rows stack top-down in the scroll view. +@interface BroPaletteListView : NSView +@end + +@implementation BroPaletteListView +- (BOOL)isFlipped { + return YES; +} +@end + +#pragma mark - BroCommandPalette + +// A section is one scored, capped slice of the snapshot rendered under a +// header. Sections are re-ranked per keystroke by their best item. +@interface BroCommandPalette + : NSView +// Rebuilds the source snapshot for |scope|, clears the query, reloads, and +// positions the panel; the caller unhides it and focuses the field. +- (void)prepareForDisplayWithScope:(BroPaletteScope)scope; +- (void)focusSearchField; +// Hands keyboard focus back if the search field's editor still holds it. +- (void)restoreFocusIfNeeded; +@end + +@implementation BroCommandPalette { + NSTextField* searchField_; + NSImageView* searchIcon_; + NSTextField* shortcutHint_; + NSView* separator_; + NSScrollView* scrollView_; + BroPaletteListView* listView_; + NSMutableArray* rows_; + NSInteger selectedIndex_; + BroHoverHighlightGroup* rowHighlightGroup_; + BroPaletteScope scope_; + // Source snapshots, rebuilt on every show (see prepareForDisplayWithScope:). + NSArray* tabItems_; + NSArray* closedItems_; + NSArray* commandItems_; + NSArray* historyItems_; +} + +- (instancetype)initWithFrame:(NSRect)frame { + self = [super initWithFrame:frame]; + if (self) { + rows_ = [NSMutableArray array]; + selectedIndex_ = -1; + + self.accessibilityRole = NSAccessibilityGroupRole; + self.accessibilityLabel = @"Command palette"; + + self.wantsLayer = YES; + self.layer.cornerRadius = 8.0; + BroApplyElevation(self, BroElevationPanel); + // Glass backdrop: the elevation's flat fill is replaced by glass under a + // near-#000 tint; border and shadow stay on the panel's own layer. The + // backdrop is added first so every control renders above it, and it + // clips to the panel's corner radius itself (the panel layer can't mask + // — that would clip the shadow). On macOS 26+ this is real Liquid Glass + // (NSGlassEffectView, used as a plain backdrop sibling — the contentView + // z-order caveat in its header doesn't apply since the palette's + // controls are never its subviews); older systems get the + // NSVisualEffectView blur + tint approximation. + self.layer.backgroundColor = [NSColor clearColor].CGColor; + if (@available(macOS 26.0, *)) { + NSGlassEffectView* glass = + [[NSGlassEffectView alloc] initWithFrame:self.bounds]; + glass.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + glass.cornerRadius = 8.0; + glass.style = NSGlassEffectViewStyleRegular; + glass.tintColor = [NSColor colorWithWhite:0.0 alpha:0.6]; + [self addSubview:glass]; + } else { + NSVisualEffectView* glass = + [[NSVisualEffectView alloc] initWithFrame:self.bounds]; + glass.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + glass.material = NSVisualEffectMaterialHUDWindow; + glass.blendingMode = NSVisualEffectBlendingModeWithinWindow; + glass.state = NSVisualEffectStateActive; + glass.wantsLayer = YES; + glass.layer.cornerRadius = 8.0; + glass.layer.masksToBounds = YES; + [self addSubview:glass]; + NSView* tint = [[NSView alloc] initWithFrame:self.bounds]; + tint.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + tint.wantsLayer = YES; + tint.layer.backgroundColor = + [NSColor colorWithWhite:0.0 alpha:0.6].CGColor; + tint.layer.cornerRadius = 8.0; + tint.layer.masksToBounds = YES; + [self addSubview:tint]; + } + + searchIcon_ = [[NSImageView alloc] initWithFrame:NSZeroRect]; + searchIcon_.image = RadixIconImage(RadixIconMagnifyingGlass, 15); + searchIcon_.contentTintColor = [NSColor colorWithWhite:1.0 alpha:0.55]; + [self addSubview:searchIcon_]; + + searchField_ = [[NSTextField alloc] initWithFrame:NSZeroRect]; + searchField_.font = BroUIFont(13.0); + searchField_.textColor = [NSColor whiteColor]; + searchField_.bordered = NO; + searchField_.bezeled = NO; + searchField_.drawsBackground = NO; + searchField_.focusRingType = NSFocusRingTypeNone; + searchField_.cell.usesSingleLineMode = YES; + searchField_.cell.scrollable = YES; + searchField_.delegate = self; + [self addSubview:searchField_]; + + shortcutHint_ = BroHoverCardLabel(BroUIFont(11.0), 0.35); + [self addSubview:shortcutHint_]; + + separator_ = [[NSView alloc] initWithFrame:NSZeroRect]; + separator_.wantsLayer = YES; + separator_.layer.backgroundColor = + [NSColor colorWithWhite:1.0 alpha:kWindowBorderAlpha].CGColor; + [self addSubview:separator_]; + + listView_ = [[BroPaletteListView alloc] initWithFrame:NSZeroRect]; + scrollView_ = [[NSScrollView alloc] initWithFrame:NSZeroRect]; + scrollView_.drawsBackground = NO; + scrollView_.borderType = NSNoBorder; + scrollView_.hasVerticalScroller = YES; + scrollView_.hasHorizontalScroller = NO; + scrollView_.scrollerStyle = NSScrollerStyleOverlay; + scrollView_.automaticallyAdjustsContentInsets = NO; + scrollView_.documentView = listView_; + [self addSubview:scrollView_]; + rowHighlightGroup_ = + [[BroHoverHighlightGroup alloc] initWithContainerView:listView_]; + } + return self; +} + +- (void)prepareForDisplayWithScope:(BroPaletteScope)scope { + scope_ = scope; + BOOL tabsOnly = scope == BroPaletteScopeTabs; + + NSString* placeholder = + tabsOnly ? @"Search tabs" : @"Search tabs, history, and commands…"; + searchField_.placeholderAttributedString = [[NSAttributedString alloc] + initWithString:placeholder + attributes:@{ + NSFontAttributeName : BroUIFont(13.0), + NSForegroundColorAttributeName : + [NSColor colorWithWhite:1.0 alpha:0.35], + }]; + searchField_.accessibilityLabel = placeholder; + shortcutHint_.stringValue = tabsOnly ? @"⇧⌘A" : @"⌘K"; + [shortcutHint_ sizeToFit]; + + tabItems_ = BroPaletteTabItems(); + closedItems_ = BroPaletteClosedItems(); + commandItems_ = tabsOnly ? @[] : BroPaletteCommandItems(); + historyItems_ = tabsOnly ? @[] : BroPaletteHistoryItems(); + + searchField_.stringValue = @""; + [self reloadWithFilter:@""]; +} + +- (void)focusSearchField { + [self.window makeFirstResponder:searchField_]; +} + +- (void)restoreFocusIfNeeded { + NSWindow* window = self.window; + NSResponder* responder = window.firstResponder; + if ([responder isKindOfClass:[NSText class]] && + ((NSText*)responder).delegate == (id)searchField_) { + [window makeFirstResponder:nil]; + } +} + +// Scores |source| against |needle| and returns the survivors sorted by +// descending score, capped at |cap|. Ties keep source order (recency), so +// the sort must be stable. +static NSArray* BroPaletteFilterSection( + NSArray* source, + const std::u16string& needle, + NSUInteger cap) { + NSMutableArray* matched = [NSMutableArray array]; + for (BroPaletteItem* item in source) { + if (BroPaletteScoreItem(item, needle)) { + [matched addObject:item]; + } + } + NSArray* sorted = [matched + sortedArrayWithOptions:NSSortStable + usingComparator:^NSComparisonResult(BroPaletteItem* a, + BroPaletteItem* b) { + if (a.score > b.score) return NSOrderedAscending; + if (a.score < b.score) return NSOrderedDescending; + return NSOrderedSame; + }]; + if (sorted.count > cap) { + sorted = [sorted subarrayWithRange:NSMakeRange(0, cap)]; + } + return sorted; +} + +// Rebuilds the row list from the snapshot. The candidate pool can be large +// (history is capped at 3000 entries) but scoring is flat char16 scans and +// only the few surviving rows become views, so a full rebuild per keystroke +// stays imperceptible. +- (void)reloadWithFilter:(NSString*)query { + [rowHighlightGroup_ dismissImmediately]; + for (NSView* subview in [listView_.subviews copy]) { + [subview removeFromSuperview]; + } + [rows_ removeAllObjects]; + selectedIndex_ = -1; + + NSString* trimmed = + [query stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceCharacterSet]] ?: @""; + BOOL browsing = trimmed.length == 0; + std::u16string needle = BroLowercaseUTF16(trimmed); + + // Assemble sections: fixed composition while browsing, scored + re-ranked + // by each section's best item once a query is typed. + NSMutableArray* sections = [NSMutableArray array]; + void (^addSection)(NSString*, NSArray*) = + ^(NSString* title, NSArray* items) { + if (items.count > 0) { + [sections addObject:@{@"title" : title, @"items" : items}]; + } + }; + if (browsing) { + for (BroPaletteItem* item in tabItems_) item.score = 0; + addSection(@"Open Tabs", tabItems_); + addSection(@"Recently Closed", + closedItems_.count > kPaletteMaxClosed + ? [closedItems_ + subarrayWithRange:NSMakeRange(0, kPaletteMaxClosed)] + : closedItems_); + addSection(@"Commands", + commandItems_.count > kPaletteMaxCommands + ? [commandItems_ + subarrayWithRange:NSMakeRange(0, kPaletteMaxCommands)] + : commandItems_); + addSection(@"History", + historyItems_.count > kPaletteMaxHistoryEmptyQuery + ? [historyItems_ subarrayWithRange: + NSMakeRange(0, + kPaletteMaxHistoryEmptyQuery)] + : historyItems_); + } else { + addSection(@"Open Tabs", + BroPaletteFilterSection(tabItems_, needle, NSUIntegerMax)); + addSection(@"Recently Closed", + BroPaletteFilterSection(closedItems_, needle, + kPaletteMaxClosed)); + addSection(@"Commands", + BroPaletteFilterSection(commandItems_, needle, + kPaletteMaxCommands)); + addSection(@"History", + BroPaletteFilterSection(historyItems_, needle, + kPaletteMaxHistory)); + [sections sortWithOptions:NSSortStable + usingComparator:^NSComparisonResult(NSDictionary* a, + NSDictionary* b) { + double bestA = [a[@"items"][0] score]; + double bestB = [b[@"items"][0] score]; + if (bestA > bestB) return NSOrderedAscending; + if (bestA < bestB) return NSOrderedDescending; + return NSOrderedSame; + }]; + } + + // The fallback row makes the palette an omnibox: whatever was typed can + // always be searched or navigated to. Tabs scope keeps the old tab-search + // behavior and skips it. + BroPaletteItem* fallback = nil; + if (!browsing && scope_ == BroPaletteScopeAll) { + NSString* resolved = BroResolveQueryToURL(trimmed); + fallback = [[BroPaletteItem alloc] init]; + fallback.kind = BroPaletteItemFallback; + fallback.fallbackQuery = trimmed; + if ([resolved hasPrefix:@"https://www.google.com/search?q="]) { + fallback.title = + [NSString stringWithFormat:@"Search the web for “%@”", trimmed]; + fallback.icon = RadixIconImage(RadixIconMagnifyingGlass, 15); + } else { + fallback.title = [NSString stringWithFormat:@"Go to %@", resolved]; + fallback.icon = RadixIconImage(RadixIconGlobe, 15); + } + fallback.subtitle = @""; + } + + const CGFloat rowWidth = kPaletteWidth - kPalettePadX * 2; + CGFloat y = 4.0; // flipped list: grows downward + + NSTextField* (^addHeader)(NSString*, CGFloat) = + ^NSTextField*(NSString* title, CGFloat headerY) { + NSTextField* header = BroHoverCardLabel(BroUIFontBold(11.0), 0.4); + header.stringValue = title; + header.frame = NSMakeRect(kPalettePadX + 6.0, headerY + 7.0, + rowWidth - 12.0, 14.0); + [listView_ addSubview:header]; + return header; + }; + BroPaletteRow* (^addRow)(BroPaletteItem*, CGFloat) = + ^BroPaletteRow*(BroPaletteItem* item, CGFloat rowY) { + BroPaletteRow* row = [[BroPaletteRow alloc] + initWithFrame:NSMakeRect(kPalettePadX, rowY, rowWidth, + kPaletteRowHeight)]; + [row configureWithItem:item]; + row.target = self; + row.action = @selector(rowClicked:); + row.hoverDelegate = self; + [listView_ addSubview:row]; + [rows_ addObject:row]; + return row; + }; + + for (NSDictionary* section in sections) { + if (y > 4.0) { + y += 6.0; // gap between sections + } + addHeader(section[@"title"], y); + y += kPaletteHeaderHeight; + for (BroPaletteItem* item in section[@"items"]) { + addRow(item, y); + y += kPaletteRowHeight + 2.0; + } + } + + if (fallback) { + if (y > 4.0) { + y += 6.0; + } + addRow(fallback, y); + y += kPaletteRowHeight + 2.0; + } + + if (rows_.count == 0) { + NSTextField* empty = BroHoverCardLabel(BroUIFont(12.0), 0.45); + empty.stringValue = @"No results"; + empty.alignment = NSTextAlignmentCenter; + empty.frame = NSMakeRect(kPalettePadX, y + 10.0, rowWidth, 16.0); + [listView_ addSubview:empty]; + y += 36.0; + } + y += 4.0; // bottom padding inside the list + + // Short windows get a shorter list rather than a clipped panel. + CGFloat maxList = kPaletteListMaxHeight; + NSView* container = self.superview; + if (container) { + maxList = MIN(maxList, NSHeight(container.bounds) - kPaletteTopOffset - + kPaletteFieldRowHeight - 1.0 - 8.0); + } + CGFloat listHeight = MIN(y, MAX(maxList, kPaletteRowHeight)); + listView_.frame = NSMakeRect(0, 0, kPaletteWidth, y); + + // Panel layout, bottom-up (unflipped): list, separator, search row. + CGFloat height = listHeight + 1.0 + kPaletteFieldRowHeight; + [self setFrameSize:NSMakeSize(kPaletteWidth, height)]; + scrollView_.frame = NSMakeRect(0, 0, kPaletteWidth, listHeight); + separator_.frame = NSMakeRect(0, listHeight, kPaletteWidth, 1.0); + + CGFloat fieldRowY = listHeight + 1.0; + searchIcon_.frame = + NSMakeRect(16, fieldRowY + (kPaletteFieldRowHeight - 15) / 2.0, 15, 15); + NSSize hintSize = shortcutHint_.frame.size; + shortcutHint_.frame = + NSMakeRect(kPaletteWidth - hintSize.width - 16, + fieldRowY + (kPaletteFieldRowHeight - hintSize.height) / 2.0, + hintSize.width, hintSize.height); + searchField_.frame = NSMakeRect( + 42, fieldRowY + (kPaletteFieldRowHeight - 18) / 2.0, + kPaletteWidth - 42 - hintSize.width - 24, 18.0); + + [listView_ scrollPoint:NSZeroPoint]; + if (rows_.count > 0) { + [self selectRowAtIndex:0 animated:NO]; + } + [self repositionInContainer]; +} + +// Horizontally centered in the browser container; the top edge hangs at a +// fixed offset (cmdk-style) so the panel only ever grows downward as +// filtering changes the height. Re-run after every reload. +- (void)repositionInContainer { + NSView* container = self.superview; + if (!container) { + return; + } + NSSize size = self.frame.size; + CGFloat x = MAX(8.0, round((NSWidth(container.bounds) - size.width) / 2.0)); + CGFloat panelY = NSMaxY(container.bounds) - kPaletteTopOffset - size.height; + self.frame = NSMakeRect(x, panelY, size.width, size.height); +} + +- (void)selectRowAtIndex:(NSInteger)index animated:(BOOL)animated { + if (index < 0 || index >= (NSInteger)rows_.count) { + return; + } + if (selectedIndex_ >= 0 && selectedIndex_ < (NSInteger)rows_.count) { + rows_[selectedIndex_].selected = NO; + } + selectedIndex_ = index; + BroPaletteRow* row = rows_[index]; + row.selected = YES; + [listView_ scrollRectToVisible:NSInsetRect(row.frame, 0, -4.0)]; + [rowHighlightGroup_ hoverOnView:row animated:animated]; +} + +- (void)moveSelectionBy:(NSInteger)delta { + NSInteger count = (NSInteger)rows_.count; + if (count == 0) { + return; + } + NSInteger next = + selectedIndex_ < 0 ? 0 : (selectedIndex_ + delta + count) % count; + [self selectRowAtIndex:next animated:YES]; + // Keyboard focus stays in the field while ↑/↓/Tab move the selection, so + // VoiceOver never sees a focus change; announce the selected row instead. + // Hover-driven selection stays silent — the pointer already says where it + // is, and announcing every crossed row would chatter. + BroPaletteRow* row = rows_[next]; + NSAccessibilityPostNotificationWithUserInfo( + row, NSAccessibilityAnnouncementRequestedNotification, @{ + NSAccessibilityAnnouncementKey : row.accessibilityLabel ?: @"", + NSAccessibilityPriorityKey : @(NSAccessibilityPriorityHigh), + }); +} + +- (void)rowHoverBegan:(BroPaletteRow*)row { + NSInteger index = [rows_ indexOfObject:row]; + if (index != NSNotFound) { + [self selectRowAtIndex:index animated:YES]; + } +} + +- (void)rowClicked:(BroPaletteRow*)row { + [self activateRow:row]; +} + +- (void)activateRow:(BroPaletteRow*)row { + BroPaletteItem* item = row.item; + switch (item.kind) { + case BroPaletteItemTab: { + BroHandler* handler = BroHandler::GetInstance(); + if (handler) { + handler->SetActiveBrowser(item.browserId); + } + // Explicit hide: activating the already-active tab fires no tab-change + // callback, so nothing else would dismiss the panel. + HideCommandPalette(); + break; + } + case BroPaletteItemClosedTab: { + BroClosedTabEntry* entry = item.closedEntry; + // Reopening consumes the history entry, mirroring ⇧⌘T. + BroRemoveClosedTab(entry); + if (entry.url.length > 0) { + CreateNewBrowserTabWithURL(std::string([entry.url UTF8String])); + } + HideCommandPalette(); + break; + } + case BroPaletteItemCommand: { + NSMenuItem* menuItem = item.menuItem; + HideCommandPalette(); + // Deferred so the palette's focus teardown settles first; actions like + // Open Location move first responder themselves and must win. The menu + // item is the sender so tag/representedObject-reading handlers work. + dispatch_async(dispatch_get_main_queue(), ^{ + [NSApp sendAction:menuItem.action to:menuItem.target from:menuItem]; + }); + break; + } + case BroPaletteItemHistory: { + NSString* url = item.historyURL; + HideCommandPalette(); + // Loads in the current tab, like the History menu; a new tab would + // pile up strip clutter for a "take me back there" gesture. + BroHandler* handler = BroHandler::GetInstance(); + CefRefPtr browser = handler ? handler->GetBrowser() : nullptr; + if (browser) { + browser->GetMainFrame()->LoadURL([url UTF8String]); + } else if (url.length > 0) { + CreateNewBrowserTabWithURL(std::string([url UTF8String])); + } + break; + } + case BroPaletteItemFallback: { + NSString* query = item.fallbackQuery; + HideCommandPalette(); + [g_toolbar navigateToURL:query]; + break; + } + } +} + +- (void)controlTextDidChange:(NSNotification*)notification { + [self reloadWithFilter:searchField_.stringValue]; +} + +- (BOOL)control:(NSControl*)control + textView:(NSTextView*)textView + doCommandBySelector:(SEL)commandSelector { + if (commandSelector == @selector(moveUp:)) { + [self moveSelectionBy:-1]; + return YES; + } + if (commandSelector == @selector(moveDown:)) { + [self moveSelectionBy:1]; + return YES; + } + // Tab cycles items like ↑/↓ (wrapping) while the field keeps focus — + // Tab must not leave the palette for the window's key-view loop. + if (commandSelector == @selector(insertTab:)) { + [self moveSelectionBy:1]; + return YES; + } + if (commandSelector == @selector(insertBacktab:)) { + [self moveSelectionBy:-1]; + return YES; + } + if (commandSelector == @selector(insertNewline:)) { + if (selectedIndex_ >= 0 && selectedIndex_ < (NSInteger)rows_.count) { + [self activateRow:rows_[selectedIndex_]]; + } + return YES; + } + if (commandSelector == @selector(cancelOperation:)) { + // Normally the Esc key monitor wins; this is the fallback if it's gone. + HideCommandPalette(); + return YES; + } + return NO; +} + +@end + +#pragma mark - Outside-click dismiss monitor + +// Installs a local mouse-down monitor that hides `panel` on any click +// outside both `panel` and its owner button. Sees clicks headed for the +// native CEF views too, which never bubble through the AppKit responder +// chain, so an outside click anywhere in the window dismisses. +// +// `isVisible`, if non-nil, is checked first and short-circuits the rest when +// it returns NO (only the command palette needs this extra guard today). +// `ownerButton` is re-evaluated on every event rather than captured once, so +// callers can pass a block reading a global that may not be set yet at +// install time. Letting a click on the (visible) owner button through +// avoids an immediate hide-then-reopen when its own action toggles the +// panel. Returns the monitor token for the caller to store and later remove +// with `-[NSEvent removeMonitor:]`. Declared extern in bro_mac_internal.h. +id BroInstallOutsideDismissMonitor(NSView* panel, + BOOL (^isVisible)(void), + NSView* (^ownerButton)(void), + void (^hide)(void)) { + return [NSEvent + addLocalMonitorForEventsMatchingMask:(NSEventMaskLeftMouseDown | + NSEventMaskRightMouseDown | + NSEventMaskOtherMouseDown) + handler:^NSEvent*(NSEvent* event) { + if (isVisible && !isVisible()) { + return event; + } + if (event.window != panel.window) { + hide(); + return event; + } + NSPoint inPanel = [panel convertPoint:event.locationInWindow fromView:nil]; + if (NSPointInRect(inPanel, panel.bounds)) { + return event; + } + NSView* owner = ownerButton ? ownerButton() : nil; + if (owner && !owner.hiddenOrHasHiddenAncestor) { + NSPoint inOwner = [owner convertPoint:event.locationInWindow fromView:nil]; + if (NSPointInRect(inOwner, owner.bounds)) { + return event; + } + } + hide(); + return event; // The click still lands wherever it was aimed. + }]; +} + +#pragma mark - Palette wiring + +static BroCommandPalette* g_command_palette = nil; +// Installed while the palette shows; both removed on hide (a leaked monitor +// would keep re-hiding on every later click / Esc press). The click monitor +// sees clicks headed for the native CEF views, which never bubble through +// the AppKit responder chain; the key monitor catches Esc even when the +// search field has lost first responder. +static id g_palette_click_monitor = nil; +static id g_palette_key_monitor = nil; + +static BOOL CommandPaletteVisible(void) { + return g_command_palette && g_command_palette.superview && + !g_command_palette.hidden; +} + +// HideCommandPalette is declared extern in bro_mac_internal.h. +void HideCommandPalette(void) { + // Monitors go first, unconditionally: the visibility check below must + // never leave one behind. + if (g_palette_click_monitor) { + [NSEvent removeMonitor:g_palette_click_monitor]; + g_palette_click_monitor = nil; + } + if (g_palette_key_monitor) { + [NSEvent removeMonitor:g_palette_key_monitor]; + g_palette_key_monitor = nil; + } + if (!CommandPaletteVisible()) { + return; + } + [g_command_palette restoreFocusIfNeeded]; + BroOverlayHide(g_command_palette); +} + +static void ShowCommandPalette(BroPaletteScope scope) { + NSView* container = BroBrowserContainerView(); + if (!container) { + return; + } + if (!g_command_palette) { + g_command_palette = [[BroCommandPalette alloc] initWithFrame:NSZeroRect]; + g_command_palette.hidden = YES; + } + // Re-add last on every show so it composites above the CEF views (see + // showHoverCardForTab:). + [g_command_palette removeFromSuperview]; + [container addSubview:g_command_palette]; + [g_command_palette prepareForDisplayWithScope:scope]; + BroOverlayShow(g_command_palette); + [g_command_palette focusSearchField]; + + if (!g_palette_click_monitor) { + g_palette_click_monitor = BroInstallOutsideDismissMonitor( + g_command_palette, + ^BOOL{ return CommandPaletteVisible(); }, + ^NSView*{ return g_tab_bar.tabSearchButton; }, + ^{ HideCommandPalette(); }); + } + if (!g_palette_key_monitor) { + g_palette_key_monitor = [NSEvent + addLocalMonitorForEventsMatchingMask:NSEventMaskKeyDown + handler:^NSEvent*(NSEvent* event) { + if (event.keyCode == 53) { // Esc + HideCommandPalette(); + return nil; + } + return event; + }]; + } +} + +// ToggleCommandPalette is declared extern in bro_mac_internal.h. +void ToggleCommandPalette(BroPaletteScope scope) { + if (CommandPaletteVisible()) { + HideCommandPalette(); + } else { + ShowCommandPalette(scope); + } +} + +// Releases the retained palette when the main window is torn down; the next +// show rebuilds it fresh. Distinct from HideCommandPalette, which keeps it +// around (hidden) for reuse. Declared extern in bro_mac_internal.h. +void TeardownCommandPalette(void) { + [g_command_palette removeFromSuperview]; + g_command_palette = nil; +} diff --git a/src/bro_tabsearch.mm b/src/bro_tabsearch.mm deleted file mode 100644 index 241945c..0000000 --- a/src/bro_tabsearch.mm +++ /dev/null @@ -1,622 +0,0 @@ -// Copyright (c) 2013 The Chromium Embedded Framework Authors. -// Portions copyright (c) 2010 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -#include "bro_handler.h" -#import "bro_mac_internal.h" -#import "bro_closed_tabs.h" -#import "radix_icons.h" - -#pragma mark - BroTabSearchPanel - -static const CGFloat kTabSearchPanelWidth = 340.0; -static const CGFloat kTabSearchRowHeight = 32.0; -static const CGFloat kTabSearchHeaderHeight = 24.0; -// The list scrolls past this; the search row above never scrolls away. -static const CGFloat kTabSearchListMaxHeight = 352.0; -static const CGFloat kTabSearchFieldRowHeight = 44.0; -static const CGFloat kTabSearchPadX = 8.0; - -// One row in the tab search panel: favicon + title, hover highlight, and a -// selection highlight driven by the panel's ↑/↓ navigation. A plain view -// rather than BroHoverButton: the hover scale reads wrong on a wide list row. -@interface BroTabSearchRow : NSView -// Open-tab rows carry the browser to activate; closed rows carry the entry -// to reopen. Exactly one is set. -@property (nonatomic, assign) int browserId; -@property (nonatomic, strong) BroClosedTabEntry* closedEntry; -@property (nonatomic, assign) BOOL selected; -@property (nonatomic, weak) id target; -@property (nonatomic, assign) SEL action; -- (void)setTitle:(NSString*)title; -// Copies the pill's already-resolved favicon (image + placeholder tint). -- (void)adoptFaviconFrom:(NSImageView*)source; -// Async fetch for closed rows; shows the globe placeholder meanwhile. -- (void)setFaviconURLString:(NSString*)urlString; -@end - -@implementation BroTabSearchRow { - NSImageView* faviconView_; - NSTextField* titleLabel_; - BOOL hovered_; - // Same guard as BroTabView: a stale fetch must not overwrite the favicon - // this row was last configured with. Main-thread only. - NSUInteger faviconGeneration_; -} - -- (instancetype)initWithFrame:(NSRect)frame { - self = [super initWithFrame:frame]; - if (self) { - _browserId = -1; - self.wantsLayer = YES; - self.layer.cornerRadius = 6.0; - self.layer.actions = BroLayerTransitionActions(); - - faviconView_ = [[NSImageView alloc] - initWithFrame:NSMakeRect(10, (frame.size.height - 15) / 2.0, 15, 15)]; - faviconView_.imageScaling = NSImageScaleProportionallyUpOrDown; - [self addSubview:faviconView_]; - [self showPlaceholderFavicon]; - - titleLabel_ = BroHoverCardLabel(BroUIFont(12.0), 0.9); - titleLabel_.frame = NSMakeRect(34, (frame.size.height - 16) / 2.0, - frame.size.width - 34 - 10, 16); - titleLabel_.autoresizingMask = NSViewWidthSizable; - [self addSubview:titleLabel_]; - - self.accessibilityElement = YES; - self.accessibilityRole = NSAccessibilityButtonRole; - - NSTrackingArea* trackingArea = [[NSTrackingArea alloc] - initWithRect:NSZeroRect - options:NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways | - NSTrackingInVisibleRect - owner:self - userInfo:nil]; - [self addTrackingArea:trackingArea]; - } - return self; -} - -- (void)setTitle:(NSString*)title { - titleLabel_.stringValue = title ?: @""; - self.accessibilityLabel = titleLabel_.stringValue; -} - -- (void)showPlaceholderFavicon { - faviconView_.image = RadixIconImage(RadixIconGlobe, 15); - faviconView_.contentTintColor = - [NSColor colorWithWhite:0x33 / 255.0 alpha:1.0]; -} - -- (void)adoptFaviconFrom:(NSImageView*)source { - faviconGeneration_++; - if (source.image) { - faviconView_.image = source.image; - faviconView_.contentTintColor = source.contentTintColor; - } else { - [self showPlaceholderFavicon]; - } -} - -- (void)setFaviconURLString:(NSString*)urlString { - NSUInteger generation = ++faviconGeneration_; - [self showPlaceholderFavicon]; - if (urlString.length == 0) { - return; - } - __weak BroTabSearchRow* weakSelf = self; - BroFetchFaviconGuarded( - urlString, generation, - ^BOOL(NSUInteger g) { - BroTabSearchRow* strongSelf = weakSelf; - return strongSelf && strongSelf->faviconGeneration_ == g; - }, - ^(NSImage* image) { - BroTabSearchRow* strongSelf = weakSelf; - strongSelf->faviconView_.image = image; - strongSelf->faviconView_.contentTintColor = nil; - }); -} - -- (void)refreshBackground { - CGFloat alpha = _selected ? 0.14 : (hovered_ ? 0.07 : 0.0); - self.layer.backgroundColor = - alpha > 0 ? [NSColor colorWithWhite:1.0 alpha:alpha].CGColor - : [NSColor clearColor].CGColor; -} - -- (void)setSelected:(BOOL)selected { - _selected = selected; - [self refreshBackground]; -} - -- (void)mouseEntered:(NSEvent*)event { - hovered_ = YES; - [self refreshBackground]; -} - -- (void)mouseExited:(NSEvent*)event { - hovered_ = NO; - [self refreshBackground]; -} - -- (void)resetCursorRects { - [self addCursorRect:self.bounds cursor:[NSCursor pointingHandCursor]]; -} - -- (void)mouseDown:(NSEvent*)event { - // Claim the click; activation fires on mouse-up inside, like a button. -} - -- (void)mouseUp:(NSEvent*)event { - NSPoint point = [self convertPoint:event.locationInWindow fromView:nil]; - if (NSPointInRect(point, self.bounds)) { - [NSApp sendAction:_action to:_target from:self]; - } -} - -@end - -// Flipped host for the row list so rows stack top-down in the scroll view. -@interface BroTabSearchListView : NSView -@end - -@implementation BroTabSearchListView -- (BOOL)isFlipped { - return YES; -} -@end - -// The ⇧⌘A dropdown: a search field over a scrollable list of open tabs and -// recently closed entries. Typing filters both sections; ↑/↓/Return navigate -// and activate; Escape or an outside click dismisses. Same overlay recipe as -// the hover card: hand-rolled, re-added last to the browser container on -// every show, retained and hidden rather than destroyed. -@interface BroTabSearchPanel : NSView -// Clears the query, rebuilds the list, and positions the panel; the caller -// unhides it and focuses the field. -- (void)prepareForDisplay; -- (void)focusSearchField; -// Hands keyboard focus back if the search field's editor still holds it. -- (void)restoreFocusIfNeeded; -@end - -@implementation BroTabSearchPanel { - NSTextField* searchField_; - NSImageView* searchIcon_; - NSTextField* shortcutHint_; - NSView* separator_; - NSScrollView* scrollView_; - BroTabSearchListView* listView_; - NSMutableArray* rows_; - NSInteger selectedIndex_; -} - -- (instancetype)initWithFrame:(NSRect)frame { - self = [super initWithFrame:frame]; - if (self) { - rows_ = [NSMutableArray array]; - selectedIndex_ = -1; - - self.wantsLayer = YES; - self.layer.cornerRadius = 8.0; - self.layer.backgroundColor = - [NSColor colorWithWhite:0.18 alpha:0.96].CGColor; - self.layer.borderWidth = 1.0; - self.layer.borderColor = - [NSColor colorWithWhite:1.0 alpha:kWindowBorderAlpha].CGColor; - - searchIcon_ = [[NSImageView alloc] initWithFrame:NSZeroRect]; - searchIcon_.image = RadixIconImage(RadixIconMagnifyingGlass, 15); - searchIcon_.contentTintColor = [NSColor colorWithWhite:1.0 alpha:0.55]; - [self addSubview:searchIcon_]; - - searchField_ = [[NSTextField alloc] initWithFrame:NSZeroRect]; - searchField_.font = BroUIFont(13.0); - searchField_.textColor = [NSColor whiteColor]; - searchField_.bordered = NO; - searchField_.bezeled = NO; - searchField_.drawsBackground = NO; - searchField_.focusRingType = NSFocusRingTypeNone; - searchField_.cell.usesSingleLineMode = YES; - searchField_.cell.scrollable = YES; - searchField_.placeholderAttributedString = [[NSAttributedString alloc] - initWithString:@"Search tabs" - attributes:@{ - NSFontAttributeName : BroUIFont(13.0), - NSForegroundColorAttributeName : - [NSColor colorWithWhite:1.0 alpha:0.35], - }]; - searchField_.delegate = self; - searchField_.accessibilityLabel = @"Search tabs"; - [self addSubview:searchField_]; - - shortcutHint_ = BroHoverCardLabel(BroUIFont(11.0), 0.35); - shortcutHint_.stringValue = @"⇧⌘A"; - [shortcutHint_ sizeToFit]; - [self addSubview:shortcutHint_]; - - separator_ = [[NSView alloc] initWithFrame:NSZeroRect]; - separator_.wantsLayer = YES; - separator_.layer.backgroundColor = - [NSColor colorWithWhite:1.0 alpha:kWindowBorderAlpha].CGColor; - [self addSubview:separator_]; - - listView_ = [[BroTabSearchListView alloc] initWithFrame:NSZeroRect]; - scrollView_ = [[NSScrollView alloc] initWithFrame:NSZeroRect]; - scrollView_.drawsBackground = NO; - scrollView_.borderType = NSNoBorder; - scrollView_.hasVerticalScroller = YES; - scrollView_.hasHorizontalScroller = NO; - scrollView_.scrollerStyle = NSScrollerStyleOverlay; - scrollView_.automaticallyAdjustsContentInsets = NO; - scrollView_.documentView = listView_; - [self addSubview:scrollView_]; - } - return self; -} - -- (void)prepareForDisplay { - searchField_.stringValue = @""; - [self reloadWithFilter:@""]; -} - -- (void)focusSearchField { - [self.window makeFirstResponder:searchField_]; -} - -- (void)restoreFocusIfNeeded { - NSWindow* window = self.window; - NSResponder* responder = window.firstResponder; - if ([responder isKindOfClass:[NSText class]] && - ((NSText*)responder).delegate == (id)searchField_) { - [window makeFirstResponder:nil]; - } -} - -- (NSString*)displayTitleForTab:(BroTabView*)tab { - if (tab.pageTitle.length > 0) { - return tab.pageTitle; - } - if (!BroURLIsBlank(tab.tabURL)) { - return BroDisplayHostForURL(tab.tabURL); - } - return kBroBlankTabTitle; -} - -// Rebuilds the row list from the live tab strip and the closed-tab history. -// The list is small (tabs + ≤10 closed), so a full rebuild per keystroke is -// simpler than diffing and imperceptible. -- (void)reloadWithFilter:(NSString*)query { - for (NSView* subview in [listView_.subviews copy]) { - [subview removeFromSuperview]; - } - [rows_ removeAllObjects]; - selectedIndex_ = -1; - - NSString* needle = - [query stringByTrimmingCharactersInSet: - [NSCharacterSet whitespaceCharacterSet]].lowercaseString - ?: @""; - BOOL (^matches)(NSString*, NSString*) = ^BOOL(NSString* title, NSString* url) { - if (needle.length == 0) { - return YES; - } - return (title.length > 0 && - [title.lowercaseString containsString:needle]) || - (url.length > 0 && [url.lowercaseString containsString:needle]); - }; - - NSMutableArray* openTabs = [NSMutableArray array]; - for (BroTabView* tab in g_tab_bar.tabs) { - if (matches([self displayTitleForTab:tab], tab.tabURL)) { - [openTabs addObject:tab]; - } - } - NSMutableArray* closedTabs = [NSMutableArray array]; - for (BroClosedTabEntry* entry in [BroClosedTabsList() reverseObjectEnumerator]) { - if (matches(entry.title, entry.url)) { - [closedTabs addObject:entry]; - } - } - - const CGFloat rowWidth = kTabSearchPanelWidth - kTabSearchPadX * 2; - CGFloat y = 4.0; // flipped list: grows downward - - NSTextField* (^addHeader)(NSString*, CGFloat) = - ^NSTextField*(NSString* title, CGFloat headerY) { - NSTextField* header = BroHoverCardLabel(BroUIFontBold(11.0), 0.4); - header.stringValue = title; - header.frame = NSMakeRect(kTabSearchPadX + 6.0, headerY + 7.0, - rowWidth - 12.0, 14.0); - [listView_ addSubview:header]; - return header; - }; - - if (openTabs.count > 0) { - addHeader(@"Open Tabs", y); - y += kTabSearchHeaderHeight; - for (BroTabView* tab in openTabs) { - BroTabSearchRow* row = [[BroTabSearchRow alloc] - initWithFrame:NSMakeRect(kTabSearchPadX, y, rowWidth, - kTabSearchRowHeight)]; - row.browserId = tab.browserId; - [row setTitle:[self displayTitleForTab:tab]]; - [row adoptFaviconFrom:tab.faviconView]; - row.target = self; - row.action = @selector(rowClicked:); - [listView_ addSubview:row]; - [rows_ addObject:row]; - y += kTabSearchRowHeight + 2.0; - } - } - - if (closedTabs.count > 0) { - if (openTabs.count > 0) { - y += 6.0; // gap between sections - } - addHeader(@"Recently Closed", y); - y += kTabSearchHeaderHeight; - for (BroClosedTabEntry* entry in closedTabs) { - BroTabSearchRow* row = [[BroTabSearchRow alloc] - initWithFrame:NSMakeRect(kTabSearchPadX, y, rowWidth, - kTabSearchRowHeight)]; - row.closedEntry = entry; - [row setTitle:entry.title.length > 0 - ? entry.title - : BroDisplayHostForURL(entry.url)]; - [row setFaviconURLString:entry.faviconURL]; - row.target = self; - row.action = @selector(rowClicked:); - [listView_ addSubview:row]; - [rows_ addObject:row]; - y += kTabSearchRowHeight + 2.0; - } - } - - if (rows_.count == 0) { - NSTextField* empty = BroHoverCardLabel(BroUIFont(12.0), 0.45); - empty.stringValue = @"No matching tabs"; - empty.alignment = NSTextAlignmentCenter; - empty.frame = NSMakeRect(kTabSearchPadX, y + 10.0, rowWidth, 16.0); - [listView_ addSubview:empty]; - y += 36.0; - } - y += 4.0; // bottom padding inside the list - - CGFloat listHeight = MIN(y, kTabSearchListMaxHeight); - listView_.frame = NSMakeRect(0, 0, kTabSearchPanelWidth, y); - - // Panel layout, bottom-up (unflipped): list, separator, search row. - CGFloat height = listHeight + 1.0 + kTabSearchFieldRowHeight; - [self setFrameSize:NSMakeSize(kTabSearchPanelWidth, height)]; - scrollView_.frame = NSMakeRect(0, 0, kTabSearchPanelWidth, listHeight); - separator_.frame = NSMakeRect(0, listHeight, kTabSearchPanelWidth, 1.0); - - CGFloat fieldRowY = listHeight + 1.0; - searchIcon_.frame = - NSMakeRect(14, fieldRowY + (kTabSearchFieldRowHeight - 15) / 2.0, 15, 15); - NSSize hintSize = shortcutHint_.frame.size; - shortcutHint_.frame = - NSMakeRect(kTabSearchPanelWidth - hintSize.width - 14, - fieldRowY + (kTabSearchFieldRowHeight - hintSize.height) / 2.0, - hintSize.width, hintSize.height); - searchField_.frame = NSMakeRect( - 38, fieldRowY + (kTabSearchFieldRowHeight - 18) / 2.0, - kTabSearchPanelWidth - 38 - hintSize.width - 22, 18.0); - - [listView_ scrollPoint:NSZeroPoint]; - if (rows_.count > 0) { - [self selectRowAtIndex:0]; - } - [self repositionInContainer]; -} - -// Anchored under the tab strip's chevron (falling back to the container's -// right edge), hanging from the top like the hover card. Re-run after every -// reload: filtering changes the height. -- (void)repositionInContainer { - NSView* container = self.superview; - if (!container) { - return; - } - NSSize size = self.frame.size; - CGFloat x = NSMaxX(container.bounds) - size.width - 8.0; - BroHoverButton* chevron = g_tab_bar.tabSearchButton; - if (chevron && chevron.window == self.window) { - NSRect chevronRect = - [container convertRect:chevron.bounds fromView:chevron]; - x = NSMaxX(chevronRect) - size.width; - } - x = MAX(8.0, MIN(x, NSMaxX(container.bounds) - size.width - 8.0)); - CGFloat panelY = NSMaxY(container.bounds) - size.height - 6.0; - self.frame = NSMakeRect(x, panelY, size.width, size.height); -} - -- (void)selectRowAtIndex:(NSInteger)index { - if (selectedIndex_ >= 0 && selectedIndex_ < (NSInteger)rows_.count) { - rows_[selectedIndex_].selected = NO; - } - selectedIndex_ = index; - BroTabSearchRow* row = rows_[index]; - row.selected = YES; - [listView_ scrollRectToVisible:NSInsetRect(row.frame, 0, -4.0)]; -} - -- (void)moveSelectionBy:(NSInteger)delta { - NSInteger count = (NSInteger)rows_.count; - if (count == 0) { - return; - } - NSInteger next = - selectedIndex_ < 0 ? 0 : (selectedIndex_ + delta + count) % count; - [self selectRowAtIndex:next]; -} - -- (void)rowClicked:(BroTabSearchRow*)row { - [self activateRow:row]; -} - -- (void)activateRow:(BroTabSearchRow*)row { - if (row.closedEntry) { - BroClosedTabEntry* entry = row.closedEntry; - // Reopening consumes the history entry, mirroring ⇧⌘T. - BroRemoveClosedTab(entry); - if (entry.url.length > 0) { - CreateNewBrowserTabWithURL(std::string([entry.url UTF8String])); - } - } else { - BroHandler* handler = BroHandler::GetInstance(); - if (handler) { - handler->SetActiveBrowser(row.browserId); - } - } - // Explicit hide: activating the already-active tab fires no tab-change - // callback, so nothing else would dismiss the panel. - HideTabSearchPanel(); -} - -- (void)controlTextDidChange:(NSNotification*)notification { - [self reloadWithFilter:searchField_.stringValue]; -} - -- (BOOL)control:(NSControl*)control - textView:(NSTextView*)textView - doCommandBySelector:(SEL)commandSelector { - if (commandSelector == @selector(moveUp:)) { - [self moveSelectionBy:-1]; - return YES; - } - if (commandSelector == @selector(moveDown:)) { - [self moveSelectionBy:1]; - return YES; - } - if (commandSelector == @selector(insertNewline:)) { - if (selectedIndex_ >= 0 && selectedIndex_ < (NSInteger)rows_.count) { - [self activateRow:rows_[selectedIndex_]]; - } - return YES; - } - if (commandSelector == @selector(cancelOperation:)) { - HideTabSearchPanel(); - return YES; - } - return NO; -} - -@end - -#pragma mark - Outside-click dismiss monitor - -// Installs a local mouse-down monitor that hides `panel` on any click -// outside both `panel` and its owner button. Sees clicks headed for the -// native CEF views too, which never bubble through the AppKit responder -// chain, so an outside click anywhere in the window dismisses. -// -// `isVisible`, if non-nil, is checked first and short-circuits the rest when -// it returns NO (only the tab search panel needs this extra guard today). -// `ownerButton` is re-evaluated on every event rather than captured once, so -// callers can pass a block reading a global that may not be set yet at -// install time. Letting a click on the (visible) owner button through -// avoids an immediate hide-then-reopen when its own action toggles the -// panel. Returns the monitor token for the caller to store and later remove -// with `-[NSEvent removeMonitor:]`. Declared extern in bro_mac_internal.h. -id BroInstallOutsideDismissMonitor(NSView* panel, - BOOL (^isVisible)(void), - NSView* (^ownerButton)(void), - void (^hide)(void)) { - return [NSEvent - addLocalMonitorForEventsMatchingMask:(NSEventMaskLeftMouseDown | - NSEventMaskRightMouseDown | - NSEventMaskOtherMouseDown) - handler:^NSEvent*(NSEvent* event) { - if (isVisible && !isVisible()) { - return event; - } - if (event.window != panel.window) { - hide(); - return event; - } - NSPoint inPanel = [panel convertPoint:event.locationInWindow fromView:nil]; - if (NSPointInRect(inPanel, panel.bounds)) { - return event; - } - NSView* owner = ownerButton ? ownerButton() : nil; - if (owner && !owner.hiddenOrHasHiddenAncestor) { - NSPoint inOwner = [owner convertPoint:event.locationInWindow fromView:nil]; - if (NSPointInRect(inOwner, owner.bounds)) { - return event; - } - } - hide(); - return event; // The click still lands wherever it was aimed. - }]; -} - -static BroTabSearchPanel* g_tab_search_panel = nil; -// Local mouse-down monitor while the panel shows; sees clicks headed for the -// native CEF views (which never bubble through the AppKit responder chain) -// so an outside click anywhere in the window dismisses. -static id g_tab_search_click_monitor = nil; - -static BOOL TabSearchPanelVisible(void) { - return g_tab_search_panel && g_tab_search_panel.superview && - !g_tab_search_panel.hidden; -} - -// HideTabSearchPanel is declared extern in bro_mac_internal.h. -void HideTabSearchPanel(void) { - if (g_tab_search_click_monitor) { - [NSEvent removeMonitor:g_tab_search_click_monitor]; - g_tab_search_click_monitor = nil; - } - if (!TabSearchPanelVisible()) { - return; - } - [g_tab_search_panel restoreFocusIfNeeded]; - g_tab_search_panel.hidden = YES; -} - -static void ShowTabSearchPanel(void) { - NSView* container = BroBrowserContainerView(); - if (!container) { - return; - } - if (!g_tab_search_panel) { - g_tab_search_panel = [[BroTabSearchPanel alloc] initWithFrame:NSZeroRect]; - } - // Re-add last on every show so it composites above the CEF views (see - // showHoverCardForTab:). - [g_tab_search_panel removeFromSuperview]; - [container addSubview:g_tab_search_panel]; - [g_tab_search_panel prepareForDisplay]; - g_tab_search_panel.hidden = NO; - [g_tab_search_panel focusSearchField]; - - if (!g_tab_search_click_monitor) { - g_tab_search_click_monitor = BroInstallOutsideDismissMonitor( - g_tab_search_panel, - ^BOOL{ return TabSearchPanelVisible(); }, - ^NSView*{ return g_tab_bar.tabSearchButton; }, - ^{ HideTabSearchPanel(); }); - } -} - -// ToggleTabSearchPanel is declared extern in bro_mac_internal.h. -void ToggleTabSearchPanel(void) { - if (TabSearchPanelVisible()) { - HideTabSearchPanel(); - } else { - ShowTabSearchPanel(); - } -} - -// Releases the retained panel when the main window is torn down; the next -// show rebuilds it fresh. Distinct from HideTabSearchPanel, which keeps it -// around (hidden) for reuse. Declared extern in bro_mac_internal.h. -void TeardownTabSearchPanel(void) { - [g_tab_search_panel removeFromSuperview]; - g_tab_search_panel = nil; -} diff --git a/src/bro_tabstrip.mm b/src/bro_tabstrip.mm index 0f8f11c..4ffdbdc 100644 --- a/src/bro_tabstrip.mm +++ b/src/bro_tabstrip.mm @@ -10,6 +10,8 @@ #include "bro_handler.h" #import "bro_mac_internal.h" +#import "bro_motion.h" +#import "bro_text_morph.h" #import "radix_icons.h" // Scales about the view's center regardless of the layer's anchorPoint. @@ -111,8 +113,8 @@ - (void)refreshBackground { if (pressed_) { alpha = 0.16; } else if (_selectedState) { - alpha = (self.enabled && hovered_) ? 0.14 : 0.10; - } else if (self.enabled && hovered_) { + alpha = (self.enabled && hovered_ && !_highlightGroup) ? 0.14 : 0.10; + } else if (self.enabled && hovered_ && !_highlightGroup) { alpha = 0.08; } self.layer.backgroundColor = @@ -124,11 +126,10 @@ - (void)refreshBackground { // Disabled buttons and Reduce Motion users get no motion. - (void)refreshTransform { CGFloat scale = 1.0; - if (self.enabled && - ![NSWorkspace sharedWorkspace].accessibilityDisplayShouldReduceMotion) { + if (self.enabled && !BroMotionReduced()) { if (pressed_) { scale = kIconPressScale; - } else if (hovered_) { + } else if (hovered_ && !_highlightGroup) { scale = kIconHoverScale; } } @@ -142,6 +143,9 @@ - (void)setSelectedState:(BOOL)selectedState { } - (void)setEnabled:(BOOL)enabled { + if (!enabled && self.enabled && hovered_) { + [_highlightGroup hoverOffView:self]; + } [super setEnabled:enabled]; [self refreshBackground]; [self refreshTransform]; @@ -162,6 +166,7 @@ - (void)viewDidHide { [super viewDidHide]; hovered_ = NO; pressed_ = NO; + [_highlightGroup hoverOffView:self]; [self refreshBackground]; [self refreshTransform]; } @@ -173,7 +178,11 @@ - (void)resetCursorRects { } - (void)mouseEntered:(NSEvent*)event { + if (!self.enabled) { + return; + } hovered_ = YES; + [_highlightGroup hoverOnView:self]; [self refreshBackground]; [self refreshTransform]; } @@ -181,6 +190,7 @@ - (void)mouseEntered:(NSEvent*)event { - (void)mouseExited:(NSEvent*)event { hovered_ = NO; pressed_ = NO; + [_highlightGroup hoverOffView:self]; [self refreshBackground]; [self refreshTransform]; } @@ -278,21 +288,13 @@ - (instancetype)initWithFrame:(NSRect)frame browserId:(int)browserId { [self addSubview:_loadingSpinner]; // Host label (hidden on the active pill, where the address field shows) - _titleLabel = [[NSTextField alloc] - initWithFrame:NSMakeRect(32, (kTabPillHeight - 18) / 2.0, - frame.size.width - 32 - 26, 18)]; - _titleLabel.stringValue = kBroBlankTabTitle; - _titleLabel.font = BroUIFont(12.0); - _titleLabel.textColor = [NSColor secondaryLabelColor]; - _titleLabel.bordered = NO; - _titleLabel.editable = NO; - _titleLabel.selectable = NO; - _titleLabel.drawsBackground = NO; - _titleLabel.lineBreakMode = NSLineBreakByTruncatingTail; - _titleLabel.cell.truncatesLastVisibleLine = YES; - // Single-line mode matches the address field's cell so the text doesn't - // shift when the label swaps for the field on tab activation. - _titleLabel.cell.usesSingleLineMode = YES; + _titleLabel = [[BroTextMorphView alloc] + initWithFont:BroUIFont(kTabTextFontSize) + color:[NSColor secondaryLabelColor]]; + _titleLabel.frame = NSMakeRect( + 32, (kTabPillHeight - kTabTextFrameHeight) / 2.0, + frame.size.width - 32 - 26, kTabTextFrameHeight); + [_titleLabel setText:kBroBlankTabTitle animated:NO]; _titleLabel.autoresizingMask = NSViewWidthSizable; [self addSubview:_titleLabel]; @@ -358,9 +360,10 @@ - (void)setTabURL:(NSString*)url { _pageDescription = nil; } _tabURL = newURL; - _titleLabel.stringValue = BroURLIsBlank(_tabURL) - ? kBroBlankTabTitle - : BroDisplayHostForURL(_tabURL); + NSString* display = BroURLIsBlank(_tabURL) + ? kBroBlankTabTitle + : BroDisplayHostForURL(_tabURL); + [_titleLabel setText:display animated:self.window != nil]; } // Hosts the toolbar's shared address field (this pill is the active tab). @@ -370,7 +373,7 @@ - (void)setTabURL:(NSString*)url { - (void)attachAddressField:(NSTextField*)field { field.autoresizingMask = NSViewNotSizable; [self addSubview:field]; - _titleLabel.hidden = YES; + _editingAddress = NO; [self layoutPillContents]; } @@ -382,19 +385,25 @@ - (void)layoutPillContents { NSRect iconFrame = NSMakeRect(iconX, (kTabPillHeight - 15) / 2.0, 15, 15); _faviconView.frame = iconFrame; _loadingSpinner.frame = iconFrame; - _titleLabel.hidden = _iconOnly || _isActive; + // Resting active and inactive tabs deliberately share the morph view. The + // NSTextField appears only while editing, eliminating the renderer/baseline + // swap that made text jump when merely changing tabs. + BOOL showEditor = _isActive && _editingAddress; + _titleLabel.hidden = _iconOnly || showEditor; // Text runs from the favicon's right edge to just inside the ✕ (or the // pill's edge when there is no room for one). Clamped at zero so a collapsed // pill can't produce a negative width. CGFloat textRight = _closable ? 26.0 : 10.0; NSRect textFrame = - NSMakeRect(32, (self.bounds.size.height - 18) / 2.0, - MAX(0.0, self.bounds.size.width - 32 - textRight), 18); + NSMakeRect(32, + (self.bounds.size.height - kTabTextFrameHeight) / 2.0, + MAX(0.0, self.bounds.size.width - 32 - textRight), + kTabTextFrameHeight); _titleLabel.frame = textFrame; for (NSView* v in self.subviews) { if ([v isKindOfClass:[BroAddressField class]]) { - v.hidden = _iconOnly; + v.hidden = _iconOnly || !showEditor; v.frame = textFrame; break; } @@ -440,7 +449,7 @@ - (void)updateAppearance { if (_isActive) { self.layer.backgroundColor = [NSColor blackColor].CGColor; self.layer.borderColor = BroControlBorderColor().CGColor; - _titleLabel.textColor = [NSColor labelColor]; + _titleLabel.color = [NSColor labelColor]; } else { // The split screen's right pane keeps the active pill's look while // inactive, so both on-screen halves read as selected — with a faint @@ -453,10 +462,7 @@ - (void)updateAppearance { (_isSplitPane || hovered_ || focused_) ? BroControlBorderColor().CGColor : [NSColor colorWithWhite:1.0 alpha:0.05].CGColor; - _titleLabel.textColor = [NSColor secondaryLabelColor]; - // The address field only lives in the active pill, so the label comes - // back — unless the pill is collapsed to its favicon. - _titleLabel.hidden = _iconOnly; + _titleLabel.color = [NSColor secondaryLabelColor]; } // A dragged pill hovering this one (drop = split the two tabs) outshines // every other state so the target is unmistakable. @@ -478,8 +484,7 @@ - (void)setCloseButtonShown:(BOOL)shown { closeButtonShown_ = shown; // Pills built off-window (init-time updateAppearance) must not fade in. BOOL animate = - self.window != nil && - ![NSWorkspace sharedWorkspace].accessibilityDisplayShouldReduceMotion; + self.window != nil && !BroMotionReduced(); if (!animate) { _closeButton.hidden = !shown; _closeButton.alphaValue = 1.0; @@ -520,6 +525,9 @@ - (void)setClosable:(BOOL)closable { - (void)setIsActive:(BOOL)isActive { _isActive = isActive; + if (!isActive) { + _editingAddress = NO; + } [self updateAppearance]; [self layoutPillContents]; } @@ -527,6 +535,7 @@ - (void)setIsActive:(BOOL)isActive { - (void)setEditingAddress:(BOOL)editingAddress { _editingAddress = editingAddress; [self updateAppearance]; + [self layoutPillContents]; } - (void)setDropTarget:(BOOL)dropTarget { @@ -563,8 +572,7 @@ - (void)refreshCorners { if (self.layer.maskedCorners == mask) { return; } - if (self.window != nil && - ![NSWorkspace sharedWorkspace].accessibilityDisplayShouldReduceMotion) { + if (self.window != nil && !BroMotionReduced()) { CATransition* fade = [CATransition animation]; fade.type = kCATransitionFade; fade.duration = kCloseButtonFadeDuration; @@ -751,7 +759,7 @@ - (NSString*)accessibilityRoleDescription { } - (NSString*)accessibilityLabel { - NSString* host = _titleLabel.stringValue; + NSString* host = _titleLabel.text; NSString* label = host.length > 0 ? host : (self.pageTitle.length > 0 ? self.pageTitle : kBroBlankTabTitle); @@ -815,7 +823,7 @@ - (void)setTitle:(NSString*)title @end @implementation BroTabHoverCard { - NSTextField* titleLabel_; + BroTextMorphView* titleLabel_; NSTextField* urlLabel_; NSTextField* descriptionLabel_; } @@ -840,15 +848,10 @@ - (instancetype)initWithFrame:(NSRect)frame { if (self) { self.wantsLayer = YES; self.layer.cornerRadius = 8.0; - // Slightly lighter and more opaque than the zoom HUD so the card stays - // readable over dark page content. - self.layer.backgroundColor = - [NSColor colorWithWhite:0.18 alpha:0.96].CGColor; - self.layer.borderWidth = 1.0; - self.layer.borderColor = - [NSColor colorWithWhite:1.0 alpha:kWindowBorderAlpha].CGColor; + BroApplyElevation(self, BroElevationOverlay); - titleLabel_ = BroHoverCardLabel(BroUIFontBold(12.0), 1.0); + titleLabel_ = [[BroTextMorphView alloc] + initWithFont:BroUIFontBold(12.0) color:[NSColor whiteColor]]; urlLabel_ = BroHoverCardLabel(BroUIFont(11.0), 0.55); descriptionLabel_ = BroHoverCardLabel(BroUIFont(11.0), 0.75); descriptionLabel_.lineBreakMode = NSLineBreakByWordWrapping; @@ -902,13 +905,13 @@ - (void)setTitle:(NSString*)title const CGFloat rowGap = 3.0; const CGFloat textWidth = width - padX * 2; - titleLabel_.stringValue = title ?: @""; + [titleLabel_ setText:title ?: @"" + animated:self.window != nil && !self.hidden]; urlLabel_.stringValue = url ?: @""; descriptionLabel_.stringValue = desc ?: @""; descriptionLabel_.hidden = desc.length == 0; - CGFloat titleHeight = ceil([titleLabel_.cell cellSizeForBounds: - NSMakeRect(0, 0, CGFLOAT_MAX, CGFLOAT_MAX)].height); + CGFloat titleHeight = titleLabel_.desiredSize.height; CGFloat urlHeight = ceil([urlLabel_.cell cellSizeForBounds: NSMakeRect(0, 0, CGFLOAT_MAX, CGFLOAT_MAX)].height); @@ -1010,23 +1013,25 @@ - (instancetype)initWithFrame:(NSRect)frame { _addTabButton.toolTip = @"New tab (⌘N)"; [self addSubview:_addTabButton]; - // Tab search chevron, pinned at the strip's right edge (the "+" button - // trails the last pill; this one never moves). + // Palette search button, pinned at the strip's right edge (the "+" + // button trails the last pill; this one never moves). Sized and styled + // like the toolbar buttons to its right — it reads as part of that + // trailing cluster, and hides/reveals with the downloads button (the + // toolbar owns the shared hover zone). _tabSearchButton = [[BroHoverButton alloc] - initWithFrame:NSMakeRect(frame.size.width - kAddTabButtonSize, addY, - kAddTabButtonSize, kAddTabButtonSize)]; + initWithFrame:NSMakeRect(frame.size.width - kButtonSize, + (frame.size.height - kButtonSize) / 2.0, + kButtonSize, kButtonSize)]; _tabSearchButton.bordered = NO; _tabSearchButton.title = @""; - _tabSearchButton.image = RadixIconImage(RadixIconChevronDown, 10); + _tabSearchButton.image = RadixIconImage(RadixIconMagnifyingGlass, 15); _tabSearchButton.imagePosition = NSImageOnly; _tabSearchButton.contentTintColor = [NSColor colorWithWhite:1.0 alpha:0.85]; - _tabSearchButton.layer.cornerRadius = 4.0; - _tabSearchButton.baseBorderWidth = 1.0; - _tabSearchButton.baseBorderColor = BroControlBorderColor(); _tabSearchButton.target = self; _tabSearchButton.action = @selector(toggleTabSearch:); _tabSearchButton.accessibilityLabel = @"Search tabs"; _tabSearchButton.toolTip = @"Search tabs (⇧⌘A)"; + _tabSearchButton.alphaValue = 0.0; [self addSubview:_tabSearchButton]; } return self; @@ -1051,7 +1056,7 @@ - (void)createNewTab:(id)sender { } - (void)toggleTabSearch:(id)sender { - ToggleTabSearchPanel(); + ToggleCommandPalette(BroPaletteScopeTabs); } - (void)addTabWithBrowserId:(int)browserId title:(NSString*)title { @@ -1072,13 +1077,28 @@ - (void)addTabWithBrowserId:(int)browserId title:(NSString*)title { tab.frame = NSMakeRect((_tabs.count - 1) * (tabWidth + kTabGap), pillY, tabWidth, kTabPillHeight); tab.alphaValue = 0.0; - [NSAnimationContext runAnimationGroup:^(NSAnimationContext* ctx) { - ctx.duration = 0.22; - ctx.timingFunction = [CAMediaTimingFunction - functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; + BroRunLayoutSpring(^{ [self applyTabLayout:YES]; - tab.animator.alphaValue = 1.0; - } completionHandler:nil]; + }, nil); + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + tab.alphaValue = 1.0; + [CATransaction commit]; + if (!BroMotionReduced()) { + CABasicAnimation* fade = [CABasicAnimation animationWithKeyPath:@"opacity"]; + fade.fromValue = @0.0; + fade.toValue = @1.0; + fade.duration = 0.15; + fade.timingFunction = [CAMediaTimingFunction + functionWithName:kCAMediaTimingFunctionEaseOut]; + [tab.layer addAnimation:fade forKey:@"bro.tab.enter.opacity"]; + CASpringAnimation* scale = + BroSpringForKeyPath(@"transform", BroSpringInteractive); + scale.fromValue = [NSValue valueWithCATransform3D: + BroCenteredScale(tab, 0.97)]; + scale.toValue = [NSValue valueWithCATransform3D:CATransform3DIdentity]; + [tab.layer addAnimation:scale forKey:@"bro.tab.enter.transform"]; + } [self.window recalculateKeyViewLoop]; } @@ -1174,12 +1194,9 @@ - (void)dragTab:(BroTabView*)tab withEvent:(NSEvent*)event { } else { [_tabs removeObjectAtIndex:current]; [_tabs insertObject:tab atIndex:hoverIndex]; - [NSAnimationContext runAnimationGroup:^(NSAnimationContext* ctx) { - ctx.duration = 0.15; - ctx.timingFunction = [CAMediaTimingFunction - functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; + BroRunLayoutSpring(^{ [self applyTabLayout:YES]; - } completionHandler:nil]; + }, nil); } } if (joinTargetTab_ != joinTarget) { @@ -1205,14 +1222,11 @@ - (BOOL)endDragForTab:(BroTabView*)tab { [self.window enableCursorRects]; [[NSCursor arrowCursor] set]; // Snap the released pill into its slot, then drop it back to pill level. - [NSAnimationContext runAnimationGroup:^(NSAnimationContext* ctx) { - ctx.duration = 0.15; - ctx.timingFunction = [CAMediaTimingFunction - functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; + BroRunLayoutSpring(^{ [self applyTabLayout:YES]; - } completionHandler:^{ + }, ^{ tab.layer.zPosition = 0.0; - }]; + }); [self.window recalculateKeyViewLoop]; if (joinTarget) { // A deferred tab close (dispatch_async) can pull either pill out of @@ -1281,12 +1295,9 @@ - (void)togglePinForTab:(BroTabView*)tab { [self ensureSplitPairAdjacent]; return; } - [NSAnimationContext runAnimationGroup:^(NSAnimationContext* ctx) { - ctx.duration = 0.18; - ctx.timingFunction = [CAMediaTimingFunction - functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; + BroRunLayoutSpring(^{ [self applyTabLayout:YES]; - } completionHandler:nil]; + }, nil); [self.window recalculateKeyViewLoop]; } @@ -1341,10 +1352,12 @@ - (void)removeTabWithBrowserId:(int)browserId { functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; tabToRemove.animator.frame = collapsed; tabToRemove.animator.alphaValue = 0.0; - [self applyTabLayout:YES]; } completionHandler:^{ [tabToRemove removeFromSuperview]; }]; + BroRunLayoutSpring(^{ + [self applyTabLayout:YES]; + }, nil); [self.window recalculateKeyViewLoop]; } } @@ -1362,9 +1375,21 @@ - (void)setActiveTab:(int)browserId { // The active pill hosts the shared editable address field. if (g_toolbar) { - [g_toolbar.addressField removeFromSuperview]; - if (activeTab) { - [activeTab attachAddressField:g_toolbar.addressField]; + NSTextField* addressField = g_toolbar.addressField; + // CEF may report the already-active browser again while its field editor + // is live. Re-parenting the same control in that state leaves AppKit's + // window-owned editor visible while attachAddressField restores the + // compact title, drawing both strings on top of each other. Keep same-tab + // activation idempotent; for a real switch, finish editing before moving + // the shared field to its new pill. + if (addressField.superview != activeTab) { + if (addressField.currentEditor) { + [self.window makeFirstResponder:nil]; + } + [addressField removeFromSuperview]; + if (activeTab) { + [activeTab attachAddressField:addressField]; + } } // The viewport toggles reflect the active tab's own emulation state. [g_toolbar setViewportMode:TabIsMobile(browserId)]; @@ -1376,12 +1401,9 @@ - (void)setActiveTab:(int)browserId { // expanded active). Animate it; with no pins and room for everyone, every // pill is already the same width and there is nothing to move. if (([self isCollapsed] || [self pinnedCount] > 0) && !dragging_) { - [NSAnimationContext runAnimationGroup:^(NSAnimationContext* ctx) { - ctx.duration = 0.18; - ctx.timingFunction = [CAMediaTimingFunction - functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; + BroRunLayoutSpring(^{ [self applyTabLayout:YES]; - } completionHandler:nil]; + }, nil); } } @@ -1425,9 +1447,10 @@ - (void)updateTabLoading:(int)browserId loading:(BOOL)loading { } - (CGFloat)availableStripWidth { - // The right edge reserves the trailing "+" button and the pinned tab - // search chevron. - return self.frame.size.width - 2.0 * (kAddTabButtonSize + 8.0); + // The right edge reserves the trailing "+" button and the pinned search + // button (toolbar-button sized). + return self.frame.size.width - (kAddTabButtonSize + 8.0) - + (kButtonSize + 8.0); } // Pinned pills are kept as a strict prefix of _tabs; every layout and drag @@ -1559,11 +1582,12 @@ - (void)applyTabLayout:(BOOL)animated { _addTabButton.frame = addTarget; } - // The chevron never trails the pills; it stays pinned at the right edge. + // The search button never trails the pills; it stays pinned at the right + // edge. _tabSearchButton.frame = - NSMakeRect(self.frame.size.width - kAddTabButtonSize, - (self.frame.size.height - kAddTabButtonSize) / 2.0, - kAddTabButtonSize, kAddTabButtonSize); + NSMakeRect(self.frame.size.width - kButtonSize, + (self.frame.size.height - kButtonSize) / 2.0, + kButtonSize, kButtonSize); } - (void)layoutTabs { @@ -1575,8 +1599,8 @@ - (void)resizeSubviewsWithOldSize:(NSSize)oldSize { [self layoutTabs]; // A resize invalidates the card's position; hiding beats tracking it. [self hideHoverCard]; - // Same for the tab search panel, which anchors to the chevron. - HideTabSearchPanel(); + // Same for the command palette, whose centering is now stale. + HideCommandPalette(); } - (void)tabSelected:(BroTabView*)tab { @@ -1674,7 +1698,7 @@ - (void)hideHoverCard { hoverCardTimer_ = nil; [hoverCardHideTimer_ invalidate]; hoverCardHideTimer_ = nil; - hoverCard_.hidden = YES; + BroOverlayHide(hoverCard_); hoverCardTabId_ = -1; } @@ -1782,12 +1806,9 @@ - (void)ensureSplitPairAdjacent { // Styling first: the joined-pair seam feeds the gap applyTabLayout uses // (and on split exit this restores the normal gap and corners). RefreshSplitPaneStyling(); - [NSAnimationContext runAnimationGroup:^(NSAnimationContext* ctx) { - ctx.duration = 0.18; - ctx.timingFunction = [CAMediaTimingFunction - functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; + BroRunLayoutSpring(^{ [self applyTabLayout:YES]; - } completionHandler:nil]; + }, nil); [self.window recalculateKeyViewLoop]; } @@ -1798,7 +1819,9 @@ - (void)showHoverCardForTab:(BroTabView*)tab { } if (!hoverCard_) { hoverCard_ = [[BroTabHoverCard alloc] initWithFrame:NSZeroRect]; + hoverCard_.hidden = YES; } + BOOL wasVisible = !hoverCard_.hidden; // Re-add last on every show: tab containers created since the previous // hover stack above the card, and the native CEF views inside them would // occlude it. @@ -1828,8 +1851,8 @@ - (void)showHoverCardForTab:(BroTabView*)tab { pageDescription:tab.pageDescription width:width]; } - [self positionHoverCardForTab:tab]; - hoverCard_.hidden = NO; + [self positionHoverCardForTab:tab animated:wasVisible]; + BroOverlayShow(hoverCard_); // Fetch the description on first hover per page; the card shows right away // with title + URL and fills in when (if) the result arrives. @@ -1843,7 +1866,7 @@ - (void)showHoverCardForTab:(BroTabView*)tab { // Hangs the card just below the toolbar, x-aligned with the pill and clamped // to the container edges. -- (void)positionHoverCardForTab:(BroTabView*)tab { +- (void)positionHoverCardForTab:(BroTabView*)tab animated:(BOOL)animated { NSView* container = hoverCard_.superview; if (!container || tab.superview != self) { return; @@ -1853,7 +1876,13 @@ - (void)positionHoverCardForTab:(BroTabView*)tab { CGFloat x = MIN(NSMinX(pill), NSMaxX(container.bounds) - size.width - 8.0); x = MAX(x, 8.0); CGFloat y = NSMaxY(container.bounds) - size.height - 6.0; - hoverCard_.frame = NSMakeRect(x, y, size.width, size.height); + NSRect target = NSMakeRect(x, y, size.width, size.height); + if (animated) { + BroSpringRetargetFrame(hoverCard_, target, BroSpringLayout, + @"bro.hover-card.frame"); + } else { + hoverCard_.frame = target; + } } - (void)updateTabDescription:(int)browserId description:(NSString*)desc { @@ -1871,7 +1900,7 @@ - (void)updateTabDescription:(int)browserId description:(NSString*)desc { url:BroDisplayHostForURL(tab.tabURL) pageDescription:tab.pageDescription width:hoverCard_.frame.size.width]; - [self positionHoverCardForTab:tab]; + [self positionHoverCardForTab:tab animated:YES]; } break; } diff --git a/src/bro_text_morph.h b/src/bro_text_morph.h new file mode 100644 index 0000000..33a64d0 --- /dev/null +++ b/src/bro_text_morph.h @@ -0,0 +1,19 @@ +// Native word/glyph text morphing for Bro's AppKit chrome. + +#ifndef BRO_TEXT_MORPH_H_ +#define BRO_TEXT_MORPH_H_ + +#import + +@interface BroTextMorphView : NSView + +- (instancetype)initWithFont:(NSFont*)font color:(NSColor*)color; +- (void)setText:(NSString*)text animated:(BOOL)animated; + +@property(nonatomic, copy, readonly) NSString* text; +@property(nonatomic, strong) NSColor* color; +@property(nonatomic, assign, readonly) NSSize desiredSize; + +@end + +#endif // BRO_TEXT_MORPH_H_ diff --git a/src/bro_text_morph.mm b/src/bro_text_morph.mm new file mode 100644 index 0000000..facc31c --- /dev/null +++ b/src/bro_text_morph.mm @@ -0,0 +1,592 @@ +#import "bro_text_morph.h" + +#import +#import + +#include +#include +#include +#include + +#import "bro_motion.h" + +@interface BroTextToken : NSObject +@property(nonatomic, copy) NSString* value; +@property(nonatomic, assign) NSRange range; +@end + +@implementation BroTextToken +@end + +// CATextLayer chooses its own baseline from its bounds, which can differ from +// NSTextField by a backing pixel even at the same nominal font size. Draw each +// segment with Core Text at an explicit baseline instead, so resting morph text +// and the editable AppKit field share identical metrics. +@interface BroTextLayer : CALayer +@property(nonatomic, copy) NSString* text; +@property(nonatomic, strong) NSFont* textFont; +@property(nonatomic, strong) NSColor* textColor; +@property(nonatomic, assign) CGFloat baselineFromBottom; +@end + +@implementation BroTextLayer + +- (instancetype)init { + self = [super init]; + if (self) { + self.needsDisplayOnBoundsChange = YES; + } + return self; +} + +- (void)setText:(NSString*)text { + _text = [text copy] ?: @""; + [self setNeedsDisplay]; +} + +- (void)setTextFont:(NSFont*)font { + _textFont = font; + [self setNeedsDisplay]; +} + +- (void)setTextColor:(NSColor*)color { + _textColor = color; + [self setNeedsDisplay]; +} + +- (void)setBaselineFromBottom:(CGFloat)baseline { + _baselineFromBottom = baseline; + [self setNeedsDisplay]; +} + +- (void)drawInContext:(CGContextRef)context { + if (_text.length == 0 || !_textFont || !_textColor) { + return; + } + NSAttributedString* attributed = [[NSAttributedString alloc] + initWithString:_text + attributes:@{ + NSFontAttributeName : _textFont, + NSForegroundColorAttributeName : _textColor, + }]; + CTLineRef line = CTLineCreateWithAttributedString( + (__bridge CFAttributedStringRef)attributed); + CGContextSetTextPosition(context, 0.0, _baselineFromBottom); + CTLineDraw(line, context); + CFRelease(line); +} + +@end + +@interface BroTextMorphSegment : NSObject +@property(nonatomic, copy) NSString* value; +@property(nonatomic, assign) NSRange range; +@property(nonatomic, assign) NSRange previousRange; +@property(nonatomic, strong) BroTextLayer* layer; +@property(nonatomic, assign) CGFloat targetX; +@property(nonatomic, assign) CGFloat width; +@property(nonatomic, assign) BOOL entering; +@end + +@implementation BroTextMorphSegment +@end + +namespace { + +NSArray* WordTokens(NSString* text) { + NSMutableArray* tokens = [NSMutableArray array]; + NSCharacterSet* whitespace = [NSCharacterSet whitespaceAndNewlineCharacterSet]; + NSUInteger index = 0; + while (index < text.length) { + while (index < text.length && + [whitespace characterIsMember:[text characterAtIndex:index]]) { + index++; + } + NSUInteger start = index; + while (index < text.length && + ![whitespace characterIsMember:[text characterAtIndex:index]]) { + index++; + } + if (index > start) { + BroTextToken* token = [[BroTextToken alloc] init]; + token.range = NSMakeRange(start, index - start); + token.value = [text substringWithRange:token.range]; + [tokens addObject:token]; + } + } + return tokens; +} + +NSArray* GraphemeTokens(NSString* text, NSRange range) { + NSMutableArray* tokens = [NSMutableArray array]; + [text enumerateSubstringsInRange:range + options:NSStringEnumerationByComposedCharacterSequences + usingBlock:^(NSString* substring, NSRange substringRange, + NSRange enclosingRange, BOOL* stop) { + BroTextToken* token = [[BroTextToken alloc] init]; + token.value = substring ?: @""; + token.range = substringRange; + [tokens addObject:token]; + }]; + return tokens; +} + +std::vector> LCSMatches( + NSArray* oldTokens, + NSArray* newTokens) { + const NSUInteger oldCount = oldTokens.count; + const NSUInteger newCount = newTokens.count; + std::vector> dp( + oldCount + 1, std::vector(newCount + 1, 0)); + for (NSInteger i = (NSInteger)oldCount - 1; i >= 0; --i) { + for (NSInteger j = (NSInteger)newCount - 1; j >= 0; --j) { + if ([oldTokens[(NSUInteger)i].value + isEqualToString:newTokens[(NSUInteger)j].value]) { + dp[(NSUInteger)i][(NSUInteger)j] = + dp[(NSUInteger)i + 1][(NSUInteger)j + 1] + 1; + } else { + dp[(NSUInteger)i][(NSUInteger)j] = + std::max(dp[(NSUInteger)i + 1][(NSUInteger)j], + dp[(NSUInteger)i][(NSUInteger)j + 1]); + } + } + } + + std::vector> matches; + NSUInteger i = 0; + NSUInteger j = 0; + while (i < oldCount && j < newCount) { + if ([oldTokens[i].value isEqualToString:newTokens[j].value]) { + matches.emplace_back(i++, j++); + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + i++; + } else { + j++; + } + } + return matches; +} + +CGFloat LineHeight(NSFont* font) { + return ceil(font.ascender - font.descender + font.leading); +} + +CATransform3D ScaleTransform(CGFloat scale) { + return CATransform3DMakeScale(scale, scale, 1.0); +} + +} // namespace + +@implementation BroTextMorphView { + NSFont* font_; + NSMutableArray* segments_; + NSMutableArray* exitingLayers_; + NSUInteger generation_; +} + +- (instancetype)initWithFont:(NSFont*)font color:(NSColor*)color { + self = [super initWithFrame:NSZeroRect]; + if (self) { + font_ = font ?: [NSFont systemFontOfSize:12.0]; + _color = color ?: [NSColor labelColor]; + _text = @""; + segments_ = [NSMutableArray array]; + exitingLayers_ = [NSMutableArray array]; + self.wantsLayer = YES; + self.layer.masksToBounds = YES; + _desiredSize = NSMakeSize(0.0, LineHeight(font_)); + } + return self; +} + +- (CGFloat)backingScale { + return self.window ? self.window.backingScaleFactor + : NSScreen.mainScreen.backingScaleFactor ?: 2.0; +} + +- (void)viewDidChangeBackingProperties { + [super viewDidChangeBackingProperties]; + CGFloat scale = [self backingScale]; + for (BroTextMorphSegment* segment in segments_) { + segment.layer.contentsScale = scale; + } + for (BroTextLayer* layer in exitingLayers_) { + layer.contentsScale = scale; + } +} + +- (void)setColor:(NSColor*)color { + _color = color ?: [NSColor labelColor]; + for (BroTextMorphSegment* segment in segments_) { + segment.layer.textColor = _color; + } + for (BroTextLayer* layer in exitingLayers_) { + layer.textColor = _color; + } +} + +- (CTLineRef)newLineForText:(NSString*)text { + NSAttributedString* attributed = [[NSAttributedString alloc] + initWithString:text ?: @"" + attributes:@{ + NSFontAttributeName : font_, + NSForegroundColorAttributeName : _color, + }]; + return CTLineCreateWithAttributedString( + (__bridge CFAttributedStringRef)attributed); +} + +- (CGFloat)xForIndex:(NSUInteger)index line:(CTLineRef)line { + return line ? CTLineGetOffsetForStringIndex(line, (CFIndex)index, nullptr) + : 0.0; +} + +- (CGFloat)segmentCenterY { + CGFloat lineHeight = LineHeight(font_); + CGFloat availableHeight = NSHeight(self.bounds); + return availableHeight > lineHeight ? availableHeight / 2.0 + : lineHeight / 2.0; +} + +- (BroTextMorphSegment*)newSegmentForToken:(BroTextToken*)token + line:(CTLineRef)line { + BroTextMorphSegment* segment = [[BroTextMorphSegment alloc] init]; + segment.value = token.value; + segment.range = token.range; + segment.previousRange = NSMakeRange(NSNotFound, 0); + segment.targetX = [self xForIndex:token.range.location line:line]; + CGFloat end = [self xForIndex:NSMaxRange(token.range) line:line]; + segment.width = MAX(1.0, ceil(end - segment.targetX)); + segment.entering = YES; + + BroTextLayer* layer = [BroTextLayer layer]; + layer.contentsScale = [self backingScale]; + layer.textFont = font_; + layer.textColor = _color; + layer.text = segment.value; + // The baseline within the line box is derived from the font descender; the + // box itself is vertically centered in the view. This matches the AppKit + // field editor's textContainerInset at every shared font size. + layer.baselineFromBottom = ceil(-font_.descender); + layer.bounds = CGRectMake(0.0, 0.0, segment.width, LineHeight(font_)); + layer.position = CGPointMake(segment.targetX + segment.width / 2.0, + [self segmentCenterY]); + segment.layer = layer; + [self.layer addSublayer:layer]; + return segment; +} + +- (NSArray*)segmentsInsideRange:(NSRange)range { + NSMutableArray* result = [NSMutableArray array]; + for (BroTextMorphSegment* segment in segments_) { + if (segment.range.location >= range.location && + NSMaxRange(segment.range) <= NSMaxRange(range)) { + [result addObject:segment]; + } + } + return result; +} + +- (NSArray*)splitSegmentsForWord:(BroTextToken*)word + line:(CTLineRef)oldLine { + NSArray* inside = [self segmentsInsideRange:word.range]; + if (inside.count != 1 || + !NSEqualRanges(inside.firstObject.range, word.range)) { + return inside; + } + NSArray* glyphs = GraphemeTokens(_text, word.range); + if (glyphs.count <= 1) { + return inside; + } + + BroTextMorphSegment* wordSegment = inside.firstObject; + CALayer* presentation = (CALayer*)wordSegment.layer.presentationLayer; + CGPoint visiblePosition = presentation ? presentation.position + : wordSegment.layer.position; + CGPoint modelPosition = wordSegment.layer.position; + CGFloat opacity = presentation ? presentation.opacity + : wordSegment.layer.opacity; + CATransform3D transform = presentation ? presentation.transform + : wordSegment.layer.transform; + [wordSegment.layer removeFromSuperlayer]; + [segments_ removeObject:wordSegment]; + + NSMutableArray* split = [NSMutableArray array]; + for (BroTextToken* glyph in glyphs) { + BroTextMorphSegment* segment = [self newSegmentForToken:glyph line:oldLine]; + segment.entering = NO; + segment.previousRange = segment.range; + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + segment.layer.position = CGPointMake( + segment.layer.position.x + visiblePosition.x - modelPosition.x, + segment.layer.position.y + visiblePosition.y - modelPosition.y); + segment.layer.opacity = opacity; + segment.layer.transform = transform; + [CATransaction commit]; + [segments_ addObject:segment]; + [split addObject:segment]; + } + return split; +} + +- (BroTextMorphSegment*)segmentForRange:(NSRange)range + among:(NSArray*)segments { + for (BroTextMorphSegment* segment in segments) { + if (NSEqualRanges(segment.range, range)) { + return segment; + } + } + return nil; +} + +- (void)rebuildImmediately:(NSString*)text line:(CTLineRef)line { + for (BroTextMorphSegment* segment in segments_) { + [segment.layer removeFromSuperlayer]; + } + for (BroTextLayer* layer in exitingLayers_) { + [layer removeFromSuperlayer]; + } + [segments_ removeAllObjects]; + [exitingLayers_ removeAllObjects]; + for (BroTextToken* token in WordTokens(text)) { + BroTextMorphSegment* segment = [self newSegmentForToken:token line:line]; + segment.entering = NO; + [segments_ addObject:segment]; + } +} + +- (void)updateDesiredSizeForLine:(CTLineRef)line { + CGFloat width = line ? ceil(CTLineGetTypographicBounds(line, nullptr, nullptr, + nullptr)) + : 0.0; + _desiredSize = NSMakeSize(MAX(0.0, width), LineHeight(font_)); +} + +- (void)setText:(NSString*)text animated:(BOOL)animated { + text = [text copy] ?: @""; + if ([_text isEqualToString:text]) { + return; + } + const NSUInteger generation = ++generation_; + for (CALayer* layer in exitingLayers_) { + [layer removeFromSuperlayer]; + } + [exitingLayers_ removeAllObjects]; + + CTLineRef newLine = [self newLineForText:text]; + [self updateDesiredSizeForLine:newLine]; + if (!animated || BroMotionReduced() || _text.length == 0) { + _text = text; + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + [self rebuildImmediately:text line:newLine]; + [CATransaction commit]; + CFRelease(newLine); + return; + } + + CTLineRef oldLine = [self newLineForText:_text]; + NSArray* oldWords = WordTokens(_text); + NSArray* newWords = WordTokens(text); + std::vector> wordMatches = + LCSMatches(oldWords, newWords); + + NSMutableArray* targets = [NSMutableArray array]; + NSHashTable* reused = + [NSHashTable hashTableWithOptions:NSHashTableObjectPointerPersonality]; + + auto reuseSegment = ^(BroTextMorphSegment* segment, + BroTextToken* oldToken, + BroTextToken* newToken) { + segment.previousRange = segment.range; + NSUInteger relative = segment.range.location - oldToken.range.location; + segment.range = NSMakeRange(newToken.range.location + relative, + segment.range.length); + segment.targetX = [self xForIndex:segment.range.location line:newLine]; + CGFloat end = [self xForIndex:NSMaxRange(segment.range) line:newLine]; + segment.width = MAX(1.0, ceil(end - segment.targetX)); + segment.entering = NO; + [reused addObject:segment]; + [targets addObject:segment]; + }; + + auto addNewToken = ^(BroTextToken* token) { + BroTextMorphSegment* segment = [self newSegmentForToken:token line:newLine]; + [targets addObject:segment]; + }; + + NSInteger previousOld = -1; + NSInteger previousNew = -1; + wordMatches.emplace_back(oldWords.count, newWords.count); + for (const auto& match : wordMatches) { + NSUInteger oldEnd = match.first; + NSUInteger newEnd = match.second; + NSUInteger oldStart = (NSUInteger)(previousOld + 1); + NSUInteger newStart = (NSUInteger)(previousNew + 1); + NSUInteger pairCount = std::min(oldEnd - oldStart, newEnd - newStart); + + for (NSUInteger offset = 0; offset < pairCount; ++offset) { + BroTextToken* oldWord = oldWords[oldStart + offset]; + BroTextToken* newWord = newWords[newStart + offset]; + NSArray* oldGlyphs = GraphemeTokens(_text, oldWord.range); + NSArray* newGlyphs = GraphemeTokens(text, newWord.range); + auto glyphMatches = LCSMatches(oldGlyphs, newGlyphs); + CGFloat similarity = + (CGFloat)glyphMatches.size() / + (CGFloat)MAX((NSUInteger)1, MAX(oldGlyphs.count, newGlyphs.count)); + if (similarity >= 0.4) { + NSArray* oldGlyphSegments = + [self splitSegmentsForWord:oldWord line:oldLine]; + NSHashTable* matchedNew = + [NSHashTable hashTableWithOptions:NSHashTableObjectPointerPersonality]; + for (const auto& glyphMatch : glyphMatches) { + BroTextToken* oldGlyph = oldGlyphs[glyphMatch.first]; + BroTextToken* newGlyph = newGlyphs[glyphMatch.second]; + BroTextMorphSegment* segment = + [self segmentForRange:oldGlyph.range among:oldGlyphSegments]; + if (segment) { + reuseSegment(segment, oldGlyph, newGlyph); + [matchedNew addObject:newGlyph]; + } + } + for (BroTextToken* newGlyph in newGlyphs) { + if (![matchedNew containsObject:newGlyph]) { + addNewToken(newGlyph); + } + } + } else { + addNewToken(newWord); + } + } + for (NSUInteger index = newStart + pairCount; index < newEnd; ++index) { + addNewToken(newWords[index]); + } + + if (oldEnd < oldWords.count && newEnd < newWords.count) { + BroTextToken* oldWord = oldWords[oldEnd]; + BroTextToken* newWord = newWords[newEnd]; + for (BroTextMorphSegment* segment in + [self segmentsInsideRange:oldWord.range]) { + reuseSegment(segment, oldWord, newWord); + } + previousOld = (NSInteger)oldEnd; + previousNew = (NSInteger)newEnd; + } + } + + [targets sortUsingComparator:^NSComparisonResult(BroTextMorphSegment* lhs, + BroTextMorphSegment* rhs) { + if (lhs.range.location < rhs.range.location) return NSOrderedAscending; + if (lhs.range.location > rhs.range.location) return NSOrderedDescending; + return NSOrderedSame; + }]; + + NSMutableArray* exiting = [NSMutableArray array]; + for (BroTextMorphSegment* oldSegment in [segments_ copy]) { + if (![reused containsObject:oldSegment]) { + oldSegment.previousRange = oldSegment.range; + [exiting addObject:oldSegment]; + } + } + + _text = text; + segments_ = targets; + CFTimeInterval duration = + BroSpringForKeyPath(@"position", BroSpringText).settlingDuration; + CFTimeInterval enterFade = MIN(duration * 0.5, 0.15); + CFTimeInterval enterDelay = MIN(duration * 0.25, 0.15); + CFTimeInterval exitFade = MIN(duration * 0.25, 0.15); + + for (BroTextMorphSegment* segment in segments_) { + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + segment.layer.bounds = CGRectMake(0.0, 0.0, segment.width, + LineHeight(font_)); + [CATransaction commit]; + CGPoint target = CGPointMake(segment.targetX + segment.width / 2.0, + [self segmentCenterY]); + if (!segment.entering) { + BroSpringRetargetPosition(segment.layer, target, BroSpringText, + @"bro.text.persist"); + continue; + } + + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + segment.layer.position = target; + segment.layer.opacity = 1.0; + segment.layer.transform = CATransform3DIdentity; + [CATransaction commit]; + CABasicAnimation* fade = [CABasicAnimation animationWithKeyPath:@"opacity"]; + fade.fromValue = @0.0; + fade.toValue = @1.0; + fade.duration = enterFade; + fade.beginTime = CACurrentMediaTime() + enterDelay; + fade.fillMode = kCAFillModeBackwards; + [segment.layer addAnimation:fade forKey:@"bro.text.enter.opacity"]; + CASpringAnimation* scale = + BroSpringForKeyPath(@"transform", BroSpringText); + scale.fromValue = [NSValue valueWithCATransform3D:ScaleTransform(0.95)]; + scale.toValue = + [NSValue valueWithCATransform3D:CATransform3DIdentity]; + [segment.layer addAnimation:scale forKey:@"bro.text.enter.transform"]; + } + + for (BroTextMorphSegment* segment in exiting) { + BroTextMorphSegment* neighbor = nil; + NSUInteger bestDistance = NSUIntegerMax; + for (BroTextMorphSegment* candidate in segments_) { + if (candidate.previousRange.location == NSNotFound) { + continue; + } + NSUInteger lhs = segment.previousRange.location; + NSUInteger rhs = candidate.previousRange.location; + NSUInteger distance = lhs > rhs ? lhs - rhs : rhs - lhs; + if (distance < bestDistance) { + bestDistance = distance; + neighbor = candidate; + } + } + CGPoint exitTarget = neighbor ? neighbor.layer.position + : segment.layer.position; + BroSpringRetargetPosition(segment.layer, exitTarget, BroSpringText, + @"bro.text.exit.position"); + CALayer* presentation = (CALayer*)segment.layer.presentationLayer; + CATransform3D fromTransform = presentation ? presentation.transform + : segment.layer.transform; + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + segment.layer.opacity = 0.0; + segment.layer.transform = ScaleTransform(0.95); + [CATransaction commit]; + CABasicAnimation* fade = [CABasicAnimation animationWithKeyPath:@"opacity"]; + fade.fromValue = @(presentation ? presentation.opacity : 1.0); + fade.toValue = @0.0; + fade.duration = exitFade; + [segment.layer addAnimation:fade forKey:@"bro.text.exit.opacity"]; + CASpringAnimation* scale = + BroSpringForKeyPath(@"transform", BroSpringText); + scale.fromValue = [NSValue valueWithCATransform3D:fromTransform]; + scale.toValue = [NSValue valueWithCATransform3D:ScaleTransform(0.95)]; + [segment.layer addAnimation:scale forKey:@"bro.text.exit.transform"]; + [exitingLayers_ addObject:segment.layer]; + } + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(duration * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + if (self->generation_ != generation) { + return; + } + for (BroTextLayer* layer in [self->exitingLayers_ copy]) { + [layer removeFromSuperlayer]; + } + [self->exitingLayers_ removeAllObjects]; + }); + + CFRelease(oldLine); + CFRelease(newLine); +} + +@end diff --git a/src/bro_toolbar.mm b/src/bro_toolbar.mm index d52e95f..4899e7a 100644 --- a/src/bro_toolbar.mm +++ b/src/bro_toolbar.mm @@ -6,6 +6,7 @@ #include "include/cef_browser.h" #include "bro_handler.h" #import "bro_mac_internal.h" +#import "bro_motion.h" #import "radix_icons.h" #pragma mark - BroToolbar @@ -13,6 +14,40 @@ // g_toolbar is declared extern in bro_mac_internal.h. BroToolbar* g_toolbar = nil; +// Resolves what the user typed into a loadable URL: safe schemes pass +// through, about: passes through, a dotted word gets https://, and anything +// else becomes a Google search. Shared by the address field and the command +// palette's fallback row (which shows the classification before navigating). +// Declared extern in bro_mac_internal.h. |query| must already be trimmed; +// returns nil for an empty query. +NSString* BroResolveQueryToURL(NSString* query) { + if (query.length == 0) { + return nil; + } + NSString* lower = query.lowercaseString; + + if ([query containsString:@"://"]) { + // Only navigate to safe schemes; anything else (javascript:, data:, ...) + // falls through to search. + if ([lower hasPrefix:@"http://"] || [lower hasPrefix:@"https://"] || + [lower hasPrefix:@"file://"]) { + return query; + } + } else if ([lower hasPrefix:@"about:"]) { + return query; + } else if ([query containsString:@"."] && ![query containsString:@" "]) { + return [@"https://" stringByAppendingString:query]; + } + + // Treat as search query. Encode everything outside the unreserved set so + // characters like & and + can't alter the query string. + NSMutableCharacterSet* allowed = [NSMutableCharacterSet alphanumericCharacterSet]; + [allowed addCharactersInString:@"-._~"]; + NSString* encoded = [query stringByAddingPercentEncodingWithAllowedCharacters:allowed]; + return [NSString stringWithFormat:@"https://www.google.com/search?q=%@", + encoded ?: @""]; +} + // BroAddressField's @interface is declared in bro_mac_internal.h (shared // with bro_mac.mm, where BroTabView checks isKindOfClass:[BroAddressField // class]). BroToolbar's @interface is declared there too (shared with @@ -42,7 +77,14 @@ - (BOOL)becomeFirstResponder { @end -@implementation BroToolbar +@implementation BroToolbar { + // Hover zone spanning the search + downloads buttons; both stay hidden + // (alpha 0) until the pointer enters it. + NSTrackingArea* revealTrackingArea_; + BOOL revealZoneHovered_; + // Invalidates a pending auto-hide scheduled by pulseDownloadsButton. + NSUInteger revealGeneration_; +} - (instancetype)initWithFrame:(NSRect)frame { self = [super initWithFrame:frame]; @@ -82,16 +124,22 @@ - (instancetype)initWithFrame:(NSRect)frame { _refreshButton.toolTip = @"Reload page (⌘R)"; [self addSubview:_refreshButton]; + _navigationHighlightGroup = + [[BroHoverHighlightGroup alloc] initWithContainerView:self]; + _backButton.highlightGroup = _navigationHighlightGroup; + _forwardButton.highlightGroup = _navigationHighlightGroup; + _refreshButton.highlightGroup = _navigationHighlightGroup; + // The editable address field lives inside the ACTIVE tab pill (the tab // strip re-parents it on tab switches); created here without a superview. - _addressField = [[BroAddressField alloc] initWithFrame:NSMakeRect(0, 0, 100, 18)]; - _addressField.font = BroUIFont(12.0); + _addressField = [[BroAddressField alloc] + initWithFrame:NSMakeRect(0, 0, 100, kTabTextFrameHeight)]; + _addressField.font = BroUIFont(kTabTextFontSize); _addressField.bezeled = NO; _addressField.bordered = NO; _addressField.drawsBackground = NO; _addressField.focusRingType = NSFocusRingTypeNone; _addressField.textColor = [NSColor labelColor]; - _addressField.placeholderString = @"Enter URL or search"; _addressField.delegate = self; _addressField.cell.scrollable = YES; _addressField.cell.usesSingleLineMode = YES; @@ -99,6 +147,22 @@ - (instancetype)initWithFrame:(NSRect)frame { // scrolls while typing. _addressField.cell.lineBreakMode = NSLineBreakByTruncatingTail; _addressField.cell.truncatesLastVisibleLine = YES; + // AppKit draws an empty field's placeholder through NSTextFieldCell, not + // through the field editor. Its borderless-cell default sits at the + // bottom of our 18pt frame, 3pt below the centered tab baseline. Apply the + // missing half-leading explicitly so the placeholder occupies the exact + // same origin as resting and selected text. + CGFloat placeholderHeight = + [_addressField.cell cellSizeForBounds:_addressField.bounds].height; + CGFloat placeholderBaselineOffset = ceil( + MAX(0.0, (kTabTextFrameHeight - placeholderHeight) / 2.0)); + _addressField.placeholderAttributedString = [[NSAttributedString alloc] + initWithString:@"Enter URL or search" + attributes:@{ + NSFontAttributeName : BroUIFont(kTabTextFontSize), + NSForegroundColorAttributeName : [NSColor placeholderTextColor], + NSBaselineOffsetAttributeName : @(placeholderBaselineOffset), + }]; _addressField.accessibilityLabel = @"Address and search bar"; // Viewport mode toggles pinned to the right edge. Exposed as a radio @@ -119,15 +183,24 @@ - (instancetype)initWithFrame:(NSRect)frame { _desktopButton.autoresizingMask = NSViewMinXMargin; [_desktopButton setAccessibilityRole:NSAccessibilityRadioButtonRole]; [self addSubview:_desktopButton]; - // Downloads sits left of the viewport radio group, set off by a wider - // gap so it doesn't read as a third mode. - rightX -= kButtonSize + kButtonSpacing + 8.0; + // Downloads sits left of the viewport radio group at the same spacing — + // the four trailing icons (search, downloads, desktop, mobile) read as + // one evenly spaced cluster. + rightX -= kButtonSize + kButtonSpacing; _downloadsButton = [self createButtonWithFrame:NSMakeRect(rightX, y, kButtonSize, kButtonSize) icon:RadixIconDownload action:@selector(toggleDownloads:) label:@"Downloads"]; _downloadsButton.autoresizingMask = NSViewMinXMargin; + // Hidden until the pointer enters the shared reveal zone (see + // updateTrackingAreas), together with the tab strip's search button. + _downloadsButton.alphaValue = 0.0; [self addSubview:_downloadsButton]; + _trailingHighlightGroup = + [[BroHoverHighlightGroup alloc] initWithContainerView:self]; + _downloadsButton.highlightGroup = _trailingHighlightGroup; + _desktopButton.highlightGroup = _trailingHighlightGroup; + _mobileButton.highlightGroup = _trailingHighlightGroup; // The selected toggle is disabled but must keep its bright tint, so don't // let AppKit dim the icon. ((NSButtonCell*)_mobileButton.cell).imageDimsWhenDisabled = NO; @@ -159,6 +232,91 @@ - (BroHoverButton*)createButtonWithFrame:(NSRect)frame return button; } +#pragma mark - Search/downloads hover reveal + +// One tracking rect covers both hideable icons — the tab strip's search +// button and the downloads button — plus the gap between them, full toolbar +// height, so the pair reveals as soon as the pointer nears either. The rect +// is explicit (not InVisibleRect) because it spans a subview of a sibling; +// AppKit re-invokes this on geometry changes, and bro_mac.mm calls it once +// more after the tab bar exists. +- (void)updateTrackingAreas { + [super updateTrackingAreas]; + if (revealTrackingArea_) { + [self removeTrackingArea:revealTrackingArea_]; + revealTrackingArea_ = nil; + } + NSRect zone = _downloadsButton.frame; + NSView* searchButton = g_tab_bar.tabSearchButton; + if (searchButton && searchButton.window == self.window) { + zone = NSUnionRect(zone, + [self convertRect:searchButton.bounds + fromView:searchButton]); + } + zone = NSInsetRect(zone, -8.0, 0); + zone.origin.y = 0; + zone.size.height = NSHeight(self.bounds); + revealTrackingArea_ = [[NSTrackingArea alloc] + initWithRect:zone + options:NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways + owner:self + userInfo:nil]; + [self addTrackingArea:revealTrackingArea_]; +} + +- (void)setSearchDownloadsRevealed:(BOOL)revealed { + CGFloat alpha = revealed ? 1.0 : 0.0; + NSView* searchButton = g_tab_bar.tabSearchButton; + if (BroMotionReduced()) { + _downloadsButton.alphaValue = alpha; + searchButton.alphaValue = alpha; + return; + } + [NSAnimationContext runAnimationGroup:^(NSAnimationContext* ctx) { + ctx.duration = kCloseButtonFadeDuration; + _downloadsButton.animator.alphaValue = alpha; + searchButton.animator.alphaValue = alpha; + }]; +} + +- (void)mouseEntered:(NSEvent*)event { + revealZoneHovered_ = YES; + [self setSearchDownloadsRevealed:YES]; +} + +- (void)mouseExited:(NSEvent*)event { + revealZoneHovered_ = NO; + [self setSearchDownloadsRevealed:NO]; +} + +// Nudge when a download starts or finishes. With the icons hidden at rest, +// the nudge is the reveal itself: fade the pair in, then back out unless +// the pointer is in the zone. If they're already revealed, fall back to the +// original opacity dip on the downloads button. Declared in +// bro_mac_internal.h; called from bro_downloads.mm. +- (void)pulseDownloadsButton { + revealGeneration_++; + NSUInteger generation = revealGeneration_; + if (revealZoneHovered_) { + if (!BroMotionReduced()) { + _downloadsButton.alphaValue = 0.25; + [NSAnimationContext runAnimationGroup:^(NSAnimationContext* ctx) { + ctx.duration = 0.5; + _downloadsButton.animator.alphaValue = 1.0; + }]; + } + return; + } + [self setSearchDownloadsRevealed:YES]; + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + if (generation == revealGeneration_ && !revealZoneHovered_) { + [self setSearchDownloadsRevealed:NO]; + } + }); +} + #pragma mark - Navigation Actions - (void)goBack:(id)sender { @@ -194,32 +352,8 @@ - (void)refresh:(id)sender { - (void)navigateToURL:(NSString*)urlString { urlString = [urlString stringByTrimmingCharactersInSet: [NSCharacterSet whitespaceAndNewlineCharacterSet]]; - if (urlString.length == 0) return; - - NSString* lower = urlString.lowercaseString; - NSString* target = nil; - - if ([urlString containsString:@"://"]) { - // Only navigate to safe schemes; anything else (javascript:, data:, ...) - // falls through to search. - if ([lower hasPrefix:@"http://"] || [lower hasPrefix:@"https://"] || - [lower hasPrefix:@"file://"]) { - target = urlString; - } - } else if ([lower hasPrefix:@"about:"]) { - target = urlString; - } else if ([urlString containsString:@"."] && ![urlString containsString:@" "]) { - target = [@"https://" stringByAppendingString:urlString]; - } - - if (!target) { - // Treat as search query. Encode everything outside the unreserved set so - // characters like & and + can't alter the query string. - NSMutableCharacterSet* allowed = [NSMutableCharacterSet alphanumericCharacterSet]; - [allowed addCharactersInString:@"-._~"]; - NSString* encoded = [urlString stringByAddingPercentEncodingWithAllowedCharacters:allowed]; - target = [NSString stringWithFormat:@"https://www.google.com/search?q=%@", encoded ?: @""]; - } + NSString* target = BroResolveQueryToURL(urlString); + if (!target) return; BroHandler* handler = BroHandler::GetInstance(); if (handler) { @@ -290,6 +424,49 @@ - (void)setViewportMode:(BOOL)mobile { #pragma mark - NSTextFieldDelegate +- (BOOL)configureAddressFieldEditor { + NSTextView* editor = (NSTextView*)[_addressField currentEditor]; + if (![editor isKindOfClass:[NSTextView class]]) { + return NO; + } + + NSFont* font = BroUIFont(kTabTextFontSize); + editor.font = font; + editor.insertionPointColor = [NSColor whiteColor]; + editor.textColor = [NSColor whiteColor]; + editor.drawsBackground = NO; + + // NSTextView adds five points of line-fragment padding by default. The + // resting Core Text renderer starts at x=0, so leaving that default in + // place makes the selected URL jump right on focus even though both views + // have the same frame. Remove every editor-owned inset explicitly. + editor.textContainer.lineFragmentPadding = 0.0; + + // Use the exact Core Text baseline equation from BroTextMorphView. AppKit's + // cell reports a 13pt height for Geist 10 while Core Text's pixel-aligned + // line box is 14pt; centering the cell therefore puts selected text 1pt + // above the resting host. Deriving the editor inset from the same font + // metrics keeps both renderers on precisely the same baseline. + CGFloat lineHeight = + ceil(font.ascender - font.descender + font.leading); + CGFloat baselineFromBottom = ceil(-font.descender); + CGFloat baselineFromTop = + (_addressField.bounds.size.height + lineHeight) / 2.0 - + baselineFromBottom; + editor.textContainerInset = + NSMakeSize(0.0, MAX(0.0, baselineFromTop - font.ascender)); + + // Selection inverts against the dark chrome: a white highlight with + // near-black glyphs, instead of the system's blue-on-white. + editor.selectedTextAttributes = @{ + NSBackgroundColorAttributeName : [NSColor whiteColor], + NSForegroundColorAttributeName : + [NSColor colorWithWhite:0x11 / 255.0 alpha:1.0], + }; + [editor selectAll:nil]; + return YES; +} + - (void)addressFieldDidFocus { // Show the full URL for editing; the host pill shows the focused look // (gray hairline border, pure white text) from click-in through typing. @@ -300,32 +477,21 @@ - (void)addressFieldDidFocus { ((BroTabView*)_addressField.superview).editingAddress = YES; } _addressField.textColor = [NSColor whiteColor]; - dispatch_async(dispatch_get_main_queue(), ^{ - NSTextView* editor = (NSTextView*)[self.addressField currentEditor]; - if ([editor isKindOfClass:[NSTextView class]]) { - editor.insertionPointColor = [NSColor whiteColor]; - editor.textColor = [NSColor whiteColor]; - editor.drawsBackground = NO; - // AppKit pins field-editor text to the top of its bounds, while the - // settled URL is vertically centered by the text-field cell. Use the - // settled URL's baseline for editing too, so focusing and typing never - // makes the text jump. - CGFloat textHeight = - [self.addressField.cell cellSizeForBounds:self.addressField.bounds] - .height; - editor.textContainerInset = NSMakeSize( - 0.0, MAX(0.0, (self.addressField.bounds.size.height - textHeight) / - 2.0)); - // Selection inverts against the dark chrome: a white highlight with - // near-black glyphs, instead of the system's blue-on-white. - editor.selectedTextAttributes = @{ - NSBackgroundColorAttributeName : [NSColor whiteColor], - NSForegroundColorAttributeName : - [NSColor colorWithWhite:0x11 / 255.0 alpha:1.0], - }; - [editor selectAll:nil]; - } - }); + // Configure synchronously so AppKit never presents one frame with its + // default font/insets before snapping to the shared tab metrics. Some + // responder paths install the field editor just after becomeFirstResponder; + // retain a one-turn fallback only for those paths. + if (![self configureAddressFieldEditor]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self configureAddressFieldEditor]; + }); + } +} + +- (void)controlTextDidBeginEditing:(NSNotification*)notification { + if (notification.object == _addressField) { + [self configureAddressFieldEditor]; + } } - (void)controlTextDidEndEditing:(NSNotification*)notification { @@ -348,7 +514,15 @@ - (void)controlTextDidEndEditing:(NSNotification*)notification { - (void)focusAddressField { if (_addressField.superview) { - [self.window makeFirstResponder:_addressField]; + BroTabView* tab = [_addressField.superview isKindOfClass:[BroTabView class]] + ? (BroTabView*)_addressField.superview + : nil; + // A hidden control cannot become first responder. Reveal the editor first; + // becomeFirstResponder then replaces the compact host with the full URL. + tab.editingAddress = YES; + if (![self.window makeFirstResponder:_addressField]) { + tab.editingAddress = NO; + } } } From dc24a5eba5693ed331fc1c816319cb471c1f247e Mon Sep 17 00:00:00 2001 From: Jose Rago <13522179+ragojose@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:59:20 -0300 Subject: [PATCH 2/3] Show glass backdrop on blank tabs and keep the chrome row always glassy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window's behind-window blur (contentView NSVisualEffectView) was fully covered by an opaque black tint. Split that backdrop at the toolbar line: the chrome row now wears a permanent 60% black scrim — the same tint as the command palette's glass — and the browser-area tint cross-fades down to that scrim whenever every visible pane is blank, so new tabs read as dark glass over the desktop. The fade hooks into UpdateTabContainerVisibility, animates via the view animator so rapid tab flips retarget cleanly, and collapses to an instant switch under Reduce Motion. Co-Authored-By: Claude Fable 5 --- src/bro_mac.mm | 61 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/src/bro_mac.mm b/src/bro_mac.mm index f0cdc5f..858f3e9 100644 --- a/src/bro_mac.mm +++ b/src/bro_mac.mm @@ -203,11 +203,23 @@ static CGFloat BroWindowCornerRadius(NSWindow* window) { return kWindowCornerRadiusFallback; } -@interface BroWindow : NSWindow +// Dark-glass scrim over the behind-window blur (the contentView +// NSVisualEffectView): the chrome row wears it permanently, and the +// browser-area tint fades down to it when the active tab is blank. Matches +// the command palette's 60% black glass tint (bro_palette.mm) so both glass +// surfaces read as the same material. +static const CGFloat kGlassBackdropScrimAlpha = 0.6; +static const CFTimeInterval kGlassBackdropFadeDuration = 0.30; + +@interface BroWindow : NSWindow { + BOOL _glassBackdropVisible; +} @property (nonatomic, strong) NSView* browserContainer; @property (nonatomic, strong) BroToolbar* navToolbar; @property (nonatomic, strong) BroTabBar* tabBar; @property (nonatomic, strong) NSView* borderOverlay; +@property (nonatomic, strong) NSView* backdropTint; +- (void)setGlassBackdropVisible:(BOOL)visible; @end NSView* BroBrowserContainerView(void) { @@ -250,13 +262,28 @@ - (instancetype)init { content.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; self.contentView = content; - // Opaque black backdrop: every surface — chrome and browser area alike — - // sits on pure #000. - NSView* tint = [[NSView alloc] initWithFrame:frame]; + // The window backdrop splits at the toolbar line. The chrome row keeps a + // permanent partial scrim so it always reads as dark glass over the + // behind-window blur; the browser area below carries an opaque black tint + // that fades to the same scrim when the active tab is blank (see + // -setGlassBackdropVisible:). CEF pages are opaque, so with a page showing + // the browser-area tint is only visible in the mobile-mode gutters. + CGFloat chromeY = frame.size.height - kToolbarHeight; + NSView* tint = [[NSView alloc] + initWithFrame:NSMakeRect(0, 0, frame.size.width, chromeY)]; tint.wantsLayer = YES; tint.layer.backgroundColor = [NSColor blackColor].CGColor; tint.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; [content addSubview:tint]; + _backdropTint = tint; + + NSView* chromeTint = [[NSView alloc] + initWithFrame:NSMakeRect(0, chromeY, frame.size.width, kToolbarHeight)]; + chromeTint.wantsLayer = YES; + chromeTint.layer.backgroundColor = [NSColor blackColor].CGColor; + chromeTint.alphaValue = kGlassBackdropScrimAlpha; + chromeTint.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin; + [content addSubview:chromeTint]; // Single chrome row at the very top; its content is inset past the // traffic lights. @@ -315,6 +342,24 @@ - (instancetype)init { return self; } +// Cross-fades the browser-area black tint to the chrome row's partial scrim +// (glass on) or back to opaque (glass off). Animates alphaValue via the +// animator so rapid tab flips retarget the in-flight fade; `hidden` is never +// touched — a hidden tint would flash the window transparent. +- (void)setGlassBackdropVisible:(BOOL)visible { + if (visible == _glassBackdropVisible) { + return; + } + _glassBackdropVisible = visible; + [NSAnimationContext runAnimationGroup:^(NSAnimationContext* ctx) { + ctx.duration = BroMotionReduced() ? 0.0 : kGlassBackdropFadeDuration; + ctx.timingFunction = [CAMediaTimingFunction + functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; + self.backdropTint.animator.alphaValue = + visible ? kGlassBackdropScrimAlpha : 1.0; + } completionHandler:nil]; +} + @end #pragma mark - CreateNewBrowserTab @@ -675,6 +720,14 @@ static void UpdateTabContainerVisibility(int active_browser_id) { } } UpdateSplitDivider(); + // Glass backdrop iff every visible pane is blank: a blank tab draws no page + // pixels, so the backdrop is its entire surface. With a split showing one + // loaded pane the window stays opaque black — half-glass at the divider + // would look broken. active_browser_id == -1 (no tabs) resolves to black. + BOOL activeBlank = [g_blank_tab_ids containsObject:@(active_browser_id)]; + BOOL partnerBlank = + !SplitActive() || [g_blank_tab_ids containsObject:@(g_split_browser_id)]; + [g_main_window setGlassBackdropVisible:(activeBlank && partnerBlank)]; } static void UpdateChromeLayout(void) { From c399f1630e81b4fa6e2c6c90bbc9c6a64bca47bc Mon Sep 17 00:00:00 2001 From: Jose Rago <13522179+ragojose@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:03:47 -0300 Subject: [PATCH 3/3] Apply concentric corner radii across the app --- CMakeLists.txt | 1 + src/bro_downloads.mm | 6 +++--- src/bro_geometry.h | 31 +++++++++++++++++++++++++++++++ src/bro_mac.mm | 4 ++-- src/bro_mac_internal.h | 4 +++- src/bro_motion.h | 2 ++ src/bro_motion.mm | 2 +- src/bro_palette.mm | 13 ++++++++----- src/bro_tabstrip.mm | 14 ++++++++++---- 9 files changed, 61 insertions(+), 16 deletions(-) create mode 100644 src/bro_geometry.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 742d0c8..ed8d5db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -61,6 +61,7 @@ set(BRO_SRCS src/bro_closed_tabs.mm src/bro_downloads.mm src/bro_favicon.mm + src/bro_geometry.h src/bro_handler.cc src/bro_handler.h src/bro_handler_mac.mm diff --git a/src/bro_downloads.mm b/src/bro_downloads.mm index 6d56da3..c82f349 100644 --- a/src/bro_downloads.mm +++ b/src/bro_downloads.mm @@ -133,7 +133,7 @@ - (instancetype)initWithFrame:(NSRect)frame { self = [super initWithFrame:frame]; if (self) { self.wantsLayer = YES; - self.layer.cornerRadius = 8.0; + self.layer.cornerRadius = kSurfaceCornerRadius; BroApplyElevation(self, BroElevationPanel); rowHighlightGroup_ = [[BroHoverHighlightGroup alloc] initWithContainerView:self]; @@ -318,7 +318,7 @@ - (NSView*)makeRowForEntry:(BroDownloadEntry*)entry index:(NSUInteger)index { NSView* track = [[NSView alloc] initWithFrame:NSMakeRect(textX, 4.0, barWidth, 2.0)]; track.wantsLayer = YES; - track.layer.cornerRadius = 1.0; + track.layer.cornerRadius = BroCapsuleCornerRadius(NSHeight(track.frame)); track.layer.backgroundColor = [NSColor colorWithWhite:1.0 alpha:0.15].CGColor; [row addSubview:track]; @@ -328,7 +328,7 @@ - (NSView*)makeRowForEntry:(BroDownloadEntry*)entry index:(NSUInteger)index { NSView* fill = [[NSView alloc] initWithFrame:NSMakeRect(textX, 4.0, barWidth * fraction, 2.0)]; fill.wantsLayer = YES; - fill.layer.cornerRadius = 1.0; + fill.layer.cornerRadius = BroCapsuleCornerRadius(NSHeight(fill.frame)); fill.layer.backgroundColor = [NSColor colorWithWhite:1.0 alpha:0.85].CGColor; [row addSubview:fill]; diff --git a/src/bro_geometry.h b/src/bro_geometry.h new file mode 100644 index 0000000..9ff5e12 --- /dev/null +++ b/src/bro_geometry.h @@ -0,0 +1,31 @@ +// Shared geometry primitives for Bro's AppKit surfaces. + +#ifndef BRO_GEOMETRY_H_ +#define BRO_GEOMETRY_H_ + +#import + +// Corner radii follow the concentric-corner rule throughout the app: +// +// outer radius - gap = inner radius +// +// Nested surfaces derive their curve from the boundary around them instead +// of repeating visually similar magic numbers. Standalone surfaces establish +// the outer radius; capsules derive theirs from their own height. +// https://cloudfour.com/thinks/the-math-behind-nesting-rounded-corners/ +static inline CGFloat BroNestedCornerRadius(CGFloat outerRadius, CGFloat gap) { + return MAX(0.0, outerRadius - MAX(0.0, gap)); +} + +static inline CGFloat BroCapsuleCornerRadius(CGFloat height) { + return height / 2.0; +} + +static const CGFloat kSurfaceCornerRadius = 8.0; +static const CGFloat kControlCornerGap = 2.0; +static const CGFloat kControlCornerRadius = + BroNestedCornerRadius(kSurfaceCornerRadius, kControlCornerGap); +static const CGFloat kCompactControlCornerRadius = + BroNestedCornerRadius(kControlCornerRadius, kControlCornerGap); + +#endif // BRO_GEOMETRY_H_ diff --git a/src/bro_mac.mm b/src/bro_mac.mm index 858f3e9..efd32b6 100644 --- a/src/bro_mac.mm +++ b/src/bro_mac.mm @@ -443,7 +443,7 @@ - (instancetype)initWithFrame:(NSRect)frame { initWithFrame:NSMakeRect((kSplitDividerGrabWidth - 4.0) / 2.0, (frame.size.height - 44.0) / 2.0, 4.0, 44.0)]; grip_.wantsLayer = YES; - grip_.layer.cornerRadius = 2.0; + grip_.layer.cornerRadius = BroCapsuleCornerRadius(NSWidth(grip_.frame)); grip_.layer.backgroundColor = [NSColor colorWithWhite:1.0 alpha:0.4].CGColor; grip_.layer.actions = BroLayerTransitionActions(); @@ -987,7 +987,7 @@ static void ShowZoomHUD(int percent) { if (!g_zoom_hud) { g_zoom_hud = [[NSView alloc] initWithFrame:NSZeroRect]; g_zoom_hud.wantsLayer = YES; - g_zoom_hud.layer.cornerRadius = 8.0; + g_zoom_hud.layer.cornerRadius = kSurfaceCornerRadius; BroApplyElevation(g_zoom_hud, BroElevationOverlay); g_zoom_hud.hidden = YES; diff --git a/src/bro_mac_internal.h b/src/bro_mac_internal.h index be7a218..5754109 100644 --- a/src/bro_mac_internal.h +++ b/src/bro_mac_internal.h @@ -15,6 +15,8 @@ #import +#import "bro_geometry.h" + #include @class BroToolbar; @@ -52,7 +54,7 @@ static const CGFloat kTabTextFrameHeight = 18.0; static const CGFloat kTabPillSquareWidth = kTabPillHeight; // The "+" button is half the pill's size, centered on the same midline. static const CGFloat kAddTabButtonSize = kTabPillHeight / 2.0; -static const CGFloat kPillCornerRadius = 8.0; +static const CGFloat kPillCornerRadius = kSurfaceCornerRadius; static const CGFloat kTrafficLightInset = 100.0; static const CGFloat kMobileViewportWidth = 390.0; static const CGFloat kMobileViewportHeight = 844.0; // matches CDP metrics diff --git a/src/bro_motion.h b/src/bro_motion.h index 6dcd21e..c1e0a49 100644 --- a/src/bro_motion.h +++ b/src/bro_motion.h @@ -6,6 +6,8 @@ #import #import +#import "bro_geometry.h" + typedef NS_ENUM(NSInteger, BroSpringPreset) { BroSpringText, BroSpringTextTight, diff --git a/src/bro_motion.mm b/src/bro_motion.mm index e1436f3..ac9c2c5 100644 --- a/src/bro_motion.mm +++ b/src/bro_motion.mm @@ -367,7 +367,7 @@ - (instancetype)initWithContainerView:(NSView*)container { container.wantsLayer = YES; highlightLayer_ = [CALayer layer]; highlightLayer_.anchorPoint = CGPointZero; - highlightLayer_.cornerRadius = 6.0; + highlightLayer_.cornerRadius = kControlCornerRadius; highlightLayer_.backgroundColor = [NSColor colorWithWhite:1.0 alpha:0.08].CGColor; highlightLayer_.opacity = 0.0; diff --git a/src/bro_palette.mm b/src/bro_palette.mm index f6889f7..b8d20ef 100644 --- a/src/bro_palette.mm +++ b/src/bro_palette.mm @@ -410,7 +410,7 @@ - (instancetype)initWithFrame:(NSRect)frame { self = [super initWithFrame:frame]; if (self) { self.wantsLayer = YES; - self.layer.cornerRadius = 6.0; + self.layer.cornerRadius = kControlCornerRadius; iconView_ = [[NSImageView alloc] initWithFrame:NSMakeRect(12, (frame.size.height - 15) / 2.0, 15, 15)]; @@ -581,7 +581,7 @@ - (instancetype)initWithFrame:(NSRect)frame { self.accessibilityLabel = @"Command palette"; self.wantsLayer = YES; - self.layer.cornerRadius = 8.0; + self.layer.cornerRadius = kSurfaceCornerRadius; BroApplyElevation(self, BroElevationPanel); // Glass backdrop: the elevation's flat fill is replaced by glass under a // near-#000 tint; border and shadow stay on the panel's own layer. The @@ -597,7 +597,8 @@ - (instancetype)initWithFrame:(NSRect)frame { NSGlassEffectView* glass = [[NSGlassEffectView alloc] initWithFrame:self.bounds]; glass.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; - glass.cornerRadius = 8.0; + glass.cornerRadius = + BroNestedCornerRadius(kSurfaceCornerRadius, 0.0); glass.style = NSGlassEffectViewStyleRegular; glass.tintColor = [NSColor colorWithWhite:0.0 alpha:0.6]; [self addSubview:glass]; @@ -609,7 +610,8 @@ - (instancetype)initWithFrame:(NSRect)frame { glass.blendingMode = NSVisualEffectBlendingModeWithinWindow; glass.state = NSVisualEffectStateActive; glass.wantsLayer = YES; - glass.layer.cornerRadius = 8.0; + glass.layer.cornerRadius = + BroNestedCornerRadius(kSurfaceCornerRadius, 0.0); glass.layer.masksToBounds = YES; [self addSubview:glass]; NSView* tint = [[NSView alloc] initWithFrame:self.bounds]; @@ -617,7 +619,8 @@ - (instancetype)initWithFrame:(NSRect)frame { tint.wantsLayer = YES; tint.layer.backgroundColor = [NSColor colorWithWhite:0.0 alpha:0.6].CGColor; - tint.layer.cornerRadius = 8.0; + tint.layer.cornerRadius = + BroNestedCornerRadius(kSurfaceCornerRadius, 0.0); tint.layer.masksToBounds = YES; [self addSubview:tint]; } diff --git a/src/bro_tabstrip.mm b/src/bro_tabstrip.mm index 4ffdbdc..f2b94ae 100644 --- a/src/bro_tabstrip.mm +++ b/src/bro_tabstrip.mm @@ -43,7 +43,7 @@ - (instancetype)initWithFrame:(NSRect)frame { self = [super initWithFrame:frame]; if (self) { self.wantsLayer = YES; - self.layer.cornerRadius = 6.0; + self.layer.cornerRadius = kControlCornerRadius; self.layer.actions = BroLayerTransitionActions(); // Keyboard focus shows as the same gray hairline the tab pills use, not // the system's accent-colored ring. @@ -309,7 +309,13 @@ - (instancetype)initWithFrame:(NSRect)frame browserId:(int)browserId { closeButton.image = RadixIconImage(RadixIconCross2, 10); closeButton.imagePosition = NSImageOnly; closeButton.contentTintColor = [NSColor tertiaryLabelColor]; - closeButton.layer.cornerRadius = 4.0; + // The close control is concentric with the pill: its 16pt square sits + // evenly inside the 28pt pill, leaving a 6pt gap on both horizontal + // edges. 8 - 6 = 2. + CGFloat closeButtonGap = + (kTabPillHeight - NSHeight(closeButton.frame)) / 2.0; + closeButton.layer.cornerRadius = + BroNestedCornerRadius(kPillCornerRadius, closeButtonGap); closeButton.target = self; closeButton.action = @selector(handleClose:); closeButton.autoresizingMask = NSViewMinXMargin; @@ -847,7 +853,7 @@ - (instancetype)initWithFrame:(NSRect)frame { self = [super initWithFrame:frame]; if (self) { self.wantsLayer = YES; - self.layer.cornerRadius = 8.0; + self.layer.cornerRadius = kSurfaceCornerRadius; BroApplyElevation(self, BroElevationOverlay); titleLabel_ = [[BroTextMorphView alloc] @@ -1004,7 +1010,7 @@ - (instancetype)initWithFrame:(NSRect)frame { _addTabButton.image = RadixIconImage(RadixIconPlus, 10); _addTabButton.imagePosition = NSImageOnly; _addTabButton.contentTintColor = [NSColor colorWithWhite:1.0 alpha:0.85]; - _addTabButton.layer.cornerRadius = 4.0; + _addTabButton.layer.cornerRadius = kCompactControlCornerRadius; _addTabButton.baseBorderWidth = 1.0; _addTabButton.baseBorderColor = BroControlBorderColor(); _addTabButton.target = self;