diff --git a/CMakeLists.txt b/CMakeLists.txt index 403e573..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 @@ -72,8 +73,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..c82f349 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, @@ -91,12 +133,10 @@ - (instancetype)initWithFrame:(NSRect)frame { self = [super initWithFrame: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; + self.layer.cornerRadius = kSurfaceCornerRadius; + 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)]; @@ -259,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]; @@ -269,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]; @@ -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_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 61cb3cd..efd32b6 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; } @@ -200,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) { @@ -247,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. @@ -265,12 +295,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)]; @@ -306,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 @@ -389,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(); @@ -666,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) { @@ -925,9 +987,9 @@ 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.backgroundColor = - [NSColor colorWithWhite:0.15 alpha:0.92].CGColor; + g_zoom_hud.layer.cornerRadius = kSurfaceCornerRadius; + 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 +1022,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 +1030,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 +1378,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 +1720,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 +1847,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 +2113,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 +2169,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..5754109 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. @@ -15,12 +15,16 @@ #import +#import "bro_geometry.h" + #include @class BroToolbar; @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,11 +45,16 @@ 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. 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 @@ -104,8 +113,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 +122,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 +140,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 +187,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 +227,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 +250,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 +363,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_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 new file mode 100644 index 0000000..b8d20ef --- /dev/null +++ b/src/bro_palette.mm @@ -0,0 +1,1207 @@ +// 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 = kControlCornerRadius; + + 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 = 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 + // 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 = + BroNestedCornerRadius(kSurfaceCornerRadius, 0.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 = + BroNestedCornerRadius(kSurfaceCornerRadius, 0.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 = + BroNestedCornerRadius(kSurfaceCornerRadius, 0.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..f2b94ae 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. @@ -41,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. @@ -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]; @@ -307,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; @@ -358,9 +366,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 +379,7 @@ - (void)setTabURL:(NSString*)url { - (void)attachAddressField:(NSTextField*)field { field.autoresizingMask = NSViewNotSizable; [self addSubview:field]; - _titleLabel.hidden = YES; + _editingAddress = NO; [self layoutPillContents]; } @@ -382,19 +391,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 +455,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 +468,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 +490,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 +531,9 @@ - (void)setClosable:(BOOL)closable { - (void)setIsActive:(BOOL)isActive { _isActive = isActive; + if (!isActive) { + _editingAddress = NO; + } [self updateAppearance]; [self layoutPillContents]; } @@ -527,6 +541,7 @@ - (void)setIsActive:(BOOL)isActive { - (void)setEditingAddress:(BOOL)editingAddress { _editingAddress = editingAddress; [self updateAppearance]; + [self layoutPillContents]; } - (void)setDropTarget:(BOOL)dropTarget { @@ -563,8 +578,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 +765,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 +829,7 @@ - (void)setTitle:(NSString*)title @end @implementation BroTabHoverCard { - NSTextField* titleLabel_; + BroTextMorphView* titleLabel_; NSTextField* urlLabel_; NSTextField* descriptionLabel_; } @@ -839,16 +853,11 @@ - (instancetype)initWithFrame:(NSRect)frame { self = [super initWithFrame: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; + self.layer.cornerRadius = kSurfaceCornerRadius; + 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 +911,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); @@ -1001,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; @@ -1010,23 +1019,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 +1062,7 @@ - (void)createNewTab:(id)sender { } - (void)toggleTabSearch:(id)sender { - ToggleTabSearchPanel(); + ToggleCommandPalette(BroPaletteScopeTabs); } - (void)addTabWithBrowserId:(int)browserId title:(NSString*)title { @@ -1072,13 +1083,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 +1200,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 +1228,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 +1301,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 +1358,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 +1381,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 +1407,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 +1453,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 +1588,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 +1605,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 +1704,7 @@ - (void)hideHoverCard { hoverCardTimer_ = nil; [hoverCardHideTimer_ invalidate]; hoverCardHideTimer_ = nil; - hoverCard_.hidden = YES; + BroOverlayHide(hoverCard_); hoverCardTabId_ = -1; } @@ -1782,12 +1812,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 +1825,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 +1857,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 +1872,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 +1882,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 +1906,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; + } } }