diff --git a/CMakeLists.txt b/CMakeLists.txt index ed8d5db..d617013 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -61,6 +61,8 @@ set(BRO_SRCS src/bro_closed_tabs.mm src/bro_downloads.mm src/bro_favicon.mm + src/bro_find.h + src/bro_find.mm src/bro_geometry.h src/bro_handler.cc src/bro_handler.h diff --git a/src/bro_downloads.mm b/src/bro_downloads.mm index c82f349..de16da9 100644 --- a/src/bro_downloads.mm +++ b/src/bro_downloads.mm @@ -51,7 +51,6 @@ @implementation BroDownloadEntry static const CGFloat kDownloadsRowHeight = 44.0; static const CGFloat kDownloadsHeaderHeight = 28.0; static const CGFloat kDownloadsFooterHeight = 36.0; -static const CGFloat kDownloadsEmptyHeight = 52.0; @interface BroDownloadRow : NSView @property(nonatomic, assign) BOOL interactive; @@ -61,17 +60,36 @@ @interface BroDownloadRow : NSView @property(nonatomic, assign) NSUInteger downloadIndex; @end -@implementation BroDownloadRow +@implementation BroDownloadRow { + BOOL hovered_; + BOOL focused_; +} + +- (instancetype)initWithFrame:(NSRect)frame { + self = [super initWithFrame:frame]; + if (self) { + self.wantsLayer = YES; + self.layer.cornerRadius = + BroCornerRadiusForSize(BroControlCornerRadius(), self.bounds.size); + self.layer.actions = BroLayerTransitionActions(); + self.focusRingType = NSFocusRingTypeNone; + } + return self; +} - (void)mouseEntered:(NSEvent*)event { if (_interactive) { + hovered_ = YES; [_highlightGroup hoverOnView:self]; } } - (void)mouseExited:(NSEvent*)event { if (_interactive) { - [_highlightGroup hoverOffView:self]; + hovered_ = NO; + if (!focused_) { + [_highlightGroup hoverOffView:self]; + } } } @@ -91,6 +109,56 @@ - (void)mouseUp:(NSEvent*)event { } } +- (BOOL)acceptsFirstResponder { + return _interactive; +} + +- (BOOL)canBecomeKeyView { + return _interactive && !self.hiddenOrHasHiddenAncestor; +} + +- (BOOL)becomeFirstResponder { + BOOL ok = [super becomeFirstResponder]; + if (ok) { + focused_ = YES; + self.layer.borderColor = BroControlBorderColor().CGColor; + self.layer.borderWidth = 1.0; + [_highlightGroup hoverOnView:self animated:NO]; + } + return ok; +} + +- (BOOL)resignFirstResponder { + BOOL ok = [super resignFirstResponder]; + if (ok) { + focused_ = NO; + self.layer.borderWidth = 0.0; + if (!hovered_) { + [_highlightGroup hoverOffView:self]; + } + } + return ok; +} + +- (void)keyDown:(NSEvent*)event { + NSString* characters = event.charactersIgnoringModifiers; + unichar key = characters.length > 0 ? [characters characterAtIndex:0] : 0; + if (_interactive && + (key == ' ' || key == '\r' || key == NSEnterCharacter)) { + [NSApp sendAction:_action to:_target from:self]; + return; + } + [super keyDown:event]; +} + +- (BOOL)accessibilityPerformPress { + if (!_interactive) { + return NO; + } + [NSApp sendAction:_action to:_target from:self]; + return YES; +} + @end @interface BroDownloadsPopover : NSView @@ -104,12 +172,13 @@ @implementation BroDownloadsPopover { BroHoverButton* clearButton_; BroHoverButton* viewAllButton_; NSView* separator_; - NSTextField* emptyLabel_; NSMutableArray* rowViews_; // Entries backing the visible rows; magnifier buttons index into this. NSMutableArray* rowEntries_; CGFloat lastMaxHeight_; BroHoverHighlightGroup* rowHighlightGroup_; + __weak BroHoverButton* focusedRevealButton_; + __weak BroHoverButton* hoveredRevealButton_; } static NSAttributedString* BroDownloadsButtonTitle(NSString* text, @@ -133,8 +202,14 @@ - (instancetype)initWithFrame:(NSRect)frame { self = [super initWithFrame:frame]; if (self) { self.wantsLayer = YES; - self.layer.cornerRadius = kSurfaceCornerRadius; + self.layer.cornerRadius = + BroCornerRadiusForSize(BroSurfaceCornerRadius(), self.bounds.size); BroApplyElevation(self, BroElevationPanel); + BroInstallGlassBackdrop(self, self.layer.cornerRadius); + + self.accessibilityRole = NSAccessibilityGroupRole; + self.accessibilityLabel = @"Recent downloads"; + rowHighlightGroup_ = [[BroHoverHighlightGroup alloc] initWithContainerView:self]; @@ -156,14 +231,10 @@ - (instancetype)initWithFrame:(NSRect)frame { @"Clear", BroUIFont(11.0), 0.55, NSTextAlignmentCenter); clearButton_.target = self; clearButton_.action = @selector(clearDownloads:); + // Plain label only: a tooltip would just repeat the visible title. clearButton_.accessibilityLabel = @"Clear recent downloads"; [self addSubview:clearButton_]; - emptyLabel_ = BroHoverCardLabel(BroUIFont(12.0), 0.4); - emptyLabel_.stringValue = @"No recent downloads"; - emptyLabel_.alignment = NSTextAlignmentCenter; - [self addSubview:emptyLabel_]; - separator_ = [[NSView alloc] initWithFrame:NSZeroRect]; separator_.wantsLayer = YES; separator_.layer.backgroundColor = @@ -199,6 +270,8 @@ - (void)reloadWithMaxHeight:(CGFloat)maxHeight { } [rowViews_ removeAllObjects]; [rowEntries_ removeAllObjects]; + focusedRevealButton_ = nil; + hoveredRevealButton_ = nil; NSArray* entries = BroDownloadsList(); const CGFloat padTop = 8.0; @@ -215,29 +288,21 @@ - (void)reloadWithMaxHeight:(CGFloat)maxHeight { } const CGFloat width = kDownloadsPopoverWidth; - CGFloat listHeight = rowCount > 0 ? rowCount * kDownloadsRowHeight - : kDownloadsEmptyHeight; + CGFloat listHeight = rowCount * kDownloadsRowHeight; CGFloat height = chromeHeight + listHeight; // Bottom-up (unflipped coords): footer, separator, rows oldest-first, then // the header at the top. viewAllButton_.frame = NSMakeRect(4.0, padBottom, width - 8.0, kDownloadsFooterHeight); + viewAllButton_.layer.cornerRadius = BroCornerRadiusForSize( + BroNestedCornerRadius(BroSurfaceCornerRadius(), + NSMinX(viewAllButton_.frame)), + viewAllButton_.bounds.size); separator_.frame = NSMakeRect(0, padBottom + kDownloadsFooterHeight, width, 1.0); CGFloat listBottom = padBottom + kDownloadsFooterHeight + 1.0; - emptyLabel_.hidden = rowCount > 0; - if (rowCount == 0) { - CGFloat labelHeight = - ceil([emptyLabel_.cell cellSizeForBounds:NSMakeRect(0, 0, CGFLOAT_MAX, - CGFLOAT_MAX)] - .height); - emptyLabel_.frame = NSMakeRect( - 12.0, listBottom + (kDownloadsEmptyHeight - labelHeight) / 2.0, - width - 24.0, labelHeight); - } - for (NSUInteger i = 0; i < rowCount; i++) { BroDownloadEntry* entry = rowEntries_[i]; // Row i is the i-th newest; stack from the top of the list area down. @@ -279,6 +344,7 @@ - (NSView*)makeRowForEntry:(BroDownloadEntry*)entry index:(NSUInteger)index { row.accessibilityRole = NSAccessibilityButtonRole; row.accessibilityLabel = [NSString stringWithFormat:@"Open %@", entry.name ?: @"download"]; + row.accessibilityHelp = @"Press Return or Space to open."; NSTrackingArea* trackingArea = [[NSTrackingArea alloc] initWithRect:NSZeroRect options:NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways | @@ -290,6 +356,7 @@ - (NSView*)makeRowForEntry:(BroDownloadEntry*)entry index:(NSUInteger)index { NSImageView* icon = [[NSImageView alloc] initWithFrame:NSMakeRect(12.0, 8.0, 28.0, 28.0)]; + icon.accessibilityElement = NO; if (entry.state == BroDownloadStateComplete && fileExists) { icon.image = [[NSWorkspace sharedWorkspace] iconForFile:entry.path]; } else { @@ -306,11 +373,15 @@ - (NSView*)makeRowForEntry:(BroDownloadEntry*)entry index:(NSUInteger)index { NSTextField* name = BroHoverCardLabel(BroUIFontBold(12.0), 1.0); name.stringValue = entry.name ?: @""; name.frame = NSMakeRect(textX, 22.0, textRight - textX, 15.0); + // The interactive row is the AX button ("Open "); its labels would + // read as duplicate static-text children. + name.accessibilityElement = !row.interactive; [row addSubview:name]; NSTextField* subtitle = BroHoverCardLabel(BroUIFont(11.0), 0.55); subtitle.stringValue = [self subtitleForEntry:entry fileExists:fileExists]; subtitle.frame = NSMakeRect(textX, 7.0, textRight - textX, 13.0); + subtitle.accessibilityElement = !row.interactive; [row addSubview:subtitle]; if (entry.state == BroDownloadStateInProgress) { @@ -318,6 +389,7 @@ - (NSView*)makeRowForEntry:(BroDownloadEntry*)entry index:(NSUInteger)index { NSView* track = [[NSView alloc] initWithFrame:NSMakeRect(textX, 4.0, barWidth, 2.0)]; track.wantsLayer = YES; + track.accessibilityElement = NO; track.layer.cornerRadius = BroCapsuleCornerRadius(NSHeight(track.frame)); track.layer.backgroundColor = [NSColor colorWithWhite:1.0 alpha:0.15].CGColor; @@ -328,6 +400,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.accessibilityElement = NO; fill.layer.cornerRadius = BroCapsuleCornerRadius(NSHeight(fill.frame)); fill.layer.backgroundColor = [NSColor colorWithWhite:1.0 alpha:0.85].CGColor; @@ -344,12 +417,34 @@ - (NSView*)makeRowForEntry:(BroDownloadEntry*)entry index:(NSUInteger)index { reveal.imagePosition = NSImageOnly; reveal.image = RadixIconImage(RadixIconMagnifyingGlass, 15.0); reveal.contentTintColor = [NSColor colorWithWhite:1.0 alpha:0.85]; - reveal.toolTip = @"Show in Finder"; - reveal.accessibilityLabel = - [NSString stringWithFormat:@"Show %@ in Finder", entry.name]; + [reveal configureActionLabel: + [NSString stringWithFormat:@"Show %@ in Finder", entry.name] + keyEquivalent:@"r" + modifierMask:NSEventModifierFlagCommand | + NSEventModifierFlagOption]; reveal.tag = (NSInteger)index; reveal.target = self; reveal.action = @selector(revealEntry:); + __weak BroDownloadsPopover* weakSelf = self; + __weak BroHoverButton* weakReveal = reveal; + reveal.focusChangedHandler = ^(BroHoverButton* button, BOOL focused) { + BroDownloadsPopover* popover = weakSelf; + if (!popover) return; + if (focused) { + popover->focusedRevealButton_ = button; + } else if (popover->focusedRevealButton_ == weakReveal) { + popover->focusedRevealButton_ = nil; + } + }; + reveal.hoverChangedHandler = ^(BroHoverButton* button, BOOL hovered) { + BroDownloadsPopover* popover = weakSelf; + if (!popover) return; + if (hovered) { + popover->hoveredRevealButton_ = button; + } else if (popover->hoveredRevealButton_ == weakReveal) { + popover->hoveredRevealButton_ = nil; + } + }; [row addSubview:reveal]; } @@ -421,6 +516,15 @@ - (void)revealEntry:(NSButton*)sender { } } +- (BOOL)performContextualRevealShortcut { + BroHoverButton* button = focusedRevealButton_ ?: hoveredRevealButton_; + if (!button || button.hiddenOrHasHiddenAncestor || !button.enabled) { + return NO; + } + [button performClick:nil]; + return YES; +} + - (void)clearDownloads:(id)sender { // Drop settled entries only; active downloads stay until they finish. NSMutableArray* list = BroDownloadsList(); @@ -432,6 +536,10 @@ - (void)clearDownloads:(id)sender { } } BroSaveRecentDownloads(); + if (list.count == 0) { + BroHideDownloadsPopover(); + return; + } [self reloadWithMaxHeight:lastMaxHeight_]; } @@ -499,7 +607,9 @@ void BroHideDownloadsPopover(void) { static void BroShowDownloadsPopover(void) { NSView* container = BroBrowserContainerView(); - if (!container) { + // Nothing to list — leave the popover closed rather than show an empty + // shell (the Downloads menu item is disabled to match). + if (!container || BroDownloadsList().count == 0) { return; } if (!g_downloads_popover) { @@ -516,6 +626,7 @@ static void BroShowDownloadsPopover(void) { [g_downloads_popover reloadWithMaxHeight:NSHeight(container.bounds) - 12.0]; BroPositionDownloadsPopover(); BroOverlayShow(g_downloads_popover); + [g_downloads_popover.window recalculateKeyViewLoop]; if (!g_downloads_click_monitor) { g_downloads_click_monitor = BroInstallOutsideDismissMonitor( @@ -537,7 +648,12 @@ static void BroShowDownloadsPopover(void) { } } -// BroToggleDownloadsPopover is declared extern in bro_mac_internal.h. +// BroHasRecentDownloads and BroToggleDownloadsPopover are declared extern in +// bro_mac_internal.h. +BOOL BroHasRecentDownloads(void) { + return BroDownloadsList().count > 0; +} + void BroToggleDownloadsPopover(void) { if (BroDownloadsPopoverVisible()) { BroHideDownloadsPopover(); @@ -546,10 +662,22 @@ void BroToggleDownloadsPopover(void) { } } +BOOL BroPerformContextualDownloadsShortcut(NSEvent* event) { + return BroDownloadsPopoverVisible() && + BroEventMatchesShortcut(event, @"r", + NSEventModifierFlagCommand | + NSEventModifierFlagOption) && + [g_downloads_popover performContextualRevealShortcut]; +} + static void BroRefreshDownloadsPopover(void) { if (!BroDownloadsPopoverVisible()) { return; } + if (BroDownloadsList().count == 0) { + BroHideDownloadsPopover(); + return; + } NSView* container = BroBrowserContainerView(); CGFloat maxHeight = container ? NSHeight(container.bounds) - 12.0 : CGFLOAT_MAX; diff --git a/src/bro_find.h b/src/bro_find.h new file mode 100644 index 0000000..1ab2465 --- /dev/null +++ b/src/bro_find.h @@ -0,0 +1,22 @@ +// Native in-page find bar for the active CEF browser. + +#ifndef BRO_FIND_H_ +#define BRO_FIND_H_ + +#import + +void BroShowFindBar(void); +void BroHideFindBar(void); +void BroFindNext(void); +void BroFindPrevious(void); +BOOL BroFindBarVisible(void); +BOOL BroFindHasQuery(void); +void BroTeardownFindBar(void); + +// Called by BroHandler's CefFindHandler callback on the CEF UI/main thread. +void BroUpdateFindResult(int browser_id, + int identifier, + int count, + int active_ordinal); + +#endif // BRO_FIND_H_ diff --git a/src/bro_find.mm b/src/bro_find.mm new file mode 100644 index 0000000..d6d8e79 --- /dev/null +++ b/src/bro_find.mm @@ -0,0 +1,330 @@ +#import "bro_find.h" + +#include "bro_handler.h" +#import "bro_mac_internal.h" +#import "bro_motion.h" +#import "radix_icons.h" + +@class BroFindBar; + +@interface BroFindField : NSSearchField +@property(nonatomic, weak) BroFindBar* findBar; +@end + +@interface BroFindBar : NSView +- (void)prepareForDisplay; +- (void)stopFinding; +- (void)findNext:(id)sender; +- (void)findPrevious:(id)sender; +- (void)closeFind:(id)sender; +- (void)updateIdentifier:(int)identifier + count:(int)count + ordinal:(int)ordinal; +@property(nonatomic, readonly) BOOL hasQuery; +@end + +@implementation BroFindField + +- (void)keyDown:(NSEvent*)event { + NSString* chars = event.charactersIgnoringModifiers; + unichar key = chars.length > 0 ? [chars characterAtIndex:0] : 0; + if (event.keyCode == 53 || key == 0x1b) { // Escape + [self.findBar closeFind:self]; + return; + } + if (key == '\r' || key == NSEnterCharacter) { + if (event.modifierFlags & NSEventModifierFlagShift) { + [self.findBar findPrevious:self]; + } else { + [self.findBar findNext:self]; + } + return; + } + [super keyDown:event]; +} + +@end + +@implementation BroFindBar { + BroFindField* searchField_; + NSTextField* countLabel_; + __weak NSResponder* previousFirstResponder_; + int lastFindIdentifier_; + int lastActiveOrdinal_; +} + +static BroHoverButton* BroFindButton(NSRect frame, + NSString* title, + NSImage* image, + NSString* label, + NSString* keyEquivalent, + NSEventModifierFlags modifiers, + id target, + SEL action) { + BroHoverButton* button = [[BroHoverButton alloc] initWithFrame:frame]; + button.bordered = NO; + button.title = title ?: @""; + button.font = BroUIFont(13.0); + button.image = image; + button.imagePosition = image ? NSImageOnly : NSNoImage; + button.contentTintColor = [NSColor colorWithWhite:1.0 alpha:0.85]; + button.target = target; + button.action = action; + [button configureActionLabel:label + keyEquivalent:keyEquivalent + modifierMask:modifiers]; + return button; +} + +- (instancetype)initWithFrame:(NSRect)frame { + self = [super initWithFrame:frame]; + if (!self) { + return nil; + } + + self.wantsLayer = YES; + self.layer.cornerRadius = + BroCornerRadiusForSize(BroSurfaceCornerRadius(), self.bounds.size); + BroApplyElevation(self, BroElevationPanel); + BroInstallGlassBackdrop(self, self.layer.cornerRadius); + self.accessibilityElement = YES; + self.accessibilityRole = NSAccessibilityGroupRole; + self.accessibilityLabel = @"Find in page"; + + searchField_ = [[BroFindField alloc] initWithFrame:NSMakeRect(8, 6, 164, 24)]; + searchField_.findBar = self; + searchField_.delegate = self; + searchField_.placeholderString = @"Find in page"; + searchField_.sendsSearchStringImmediately = YES; + searchField_.accessibilityLabel = @"Find in page"; + searchField_.accessibilityHelp = + @"Type search text. Press Return for the next match or Shift-Return for the previous match."; + [self addSubview:searchField_]; + + countLabel_ = BroHoverCardLabel(BroUIFont(11.0), 0.55); + countLabel_.frame = NSMakeRect(178, 6, 48, 24); + countLabel_.alignment = NSTextAlignmentCenter; + countLabel_.lineBreakMode = NSLineBreakByClipping; + countLabel_.accessibilityElement = YES; + countLabel_.accessibilityLabel = @"No search results yet"; + [self addSubview:countLabel_]; + + const NSEventModifierFlags command = NSEventModifierFlagCommand; + BroHoverButton* previous = BroFindButton( + NSMakeRect(230, 4, 28, 28), @"↑", nil, @"Find Previous", @"g", + command | NSEventModifierFlagShift, self, @selector(findPrevious:)); + [self addSubview:previous]; + + BroHoverButton* next = BroFindButton(NSMakeRect(262, 4, 28, 28), @"↓", nil, + @"Find Next", @"g", command, self, + @selector(findNext:)); + [self addSubview:next]; + + BroHoverButton* close = BroFindButton( + NSMakeRect(294, 4, 28, 28), @"", RadixIconImage(RadixIconCross2, 13), + @"Close Find Bar", @"\e", 0, self, @selector(closeFind:)); + [self addSubview:close]; + + return self; +} + +- (BOOL)hasQuery { + return searchField_.stringValue.length > 0; +} + +- (void)prepareForDisplay { + previousFirstResponder_ = self.window.firstResponder; + countLabel_.stringValue = @""; + countLabel_.accessibilityLabel = @"Searching"; + [self.window makeFirstResponder:searchField_]; + [searchField_ selectText:nil]; + if (self.hasQuery) { + [self startNewSearch]; + } +} + +- (CefRefPtr)activeBrowser { + BroHandler* handler = BroHandler::GetInstance(); + return handler ? handler->GetBrowser() : nullptr; +} + +- (void)startNewSearch { + CefRefPtr browser = [self activeBrowser]; + if (!browser) { + return; + } + NSString* query = searchField_.stringValue; + if (query.length == 0) { + browser->GetHost()->StopFinding(true); + countLabel_.stringValue = @""; + countLabel_.accessibilityLabel = @"No search query"; + return; + } + countLabel_.stringValue = @"…"; + countLabel_.accessibilityLabel = @"Searching"; + lastActiveOrdinal_ = 0; + browser->GetHost()->Find([query UTF8String], true, false, false); +} + +- (void)findInDirection:(BOOL)forward { + if (!self.hasQuery) { + [self.window makeFirstResponder:searchField_]; + return; + } + CefRefPtr browser = [self activeBrowser]; + if (browser) { + browser->GetHost()->Find([searchField_.stringValue UTF8String], forward, + false, true); + // A windowed CEF child can raise its native view while moving to the next + // match. Reassert the find bar as the top sibling and keep typing focus in + // the query field after the browser scrolls the result into view. + [self.superview addSubview:self positioned:NSWindowAbove relativeTo:nil]; + [self.window makeFirstResponder:searchField_]; + } +} + +- (void)findNext:(id)sender { + [self findInDirection:YES]; +} + +- (void)findPrevious:(id)sender { + [self findInDirection:NO]; +} + +- (void)closeFind:(id)sender { + BroHideFindBar(); +} + +- (void)stopFinding { + CefRefPtr browser = [self activeBrowser]; + if (browser) { + browser->GetHost()->StopFinding(true); + browser->GetHost()->SetFocus(true); + } + NSResponder* responder = previousFirstResponder_; + if (responder && responder != searchField_ && + [responder isKindOfClass:[NSView class]] && + [(NSView*)responder window] == self.window) { + [self.window makeFirstResponder:responder]; + } + previousFirstResponder_ = nil; +} + +- (void)updateIdentifier:(int)identifier + count:(int)count + ordinal:(int)ordinal { + if (identifier < lastFindIdentifier_) { + return; + } + if (identifier > lastFindIdentifier_) { + lastFindIdentifier_ = identifier; + lastActiveOrdinal_ = 0; + } + if (count <= 0) { + countLabel_.stringValue = @"0/0"; + countLabel_.accessibilityLabel = @"No matches"; + return; + } + if (ordinal > 0) { + lastActiveOrdinal_ = ordinal; + } + int displayedOrdinal = lastActiveOrdinal_ > 0 ? lastActiveOrdinal_ : 1; + displayedOrdinal = MIN(displayedOrdinal, count); + countLabel_.stringValue = + [NSString stringWithFormat:@"%d/%d", displayedOrdinal, count]; + countLabel_.accessibilityLabel = + [NSString stringWithFormat:@"Match %d of %d", displayedOrdinal, count]; +} + +- (void)controlTextDidChange:(NSNotification*)notification { + [self startNewSearch]; +} + +@end + +static BroFindBar* g_find_bar = nil; +static BOOL g_find_bar_showing = NO; + +BOOL BroFindBarVisible(void) { + return g_find_bar_showing; +} + +BOOL BroFindHasQuery(void) { + return g_find_bar_showing && g_find_bar.hasQuery; +} + +static void BroPositionFindBar(NSView* container) { + const NSSize size = NSMakeSize(330, 36); + g_find_bar.frame = NSMakeRect(NSWidth(container.bounds) - size.width - 12.0, + NSHeight(container.bounds) - size.height - 12.0, + size.width, size.height); + g_find_bar.autoresizingMask = NSViewMinXMargin | NSViewMinYMargin; +} + +void BroShowFindBar(void) { + NSView* container = BroBrowserContainerView(); + if (!container) { + return; + } + HideCommandPalette(); + BroHideDownloadsPopover(); + if (!g_find_bar) { + // Construct with its real geometry so shell-derived corners are clamped + // before the glass backdrop captures the radius. + g_find_bar = [[BroFindBar alloc] + initWithFrame:NSMakeRect(0.0, 0.0, 330.0, 36.0)]; + g_find_bar.hidden = YES; + } + [g_find_bar removeFromSuperview]; + BroPositionFindBar(container); + [container addSubview:g_find_bar]; + g_find_bar_showing = YES; + BroOverlayShow(g_find_bar); + [g_find_bar prepareForDisplay]; + [g_find_bar.window recalculateKeyViewLoop]; +} + +void BroHideFindBar(void) { + if (!g_find_bar_showing) { + return; + } + g_find_bar_showing = NO; + [g_find_bar stopFinding]; + BroOverlayHide(g_find_bar); +} + +void BroFindNext(void) { + if (!BroFindBarVisible()) { + BroShowFindBar(); + return; + } + [g_find_bar findNext:nil]; +} + +void BroFindPrevious(void) { + if (!BroFindBarVisible()) { + BroShowFindBar(); + return; + } + [g_find_bar findPrevious:nil]; +} + +void BroUpdateFindResult(int browser_id, + int identifier, + int count, + int active_ordinal) { + BroHandler* handler = BroHandler::GetInstance(); + if (!g_find_bar_showing || !handler || + browser_id != handler->GetActiveBrowserId()) { + return; + } + [g_find_bar updateIdentifier:identifier + count:count + ordinal:active_ordinal]; +} + +void BroTeardownFindBar(void) { + g_find_bar_showing = NO; + [g_find_bar removeFromSuperview]; + g_find_bar = nil; +} diff --git a/src/bro_geometry.h b/src/bro_geometry.h index 9ff5e12..e6e40be 100644 --- a/src/bro_geometry.h +++ b/src/bro_geometry.h @@ -5,13 +5,14 @@ #import -// Corner radii follow the concentric-corner rule throughout the app: +// The system-resolved shell radius is the root of the app's concentric-corner +// hierarchy. Every non-capsule surface derives from the boundary above it: // // 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. +// of establishing independent radii. Capsules derive theirs from their own +// height because their curve is intrinsic rather than nested. // 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)); @@ -21,11 +22,52 @@ static inline CGFloat BroCapsuleCornerRadius(CGFloat height) { return height / 2.0; } -static const CGFloat kSurfaceCornerRadius = 8.0; +// Core Animation accepts radii larger than half a layer's shortest edge, but +// its corner arcs then overlap and produce pointed/diamond-like shapes. Keep +// the shell-derived preferred radius while enforcing the geometric limit of +// the element that will render it. A zero-sized view is usually awaiting its +// first layout, so preserve the preferred radius until it has real bounds. +static inline CGFloat BroCornerRadiusForSize(CGFloat preferredRadius, + NSSize size) { + CGFloat shortestEdge = MIN(size.width, size.height); + if (shortestEdge <= 0.0) { + return MAX(0.0, preferredRadius); + } + return MIN(MAX(0.0, preferredRadius), shortestEdge / 2.0); +} + +// Used until the window frame is available. BroShellCornerRadius is updated +// from AppKit's real NSThemeFrame radius before chrome views are constructed. +static const CGFloat kShellCornerRadiusFallback = 12.0; +static const CGFloat kShellCornerRadiusReduction = 2.0; +extern CGFloat BroShellCornerRadius(void); + +static const CGFloat kShellSurfaceCornerGap = 4.0; static const CGFloat kControlCornerGap = 2.0; -static const CGFloat kControlCornerRadius = - BroNestedCornerRadius(kSurfaceCornerRadius, kControlCornerGap); -static const CGFloat kCompactControlCornerRadius = - BroNestedCornerRadius(kControlCornerRadius, kControlCornerGap); +// AppKit can expose a much larger shell radius for some window styles. The +// hierarchy still derives from that root, but each smaller component level +// has a design-scale ceiling so compact controls remain rounded rectangles +// rather than inflating into circles. +static const CGFloat kSurfaceCornerRadiusMaximum = 12.0; +static const CGFloat kControlCornerRadiusMaximum = 8.0; +static const CGFloat kCompactControlCornerRadiusMaximum = 6.0; + +static inline CGFloat BroSurfaceCornerRadius(void) { + return MIN(kSurfaceCornerRadiusMaximum, + BroNestedCornerRadius(BroShellCornerRadius(), + kShellSurfaceCornerGap)); +} + +static inline CGFloat BroControlCornerRadius(void) { + return MIN(kControlCornerRadiusMaximum, + BroNestedCornerRadius(BroSurfaceCornerRadius(), + kControlCornerGap)); +} + +static inline CGFloat BroCompactControlCornerRadius(void) { + return MIN(kCompactControlCornerRadiusMaximum, + BroNestedCornerRadius(BroControlCornerRadius(), + kControlCornerGap)); +} #endif // BRO_GEOMETRY_H_ diff --git a/src/bro_handler.cc b/src/bro_handler.cc index 337b690..7041b92 100644 --- a/src/bro_handler.cc +++ b/src/bro_handler.cc @@ -87,6 +87,19 @@ void BroHandler::SetActiveBrowser(int browser_id) { } } +void BroHandler::OnFindResult(CefRefPtr browser, + int identifier, + int count, + const CefRect& selectionRect, + int activeMatchOrdinal, + bool finalUpdate) { + CEF_REQUIRE_UI_THREAD(); + if (browser) { + BroUpdateFindResult(browser->GetIdentifier(), identifier, count, + activeMatchOrdinal); + } +} + void BroHandler::SetBrowserHidden(int browser_id, bool hidden) { if (!CefCurrentlyOn(TID_UI)) { CefPostTask(TID_UI, base::BindOnce(&BroHandler::SetBrowserHidden, this, diff --git a/src/bro_handler.h b/src/bro_handler.h index 293f265..ec00601 100644 --- a/src/bro_handler.h +++ b/src/bro_handler.h @@ -26,6 +26,10 @@ void OnTabFaviconChanged(int browser_id, const std::string& favicon_url); void OnTabClosed(int browser_id); void OnActiveTabChanged(int browser_id); void OnTabLoadingChanged(int browser_id, bool is_loading); +void BroUpdateFindResult(int browser_id, + int identifier, + int count, + int active_ordinal); void OpenLinkInNewTab(const std::string& url); // Detaches a tab's container view so CEF can finish destroying the browser. void DetachTabView(int browser_id); @@ -55,6 +59,7 @@ std::string ResolveDownloadTargetPath(const std::string& suggested_name); class BroHandler : public CefClient, public CefDisplayHandler, + public CefFindHandler, public CefLifeSpanHandler, public CefLoadHandler, public CefContextMenuHandler, @@ -107,6 +112,7 @@ class BroHandler : public CefClient, // CefClient methods: CefRefPtr GetDisplayHandler() override { return this; } + CefRefPtr GetFindHandler() override { return this; } CefRefPtr GetLifeSpanHandler() override { return this; } CefRefPtr GetLoadHandler() override { return this; } CefRefPtr GetContextMenuHandler() override { return this; } @@ -121,6 +127,14 @@ class BroHandler : public CefClient, void OnFaviconURLChange(CefRefPtr browser, const std::vector& icon_urls) override; + // CefFindHandler methods: + void OnFindResult(CefRefPtr browser, + int identifier, + int count, + const CefRect& selectionRect, + int activeMatchOrdinal, + bool finalUpdate) override; + // CefDevToolsMessageObserver methods: void OnDevToolsMethodResult(CefRefPtr browser, int message_id, diff --git a/src/bro_mac.mm b/src/bro_mac.mm index efd32b6..11b16e0 100644 --- a/src/bro_mac.mm +++ b/src/bro_mac.mm @@ -20,6 +20,7 @@ #import "radix_icons.h" #import "bro_mac_internal.h" #import "bro_closed_tabs.h" +#import "bro_find.h" #import "bro_history.h" #import "bro_motion.h" #import "bro_persist.h" @@ -53,6 +54,10 @@ return [NSColor colorWithWhite:0x11 / 255.0 alpha:1.0]; } +NSColor* BroPlaceholderFaviconColor(void) { + return [NSColor colorWithWhite:0x24 / 255.0 alpha:1.0]; +} + // Global references static BroWindow* g_main_window = nil; // g_toolbar is declared extern in bro_mac_internal.h; defined in @@ -75,6 +80,11 @@ // are removed when the popup's browser is adopted as a tab, or on abort. static NSMutableDictionary* g_pending_popup_containers = nil; +// Blank tabs created by the shell should be ready for typing as soon as CEF +// adopts them. Track the exact container rather than a shared boolean: browser +// creation is asynchronous, and several tabs may be in flight at once. +static NSHashTable* g_pending_address_focus_containers = nil; + // Downloads: BroDownloadEntry, BroDownloadsList, the popover, and the // bridge callbacks live in bro_downloads.mm. BroHideDownloadsPopover and // BroToggleDownloadsPopover are declared extern in bro_mac_internal.h. @@ -189,26 +199,30 @@ - (NSView*)hitTest:(NSPoint)point { } @end -// The system frame view already masks the full-size content (including the -// CEF child views) to the window shape, so the hairline only has to trace the -// same curve. The radius varies across macOS releases; probe the frame view. -static CGFloat BroWindowCornerRadius(NSWindow* window) { +// AppKit owns the outer shell shape. Cache its resolved radius as the root of +// the app-wide corner hierarchy before constructing any nested chrome views. +static CGFloat g_shell_corner_radius = BroNestedCornerRadius( + kShellCornerRadiusFallback, kShellCornerRadiusReduction); + +CGFloat BroShellCornerRadius(void) { + return g_shell_corner_radius; +} + +static CGFloat BroResolveShellCornerRadius(NSWindow* window) { NSView* frameView = window.contentView.superview; // NSThemeFrame if ([frameView respondsToSelector:@selector(cornerRadius)]) { CGFloat r = [[frameView valueForKey:@"cornerRadius"] doubleValue]; if (r > 0.5) { - return r; + return BroNestedCornerRadius(r, kShellCornerRadiusReduction); } } - return kWindowCornerRadiusFallback; + return BroNestedCornerRadius(kShellCornerRadiusFallback, + kShellCornerRadiusReduction); } // 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; +// NSVisualEffectView): the chrome row wears the same shared tint as every +// glass surface, and the browser-area tint fades down to it when blank. static const CFTimeInterval kGlassBackdropFadeDuration = 0.30; @interface BroWindow : NSWindow { @@ -261,6 +275,7 @@ - (instancetype)init { content.state = NSVisualEffectStateActive; content.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; self.contentView = content; + g_shell_corner_radius = BroResolveShellCornerRadius(self); // 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 @@ -280,8 +295,7 @@ - (instancetype)init { 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.layer.backgroundColor = BroGlassTintColor().CGColor; chromeTint.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin; [content addSubview:chromeTint]; @@ -296,14 +310,17 @@ - (instancetype)init { // right-aligned viewport toggles. CGFloat stripX = kTrafficLightInset + 3 * (kButtonSize + kButtonSpacing) + 8.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. + // shared button rhythm, with a small optical correction for the + // magnifier artwork. The complete control moves so its glyph stays + // centered inside hover/focus feedback. CGFloat stripRight = - frame.size.width - 12.0 - 3.0 * (kButtonSize + kButtonSpacing); + BroTrailingControlX(frame.size.width, 3) + kButtonSize + + kTabSearchOpticalOffsetX; _tabBar = [[BroTabBar alloc] initWithFrame:NSMakeRect(stripX, 0, stripRight - stripX, kToolbarHeight)]; [_navToolbar addSubview:_tabBar]; g_tab_bar = _tabBar; + [_navToolbar registerTabSearchButton:_tabBar.tabSearchButton]; // The toolbar's search/downloads reveal zone spans a tab-bar subview, so // recompute it now that the tab bar exists. [_navToolbar updateTrackingAreas]; @@ -317,9 +334,9 @@ - (instancetype)init { // CEF child views). BroWindowBorderView* border = [[BroWindowBorderView alloc] initWithFrame:frame]; border.wantsLayer = YES; - border.layer.borderWidth = 1.0; - border.layer.borderColor = BroControlBorderColor().CGColor; - border.layer.cornerRadius = BroWindowCornerRadius(self); + border.layer.borderWidth = kBroGlassBorderWidth; + border.layer.borderColor = BroGlassBorderColor().CGColor; + border.layer.cornerRadius = BroShellCornerRadius(); border.layer.cornerCurve = kCACornerCurveContinuous; border.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; [content addSubview:border]; @@ -328,6 +345,7 @@ - (instancetype)init { // Initialize browser views dictionary g_browser_views = [NSMutableDictionary dictionary]; g_blank_tab_ids = [NSMutableSet set]; + g_pending_address_focus_containers = [NSHashTable weakObjectsHashTable]; // Closed-tab history lazily initializes itself on first use. // Keep the Tab-key loop current as pills and buttons come and go. @@ -356,7 +374,7 @@ - (void)setGlassBackdropVisible:(BOOL)visible { ctx.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; self.backdropTint.animator.alphaValue = - visible ? kGlassBackdropScrimAlpha : 1.0; + visible ? kBroGlassTintAlpha : 1.0; } completionHandler:nil]; } @@ -987,7 +1005,8 @@ 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 = kSurfaceCornerRadius; + g_zoom_hud.layer.cornerRadius = BroCornerRadiusForSize( + BroSurfaceCornerRadius(), g_zoom_hud.bounds.size); BroApplyElevation(g_zoom_hud, BroElevationOverlay); g_zoom_hud.hidden = YES; @@ -1019,6 +1038,8 @@ static void ShowZoomHUD(int percent) { g_zoom_hud.frame = NSMakeRect(NSMaxX(bounds) - hudSize.width - 16.0, NSMaxY(bounds) - hudSize.height - 16.0, hudSize.width, hudSize.height); + g_zoom_hud.layer.cornerRadius = BroCornerRadiusForSize( + BroSurfaceCornerRadius(), g_zoom_hud.bounds.size); g_zoom_hud_label.frame = NSMakeRect(padX, padY, labelSize.width, labelSize.height); @@ -1034,8 +1055,8 @@ static void ShowZoomHUD(int percent) { }]; } -// CreateNewBrowserTab(WithURL) are declared extern in bro_mac_internal.h. -void CreateNewBrowserTabWithURL(const std::string& url) { +static void CreateNewBrowserTabWithURLAndFocus(const std::string& url, + BOOL focusAddressWhenReady) { BroHandler* handler = BroHandler::GetInstance(); if (!handler) { return; @@ -1060,13 +1081,26 @@ void CreateNewBrowserTabWithURL(const std::string& url) { CefRect(0, 0, bounds.size.width, bounds.size.height)); window_info.runtime_style = CEF_RUNTIME_STYLE_ALLOY; - // Create the browser with the specified URL - CefBrowserHost::CreateBrowser(window_info, handler, url, browser_settings, - nullptr, nullptr); + if (focusAddressWhenReady) { + [g_pending_address_focus_containers addObject:browserContainer]; + } + + // Create the browser with the specified URL. If creation is rejected + // synchronously, discard the pending focus request with the container. + if (!CefBrowserHost::CreateBrowser(window_info, handler, url, + browser_settings, nullptr, nullptr)) { + [g_pending_address_focus_containers removeObject:browserContainer]; + [browserContainer removeFromSuperview]; + } +} + +// CreateNewBrowserTab(WithURL) are declared extern in bro_mac_internal.h. +void CreateNewBrowserTabWithURL(const std::string& url) { + CreateNewBrowserTabWithURLAndFocus(url, NO); } void CreateNewBrowserTab(void) { - CreateNewBrowserTabWithURL("about:blank"); + CreateNewBrowserTabWithURLAndFocus("about:blank", YES); } #pragma mark - BroAppDelegate @@ -1134,24 +1168,75 @@ - (void)setHandlingSendEvent:(BOOL)handlingSendEvent { - (void)sendEvent:(NSEvent*)event { CefScopedSendingEvent sendingEventScoper; - // Ctrl+Tab / Ctrl+Shift+Tab cycle tabs. This cannot be a menu key + // A repeated downloads-row shortcut is only meaningful when one magnifier + // is focused or hovered. Resolve it before the normal menu/event pass so it + // can never reveal an arbitrary file. + if (event.window == g_main_window && + BroPerformContextualDownloadsShortcut(event)) { + return; + } + + // Route in-page Find before AppKit's field editor sees the event. Generic + // Find selectors are otherwise consumed as text-field operations while the + // native query field is focused instead of reaching the browser command. + if (event.type == NSEventTypeKeyDown && event.window == g_main_window) { + NSEventModifierFlags relevant = + NSEventModifierFlagCommand | NSEventModifierFlagShift | + NSEventModifierFlagControl | NSEventModifierFlagOption; + NSEventModifierFlags mods = + event.modifierFlags & NSEventModifierFlagDeviceIndependentFlagsMask & + relevant; + NSString* chars = event.charactersIgnoringModifiers.lowercaseString; + if ([chars isEqualToString:@"f"] && + mods == NSEventModifierFlagCommand) { + BroShowFindBar(); + return; + } + if ([chars isEqualToString:@"g"] && + (mods == NSEventModifierFlagCommand || + mods == (NSEventModifierFlagCommand | NSEventModifierFlagShift))) { + if (mods & NSEventModifierFlagShift) { + BroFindPrevious(); + } else { + BroFindNext(); + } + return; + } + } + + // Ctrl+Tab / Ctrl+Shift+Tab and Ctrl+PageDown / Ctrl+PageUp cycle tabs. + // Tab cannot be a menu key // equivalent: Tab is consumed for focus traversal before the menu pass // when the browser view is first responder. Only the main window cycles; // DevTools and other windows keep their native behavior. - if (event.type == NSEventTypeKeyDown && event.keyCode == 48 /* kVK_Tab */ && - event.window == g_main_window && g_tab_bar) { + if (event.type == NSEventTypeKeyDown && event.window == g_main_window && + g_tab_bar && + (event.keyCode == 48 /* kVK_Tab */ || + event.keyCode == 116 /* kVK_PageUp */ || + event.keyCode == 121 /* kVK_PageDown */)) { NSEventModifierFlags mods = event.modifierFlags & NSEventModifierFlagDeviceIndependentFlagsMask; BOOL ctrlOnly = (mods & NSEventModifierFlagControl) != 0 && (mods & (NSEventModifierFlagCommand | NSEventModifierFlagOption)) == 0; if (ctrlOnly) { + BOOL previous = event.keyCode == 116 || + (event.keyCode == 48 && + (mods & NSEventModifierFlagShift)); [g_tab_bar activateTabRelativeToActiveWithOffset: - (mods & NSEventModifierFlagShift) ? -1 : 1]; + previous ? -1 : 1]; return; } } + // Escape closes in-page Find even if keyboard focus has moved from its + // search field to one of the result-navigation buttons. + if (event.type == NSEventTypeKeyDown && event.keyCode == 53 && + event.window == g_main_window && BroFindBarVisible()) { + BroHideFindBar(); + return; + } + // Cmd+Left/Right = history back/forward. Handled here rather than as menu // key equivalents or a CefKeyboardHandler because CEF's windowed mac view // never forwards Cmd-modified keys to the renderer, so pages cannot see @@ -1289,6 +1374,25 @@ - (void)setupMainMenu { [editMenu addItemWithTitle:@"Copy" action:@selector(copy:) keyEquivalent:@"c"]; [editMenu addItemWithTitle:@"Paste" action:@selector(paste:) keyEquivalent:@"v"]; [editMenu addItemWithTitle:@"Select All" action:@selector(selectAll:) keyEquivalent:@"a"]; + [editMenu addItem:[NSMenuItem separatorItem]]; + NSMenuItem* findMenuItem = [[NSMenuItem alloc] initWithTitle:@"Find" + action:nil + keyEquivalent:@""]; + NSMenu* findMenu = [[NSMenu alloc] initWithTitle:@"Find"]; + [findMenu addItemWithTitle:@"Find…" + action:@selector(showFindBar:) + keyEquivalent:@"f"]; + [findMenu addItemWithTitle:@"Find Next" + action:@selector(findNextInPage:) + keyEquivalent:@"g"]; + NSMenuItem* findPreviousItem = + [findMenu addItemWithTitle:@"Find Previous" + action:@selector(findPreviousInPage:) + keyEquivalent:@"g"]; + findPreviousItem.keyEquivalentModifierMask = + NSEventModifierFlagCommand | NSEventModifierFlagShift; + findMenuItem.submenu = findMenu; + [editMenu addItem:findMenuItem]; editMenuItem.submenu = editMenu; [mainMenu addItem:editMenuItem]; @@ -1305,6 +1409,26 @@ - (void)setupMainMenu { [viewMenu addItemWithTitle:@"Stop" action:@selector(stopLoading:) keyEquivalent:@"."]; [viewMenu addItem:[NSMenuItem separatorItem]]; + NSMenuItem* downloadsItem = + [viewMenu addItemWithTitle:@"Downloads" + action:@selector(showDownloads:) + keyEquivalent:@"j"]; + downloadsItem.keyEquivalentModifierMask = + NSEventModifierFlagCommand | NSEventModifierFlagShift; + NSMenuItem* desktopItem = + [viewMenu addItemWithTitle:@"Desktop Viewport" + action:@selector(selectDesktopViewport:) + keyEquivalent:@"d"]; + desktopItem.keyEquivalentModifierMask = + NSEventModifierFlagCommand | NSEventModifierFlagShift; + NSMenuItem* mobileItem = + [viewMenu addItemWithTitle:@"Mobile Viewport" + action:@selector(selectMobileViewport:) + keyEquivalent:@"m"]; + mobileItem.keyEquivalentModifierMask = + NSEventModifierFlagCommand | NSEventModifierFlagShift; + [viewMenu addItem:[NSMenuItem separatorItem]]; + // Zoom controls. Cmd+"=" is the unshifted "+" key; a "+" key equivalent // with a Command-only mask would never match because typing "+" requires // Shift, which AppKit only auto-infers for uppercase letters. @@ -1344,12 +1468,33 @@ - (void)setupMainMenu { NSMenu* historyMenu = [[NSMenu alloc] initWithTitle:@"History"]; historyMenu.delegate = self; - NSMenuItem* backItem = [historyMenu addItemWithTitle:@"Back" action:@selector(goBack:) keyEquivalent:@"["]; + NSString* leftArrow = + [NSString stringWithFormat:@"%C", (unichar)NSLeftArrowFunctionKey]; + NSString* rightArrow = + [NSString stringWithFormat:@"%C", (unichar)NSRightArrowFunctionKey]; + NSMenuItem* backItem = [historyMenu addItemWithTitle:@"Back" + action:@selector(goBack:) + keyEquivalent:leftArrow]; backItem.keyEquivalentModifierMask = NSEventModifierFlagCommand; - NSMenuItem* forwardItem = [historyMenu addItemWithTitle:@"Forward" action:@selector(goForward:) keyEquivalent:@"]"]; + NSMenuItem* forwardItem = [historyMenu addItemWithTitle:@"Forward" + action:@selector(goForward:) + keyEquivalent:rightArrow]; forwardItem.keyEquivalentModifierMask = NSEventModifierFlagCommand; + // Keep the established bracket aliases active without advertising two + // different primary bindings in the menu or toolbar tooltip. + NSMenuItem* backBracket = [[NSMenuItem alloc] initWithTitle:@"Back" + action:@selector(goBack:) + keyEquivalent:@"["]; + backBracket.hidden = YES; + [historyMenu addItem:backBracket]; + NSMenuItem* forwardBracket = [[NSMenuItem alloc] initWithTitle:@"Forward" + action:@selector(goForward:) + keyEquivalent:@"]"]; + forwardBracket.hidden = YES; + [historyMenu addItem:forwardBracket]; + NSMenuItem* recentsSeparator = [NSMenuItem separatorItem]; recentsSeparator.tag = kBroHistorySeparatorTag; [historyMenu addItem:recentsSeparator]; @@ -1361,10 +1506,12 @@ - (void)setupMainMenu { NSMenuItem* fileMenuItem = [[NSMenuItem alloc] init]; NSMenu* fileMenu = [[NSMenu alloc] initWithTitle:@"File"]; - // Single-window app: Cmd+N means new tab, not new window. + [fileMenu addItemWithTitle:@"New Window" + action:@selector(newWindow:) + keyEquivalent:@"n"]; [fileMenu addItemWithTitle:@"New Tab" action:@selector(newTab:) - keyEquivalent:@"n"]; + keyEquivalent:@"t"]; [fileMenu addItem:[NSMenuItem separatorItem]]; [fileMenu addItemWithTitle:@"Close Tab" action:@selector(closeTab:) @@ -1429,9 +1576,12 @@ - (void)setupMainMenu { // Tab actions; validateMenuItem: retitles them to match the current state. [windowMenu addItem:[NSMenuItem separatorItem]]; - [windowMenu addItemWithTitle:@"Pin Tab" - action:@selector(togglePinActiveTab:) - keyEquivalent:@""]; + NSMenuItem* pinItem = + [windowMenu addItemWithTitle:@"Pin Tab" + action:@selector(togglePinActiveTab:) + keyEquivalent:@"p"]; + pinItem.keyEquivalentModifierMask = + NSEventModifierFlagCommand | NSEventModifierFlagOption; NSMenuItem* splitItem = [windowMenu addItemWithTitle:@"Split Screen" action:@selector(toggleSplitScreen:) keyEquivalent:@"s"]; @@ -1495,8 +1645,12 @@ - (void)createBrowserInWindow { // Create the browser on a blank page; the address bar stays empty. std::string url = "about:blank"; - CefBrowserHost::CreateBrowser(window_info, handler, url, browser_settings, - nullptr, nullptr); + [g_pending_address_focus_containers addObject:browserContainer]; + if (!CefBrowserHost::CreateBrowser(window_info, handler, url, + browser_settings, nullptr, nullptr)) { + [g_pending_address_focus_containers removeObject:browserContainer]; + [browserContainer removeFromSuperview]; + } if (g_toolbar) { [g_toolbar updateURL:@""]; @@ -1654,6 +1808,48 @@ - (void)newTab:(id)sender { CreateNewBrowserTab(); } +- (void)newWindow:(id)sender { + NSURL* applicationURL = NSBundle.mainBundle.bundleURL; + if (!applicationURL) { + return; + } + + // CEF holds a process lock on its profile directory. This codebase's + // window state is intentionally single-window, so launch a second app + // instance with an isolated temporary profile instead of corrupting the + // live profile or relabeling a tab as a window. + NSString* profile = [NSTemporaryDirectory() + stringByAppendingPathComponent:[NSString + stringWithFormat:@"Bro-New-Window-%@", NSUUID.UUID.UUIDString]]; + NSWorkspaceOpenConfiguration* configuration = + [NSWorkspaceOpenConfiguration configuration]; + configuration.createsNewApplicationInstance = YES; + configuration.activates = YES; + configuration.addsToRecentItems = NO; + configuration.environment = @{ @"BRO_USER_DATA_DIR" : profile }; + [[NSWorkspace sharedWorkspace] + openApplicationAtURL:applicationURL + configuration:configuration + completionHandler:^(NSRunningApplication* application, + NSError* error) { + if (error) { + NSLog(@"Unable to open a new browser window: %@", error); + } + }]; +} + +- (void)showFindBar:(id)sender { + BroShowFindBar(); +} + +- (void)findNextInPage:(id)sender { + BroFindNext(); +} + +- (void)findPreviousInPage:(id)sender { + BroFindPrevious(); +} + - (void)closeTab:(id)sender { BroHandler* handler = BroHandler::GetInstance(); if (handler) { @@ -1702,6 +1898,9 @@ - (void)selectPreviousTab:(id)sender { } - (void)togglePinActiveTab:(id)sender { + if ([g_tab_bar performContextualPinShortcut]) { + return; + } BroDismissTransientOverlays(); for (BroTabView* tab in g_tab_bar.tabs) { if (tab.browserId == g_tab_bar.activeTabId) { @@ -1712,6 +1911,9 @@ - (void)togglePinActiveTab:(id)sender { } - (void)toggleSplitScreen:(id)sender { + if ([g_tab_bar performContextualSplitShortcut]) { + return; + } BroDismissTransientOverlays(); if (SplitActive()) { ToggleSplitForTab(g_split_browser_id); @@ -1724,6 +1926,21 @@ - (void)showCommandPalette:(id)sender { ToggleCommandPalette(BroPaletteScopeAll); } +- (void)showDownloads:(id)sender { + HideCommandPalette(); + BroToggleDownloadsPopover(); +} + +- (void)selectDesktopViewport:(id)sender { + BroDismissTransientOverlays(); + [g_toolbar selectDesktopMode:sender]; +} + +- (void)selectMobileViewport:(id)sender { + BroDismissTransientOverlays(); + [g_toolbar selectMobileMode:sender]; +} + // ⇧⌘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 { @@ -1759,6 +1976,25 @@ - (BOOL)validateMenuItem:(NSMenuItem*)menuItem { if (action == @selector(reopenClosedTab:)) { return BroClosedTabsCount() > 0; } + if (action == @selector(showDownloads:)) { + return g_toolbar != nil && BroHasRecentDownloads(); + } + if (action == @selector(selectDesktopViewport:) || + action == @selector(selectMobileViewport:)) { + BroHandler* handler = BroHandler::GetInstance(); + int activeId = handler ? handler->GetActiveBrowserId() : -1; + if (!handler || activeId < 0) { + menuItem.state = NSControlStateValueOff; + return NO; + } + BOOL mobile = handler->IsTabMobile(activeId); + BOOL itemSelected = action == @selector(selectMobileViewport:) + ? mobile + : !mobile; + menuItem.state = itemSelected ? NSControlStateValueOn + : NSControlStateValueOff; + return YES; + } if (action == @selector(togglePinActiveTab:)) { BroTabView* active = nil; for (BroTabView* tab in g_tab_bar.tabs) { @@ -1848,9 +2084,11 @@ - (BOOL)windowShouldClose:(NSWindow*)sender { - (void)windowWillClose:(NSNotification*)notification { HideCommandPalette(); + BroHideFindBar(); // The retained palette belongs to the dying window's view tree; rebuild it // fresh for any future window. TeardownCommandPalette(); + BroTeardownFindBar(); g_main_window = nil; g_toolbar = nil; g_tab_bar = nil; @@ -1961,6 +2199,10 @@ bool OnTabCreated(int browser_id, const std::string& url, void* native_view) { return false; } + BOOL shouldFocusAddress = + [g_pending_address_focus_containers containsObject:container]; + [g_pending_address_focus_containers removeObject:container]; + g_browser_views[@(browser_id)] = container; // If this was a popup's container, it is no longer pending. @@ -1987,6 +2229,21 @@ bool OnTabCreated(int browser_id, const std::string& url, void* native_view) { // A newly adopted tab becomes active without going through // OnActiveTabChanged; the shell follows its viewport mode (desktop). UpdateWindowForViewportMode(TabIsMobile(browser_id), YES); + + if (shouldFocusAddress) { + // Let adoption and layout finish before asking AppKit for its field + // editor. The active-ID and blank-URL guards make multiple rapid new-tab + // requests deterministic: only the latest still-blank tab receives focus. + dispatch_async(dispatch_get_main_queue(), ^{ + if (!g_tab_bar || g_tab_bar.activeTabId != browser_id) { + return; + } + BroTabView* tab = [g_tab_bar tabWithBrowserId:browser_id]; + if (tab && BroURLIsBlank(tab.tabURL)) { + [tab focusAddressField]; + } + }); + } return true; } diff --git a/src/bro_mac_internal.h b/src/bro_mac_internal.h index 5754109..319bdd4 100644 --- a/src/bro_mac_internal.h +++ b/src/bro_mac_internal.h @@ -24,7 +24,7 @@ @class BroTabBar; @class BroHoverButton; @class BroHoverHighlightGroup; -@class BroTextMorphView; +@class BroShimmerTextView; // Layout/timing constants shared across the split family. Each file gets // its own copy (harmless for compile-time constants); kWindowBorderAlpha is @@ -32,6 +32,20 @@ static const CGFloat kToolbarHeight = 52.0; static const CGFloat kButtonSize = 28.0; static const CGFloat kButtonSpacing = 4.0; +static const CGFloat kToolbarTrailingInset = 12.0; +// Radix's magnifier has 2pt more visible whitespace toward Downloads than +// the Download/Desktop pair. Move the complete control—not only its glyph— +// so its icon remains centered inside hover and focus feedback. +static const CGFloat kTabSearchOpticalOffsetX = 2.0; + +// The trailing controls are indexed from the right: mobile = 0, desktop = 1, +// downloads = 2, search = 3. Keeping their x positions on one equation makes +// the 4pt rhythm survive every window and mobile-shell resize. +static inline CGFloat BroTrailingControlX(CGFloat toolbarWidth, + NSUInteger indexFromRight) { + return toolbarWidth - kToolbarTrailingInset - kButtonSize - + indexFromRight * (kButtonSize + kButtonSpacing); +} // Wide enough that the "Enter URL or search" placeholder reads in full. static const CGFloat kTabPillMaxWidth = 180.0; static const CGFloat kTabPillMinWidth = 110.0; @@ -52,9 +66,11 @@ static const CGFloat kTabTextFontSize = 10.0; static const CGFloat kTabTextFrameHeight = 18.0; // A collapsed pill is exactly square. static const CGFloat kTabPillSquareWidth = kTabPillHeight; +// A pinned inactive pill stays text-free but keeps separate favicon/select +// and trailing unpin targets. +static const CGFloat kPinnedTabPillWidth = 52.0; // The "+" button is half the pill's size, centered on the same midline. static const CGFloat kAddTabButtonSize = kTabPillHeight / 2.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 @@ -63,7 +79,6 @@ static const CGFloat kMobileViewportHeight = 844.0; // matches CDP metrics // downloads button) still fits. static const CGFloat kMobileShellWidth = 512.0; static const NSTimeInterval kViewportAnimDuration = 0.28; -static const CGFloat kWindowCornerRadiusFallback = 12.0; // Tab hover card: dwell time on a pill before the card appears (its width // tracks the hovered pill's). The grace period keeps the card up after the // mouse leaves the pill so it can travel onto the card's action buttons. @@ -131,9 +146,11 @@ extern void BroFetchFaviconGuarded(NSString* urlString, void (^applyImage)(NSImage* image)); // Downloads popover: toggle shows or hides it; hide is safe to call any -// time. Defined with the popover class. +// time. Defined with the popover class. The popover never opens while the +// list is empty — gate menu validation on BroHasRecentDownloads. extern void BroHideDownloadsPopover(void); extern void BroToggleDownloadsPopover(void); +extern BOOL BroHasRecentDownloads(void); // Blank pages (and browsers with no committed URL yet) keep their opaque CEF // view hidden so the black window backdrop shows through as the new-tab @@ -148,8 +165,7 @@ 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 -// URL. +// for navigation; BroTabView.tabURL keeps the canonical URL. extern NSString* BroDisplayHostForURL(NSString* urlString); // Animates the window between its desktop frame and a shell that hugs the @@ -171,6 +187,11 @@ extern void CreateNewBrowserTabWithURL(const std::string& url); // 1pt #111111 hairline. extern NSColor* BroControlBorderColor(void); +// Shared tint for the template globe shown while a tab has no fetched +// favicon. Kept darker than regular chrome icons so it stays defined when +// glass lifts over a light desktop background. +extern NSColor* BroPlaceholderFaviconColor(void); + // True if the given tab has mobile emulation active. extern BOOL TabIsMobile(int browser_id); @@ -230,8 +251,29 @@ extern NSDictionary* BroLayerTransitionActions(void); // 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; +// Canonical shortcut metadata for the native tooltip, VoiceOver help, and +// focused contextual activation. Global commands use matching NSMenuItems. +@property (nonatomic, copy) NSString* shortcutKeyEquivalent; +@property (nonatomic, assign) NSEventModifierFlags shortcutModifierMask; +// Used by compound controls to keep hover surfaces visible while keyboard +// focus moves through them and to track contextual shortcut targets. +@property (nonatomic, copy) void (^focusChangedHandler)(BroHoverButton* button, + BOOL focused); +@property (nonatomic, copy) void (^hoverChangedHandler)(BroHoverButton* button, + BOOL hovered); +- (void)configureActionLabel:(NSString*)label + keyEquivalent:(NSString*)keyEquivalent + modifierMask:(NSEventModifierFlags)modifierMask; @end +// Shared shortcut formatting/matching keeps menu hints, tooltips, and +// contextual event routing in agreement. +extern NSString* BroShortcutDisplayString(NSString* keyEquivalent, + NSEventModifierFlags modifierMask); +extern BOOL BroEventMatchesShortcut(NSEvent* event, + NSString* keyEquivalent, + NSEventModifierFlags modifierMask); + // Builds a dimmed, non-editable NSTextField used by the hover card, the // downloads popover, and the tab search panel/rows. extern NSTextField* BroHoverCardLabel(NSFont* font, CGFloat whiteAlpha); @@ -243,27 +285,28 @@ extern NSTextField* BroHoverCardLabel(NSFont* font, CGFloat whiteAlpha); @interface BroAddressField : NSTextField @end -// One tab pill in the tab strip. Its address field is re-parented onto the -// active pill by the tab strip; the toolbar flips editingAddress when that -// field gains/loses focus. +// One tab pill in the tab strip. Each pill permanently owns its address field; +// the active pill reveals it only while editing. @interface BroTabView : NSView @property (nonatomic, assign) int browserId; @property (nonatomic, strong) NSImageView* faviconView; -@property (nonatomic, strong) NSProgressIndicator* loadingSpinner; -@property (nonatomic, strong) BroTextMorphView* titleLabel; -@property (nonatomic, strong) NSButton* closeButton; +@property (nonatomic, strong) BroShimmerTextView* titleLabel; +// Each pill owns its editor for its entire lifetime. Only the active pill may +// reveal it, which avoids moving a live AppKit field editor between views. +@property (nonatomic, strong) BroAddressField* addressField; +@property (nonatomic, strong) BroHoverButton* closeButton; @property (nonatomic, assign) BOOL isActive; @property (nonatomic, assign) BOOL isLoading; // NO on a lone tab: closing it would close the window, so the pill hides ✕. @property (nonatomic, assign) BOOL closable; -// YES while the hosted address field is being edited; keeps the focused +// YES while the owned address field is being edited; keeps the focused // border through hover/layout appearance refreshes. @property (nonatomic, assign) BOOL editingAddress; // YES once the pill is too narrow for text: favicon only, centered. @property (nonatomic, assign) BOOL iconOnly; -// Pinned pills sit as favicon-only squares at the left edge of the strip and -// hide their ✕ (Cmd+W still closes them). The active pinned pill expands so -// the hosted address field stays usable. +// Pinned pills sit as compact favicon-only controls at the left edge of the +// strip, with a separate hover-revealed unpin action. The active pinned pill +// expands so the hosted address field stays usable; Cmd+W still closes it. @property (nonatomic, assign) BOOL pinned; // YES while this tab is the split screen's right pane; inactive pills get the // active-ish border so both halves read as "on screen". @@ -292,7 +335,8 @@ extern NSTextField* BroHoverCardLabel(NSFont* font, CGFloat whiteAlpha); @property (nonatomic, copy) NSString* faviconURL; - (void)setLoading:(BOOL)loading; - (void)setTabURL:(NSString*)url; -- (void)attachAddressField:(NSTextField*)field; +- (void)focusAddressField; +- (void)finishAddressEditing; @end #pragma mark - BroTabBar @@ -335,6 +379,8 @@ extern NSTextField* BroHoverCardLabel(NSFont* font, CGFloat whiteAlpha); - (void)tabHoverEnded:(BroTabView*)tab; - (void)cardHoverBegan; - (void)cardHoverEnded; +- (void)tabFocusBegan:(BroTabView*)tab; +- (void)tabFocusEnded:(BroTabView*)tab; - (void)hideHoverCard; - (void)updateTabDescription:(int)browserId description:(NSString*)desc; // Toggles the tab's pinned state, moving it across the pinned/unpinned @@ -347,6 +393,10 @@ extern NSTextField* BroHoverCardLabel(NSFont* font, CGFloat whiteAlpha); // Moves the split pair's pills next to each other so they can render as one // joined control; refreshes the joined styling either way. - (void)ensureSplitPairAdjacent; +// Menu shortcuts act on the visible hover-card tab when there is one, keeping +// their target identical to the icon whose tooltip advertises the shortcut. +- (BOOL)performContextualPinShortcut; +- (BOOL)performContextualSplitShortcut; @end // The tab bar; nil before the window exists. Defined with BroTabBar. @@ -358,14 +408,12 @@ extern BroTabBar* g_tab_bar; @property (nonatomic, strong) BroHoverButton* backButton; @property (nonatomic, strong) BroHoverButton* forwardButton; @property (nonatomic, strong) BroHoverButton* refreshButton; -@property (nonatomic, strong) NSTextField* addressField; @property (nonatomic, strong) BroHoverButton* desktopButton; @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)addressFieldDidFocus:(BroAddressField*)field; - (void)setViewportMode:(BOOL)mobile; - (void)updateURL:(NSString*)url; - (void)focusAddressField; @@ -376,12 +424,21 @@ extern BroTabBar* g_tab_bar; // icons briefly (they hide at rest and show on hover; the toolbar owns the // shared hover zone). Called from bro_downloads.mm. - (void)pulseDownloadsButton; +// Completes the four-control trailing cluster after BroTabBar exists. +- (void)registerTabSearchButton:(BroHoverButton*)button; // Menu-action forwarders; called on g_toolbar from BroWindow/BroAppDelegate // and the UI-update bridge functions. - (void)goBack:(id)sender; - (void)goForward:(id)sender; - (void)refresh:(id)sender; +- (void)toggleDownloads:(id)sender; +- (void)selectDesktopMode:(id)sender; +- (void)selectMobileMode:(id)sender; - (void)updateNavigationState:(BOOL)canGoBack canGoForward:(BOOL)canGoForward; @end +// Handles ⌥⌘R only while the downloads popover has an unambiguous focused +// or hovered magnifier target. Defined in bro_downloads.mm. +extern BOOL BroPerformContextualDownloadsShortcut(NSEvent* event); + #endif // BRO_MAC_INTERNAL_H_ diff --git a/src/bro_motion.h b/src/bro_motion.h index c1e0a49..2e94e60 100644 --- a/src/bro_motion.h +++ b/src/bro_motion.h @@ -51,6 +51,20 @@ extern void BroRunLayoutSpring(void (^changes)(void), void (^completion)(void)); extern void BroApplyElevation(NSView* view, BroElevation elevation); + +// Shared material tokens for every glass surface: the window shell, active +// browse input, and floating panels all use this tint/style/hairline recipe. +extern const CGFloat kBroGlassTintAlpha; +extern const CGFloat kBroGlassBorderWidth; +extern NSColor* BroGlassTintColor(void); +extern NSColor* BroGlassBorderColor(void); +extern NSGlassEffectViewStyle BroGlassEffectStyle(void) + API_AVAILABLE(macos(26.0)); + +// Replaces the elevation's flat fill with the shared Liquid Glass backdrop. +// Call after BroApplyElevation and before adding any other subviews so the +// backdrop stays the bottom sibling. +extern void BroInstallGlassBackdrop(NSView* panel, CGFloat cornerRadius); extern void BroOverlayShow(NSView* view); extern void BroOverlayHide(NSView* view); diff --git a/src/bro_motion.mm b/src/bro_motion.mm index ac9c2c5..f293256 100644 --- a/src/bro_motion.mm +++ b/src/bro_motion.mm @@ -4,6 +4,21 @@ #include +const CGFloat kBroGlassTintAlpha = 0.18; +const CGFloat kBroGlassBorderWidth = 1.0; + +NSColor* BroGlassTintColor(void) { + return [NSColor colorWithWhite:0.0 alpha:kBroGlassTintAlpha]; +} + +NSColor* BroGlassBorderColor(void) { + return [NSColor colorWithWhite:1.0 alpha:0.12]; +} + +NSGlassEffectViewStyle BroGlassEffectStyle(void) { + return NSGlassEffectViewStyleClear; +} + namespace { struct BroSpringValues { @@ -244,9 +259,8 @@ void BroApplyElevation(NSView* view, BroElevation elevation) { case BroElevationPanel: layer.backgroundColor = [NSColor colorWithWhite:0.18 alpha:0.96].CGColor; - layer.borderWidth = 1.0; - layer.borderColor = - [NSColor colorWithWhite:1.0 alpha:0.12].CGColor; + layer.borderWidth = kBroGlassBorderWidth; + layer.borderColor = BroGlassBorderColor().CGColor; layer.shadowOffset = elevation == BroElevationOverlay ? CGSizeMake(0.0, -2.0) : CGSizeMake(0.0, -4.0); @@ -258,6 +272,66 @@ void BroApplyElevation(NSView* view, BroElevation elevation) { } } +// 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 panel's controls are +// never its subviews); older systems get the NSVisualEffectView blur + tint +// approximation. +// Constraint-pinned rather than autoresized: panels are typically created at +// NSZeroRect and sized on first show, and autoresizing from a zero-size +// superview leaves the backdrop with stale geometry. +static void BroPinBackdrop(NSView* backdrop, NSView* panel) { + backdrop.translatesAutoresizingMaskIntoConstraints = NO; + [panel addSubview:backdrop]; + [NSLayoutConstraint activateConstraints:@[ + [backdrop.leadingAnchor constraintEqualToAnchor:panel.leadingAnchor], + [backdrop.trailingAnchor constraintEqualToAnchor:panel.trailingAnchor], + [backdrop.topAnchor constraintEqualToAnchor:panel.topAnchor], + [backdrop.bottomAnchor constraintEqualToAnchor:panel.bottomAnchor], + ]]; +} + +void BroInstallGlassBackdrop(NSView* panel, CGFloat cornerRadius) { + if (!panel) { + return; + } + panel.wantsLayer = YES; + panel.layer.backgroundColor = [NSColor clearColor].CGColor; + CGFloat backdropRadius = BroCornerRadiusForSize( + BroNestedCornerRadius(cornerRadius, 0.0), panel.bounds.size); + if (@available(macOS 26.0, *)) { + NSGlassEffectView* glass = + [[NSGlassEffectView alloc] initWithFrame:panel.bounds]; + glass.cornerRadius = backdropRadius; + glass.style = BroGlassEffectStyle(); + glass.tintColor = BroGlassTintColor(); + glass.wantsLayer = YES; + glass.layer.masksToBounds = YES; + glass.layer.cornerCurve = kCACornerCurveContinuous; + BroPinBackdrop(glass, panel); + } else { + NSVisualEffectView* glass = + [[NSVisualEffectView alloc] initWithFrame:panel.bounds]; + glass.material = NSVisualEffectMaterialHUDWindow; + glass.blendingMode = NSVisualEffectBlendingModeWithinWindow; + glass.state = NSVisualEffectStateActive; + glass.wantsLayer = YES; + glass.layer.cornerRadius = backdropRadius; + glass.layer.masksToBounds = YES; + BroPinBackdrop(glass, panel); + NSView* tint = [[NSView alloc] initWithFrame:panel.bounds]; + tint.wantsLayer = YES; + tint.layer.backgroundColor = BroGlassTintColor().CGColor; + tint.layer.cornerRadius = backdropRadius; + tint.layer.masksToBounds = YES; + BroPinBackdrop(tint, panel); + } +} + void BroOverlayShow(NSView* view) { if (!view) { return; @@ -367,7 +441,8 @@ - (instancetype)initWithContainerView:(NSView*)container { container.wantsLayer = YES; highlightLayer_ = [CALayer layer]; highlightLayer_.anchorPoint = CGPointZero; - highlightLayer_.cornerRadius = kControlCornerRadius; + highlightLayer_.cornerRadius = BroCornerRadiusForSize( + BroControlCornerRadius(), highlightLayer_.bounds.size); highlightLayer_.backgroundColor = [NSColor colorWithWhite:1.0 alpha:0.08].CGColor; highlightLayer_.opacity = 0.0; @@ -391,6 +466,12 @@ - (void)hoverOnView:(NSView*)view animated:(BOOL)animated { CGRect target = [container convertRect:view.bounds fromView:view]; hoveredView_ = view; + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + highlightLayer_.cornerRadius = BroCornerRadiusForSize( + BroControlCornerRadius(), target.size); + [CATransaction commit]; + if (!animated || BroMotionReduced() || !wasVisible) { SetLayerFrameWithoutActions(highlightLayer_, target); } else { diff --git a/src/bro_palette.mm b/src/bro_palette.mm index b8d20ef..8db1283 100644 --- a/src/bro_palette.mm +++ b/src/bro_palette.mm @@ -253,6 +253,10 @@ static bool BroPaletteScoreItem(BroPaletteItem* item, RadixIcon icon = RadixIconMagnifyingGlass; if (action == @selector(newTab:)) { icon = RadixIconPlus; + } else if (action == @selector(showFindBar:) || + action == @selector(findNextInPage:) || + action == @selector(findPreviousInPage:)) { + icon = RadixIconMagnifyingGlass; } else if (action == @selector(closeTab:)) { icon = RadixIconCross2; } else if (action == @selector(reloadPage:) || @@ -264,6 +268,14 @@ static bool BroPaletteScoreItem(BroPaletteItem* item, icon = RadixIconArrowRight; } else if (action == @selector(toggleSplitScreen:)) { icon = RadixIconViewVertical; + } else if (action == @selector(togglePinActiveTab:)) { + icon = RadixIconDrawingPin; + } else if (action == @selector(showDownloads:)) { + icon = RadixIconDownload; + } else if (action == @selector(selectDesktopViewport:)) { + icon = RadixIconDesktop; + } else if (action == @selector(selectMobileViewport:)) { + icon = RadixIconMobile; } else if (action == @selector(focusAddressBar:)) { icon = RadixIconGlobe; } @@ -404,17 +416,22 @@ @implementation BroPaletteRow { // Same guard as BroTabView: a stale fetch must not overwrite the favicon // this row was last configured with. Main-thread only. NSUInteger faviconGeneration_; + BOOL focused_; } - (instancetype)initWithFrame:(NSRect)frame { self = [super initWithFrame:frame]; if (self) { self.wantsLayer = YES; - self.layer.cornerRadius = kControlCornerRadius; + self.layer.cornerRadius = + BroCornerRadiusForSize(BroControlCornerRadius(), self.bounds.size); + self.layer.actions = BroLayerTransitionActions(); + self.focusRingType = NSFocusRingTypeNone; iconView_ = [[NSImageView alloc] initWithFrame:NSMakeRect(12, (frame.size.height - 15) / 2.0, 15, 15)]; iconView_.imageScaling = NSImageScaleProportionallyUpOrDown; + iconView_.accessibilityElement = NO; [self addSubview:iconView_]; titleLabel_ = BroHoverCardLabel(BroUIFont(13.0), 0.9); @@ -440,7 +457,7 @@ - (instancetype)initWithFrame:(NSRect)frame { - (void)showPlaceholderFavicon { iconView_.image = RadixIconImage(RadixIconGlobe, 15); - iconView_.contentTintColor = [NSColor colorWithWhite:0x33 / 255.0 alpha:1.0]; + iconView_.contentTintColor = BroPlaceholderFaviconColor(); } - (void)setFaviconURLString:(NSString*)urlString { @@ -467,11 +484,20 @@ - (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; + // Command shortcuts are help, not part of the action's concise name. URL + // subtitles remain useful context for tab/history results. + if (item.kind == BroPaletteItemCommand) { + self.accessibilityLabel = titleLabel_.stringValue; + self.accessibilityHelp = item.subtitle.length > 0 + ? [NSString stringWithFormat:@"Keyboard shortcut: %@.", item.subtitle] + : @"Press Return to run this command."; + } else { + self.accessibilityLabel = + item.subtitle.length > 0 + ? [NSString stringWithFormat:@"%@, %@", item.title, item.subtitle] + : titleLabel_.stringValue; + self.accessibilityHelp = @"Press Return to activate."; + } faviconGeneration_++; if (item.icon) { @@ -527,6 +553,48 @@ - (void)mouseUp:(NSEvent*)event { } } +- (BOOL)acceptsFirstResponder { + return YES; +} + +- (BOOL)canBecomeKeyView { + return !self.hiddenOrHasHiddenAncestor; +} + +- (BOOL)becomeFirstResponder { + BOOL ok = [super becomeFirstResponder]; + if (ok) { + focused_ = YES; + self.layer.borderColor = BroControlBorderColor().CGColor; + self.layer.borderWidth = 1.0; + } + return ok; +} + +- (BOOL)resignFirstResponder { + BOOL ok = [super resignFirstResponder]; + if (ok) { + focused_ = NO; + self.layer.borderWidth = 0.0; + } + return ok; +} + +- (void)keyDown:(NSEvent*)event { + NSString* characters = event.charactersIgnoringModifiers; + unichar key = characters.length > 0 ? [characters characterAtIndex:0] : 0; + if (key == ' ' || key == '\r' || key == NSEnterCharacter) { + [NSApp sendAction:_action to:_target from:self]; + return; + } + [super keyDown:event]; +} + +- (BOOL)accessibilityPerformPress { + [NSApp sendAction:_action to:_target from:self]; + return YES; +} + @end // Flipped host for the row list so rows stack top-down in the scroll view. @@ -581,53 +649,15 @@ - (instancetype)initWithFrame:(NSRect)frame { self.accessibilityLabel = @"Command palette"; self.wantsLayer = YES; - self.layer.cornerRadius = kSurfaceCornerRadius; + self.layer.cornerRadius = + BroCornerRadiusForSize(BroSurfaceCornerRadius(), self.bounds.size); 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]; - } + BroInstallGlassBackdrop(self, self.layer.cornerRadius); searchIcon_ = [[NSImageView alloc] initWithFrame:NSZeroRect]; searchIcon_.image = RadixIconImage(RadixIconMagnifyingGlass, 15); searchIcon_.contentTintColor = [NSColor colorWithWhite:1.0 alpha:0.55]; + searchIcon_.accessibilityElement = NO; [self addSubview:searchIcon_]; searchField_ = [[NSTextField alloc] initWithFrame:NSZeroRect]; diff --git a/src/bro_tabstrip.mm b/src/bro_tabstrip.mm index f2b94ae..047a291 100644 --- a/src/bro_tabstrip.mm +++ b/src/bro_tabstrip.mm @@ -14,6 +14,25 @@ #import "bro_text_morph.h" #import "radix_icons.h" +// The active pill shares the same glass as its neighbors; a brighter +// hairline is its primary structural selection cue. +static const CGFloat kActiveTabBorderAlpha = 0.28; +static const CGFloat kHoveredTabBorderAlpha = 0.18; +static const CGFloat kInactiveTabForegroundAlpha = 0.42; +static const CFTimeInterval kTabColorTransitionDuration = 0.18; + +static NSDictionary* BroTabLayerTransitionActions(void) { + NSMutableDictionary* actions = [BroLayerTransitionActions() mutableCopy]; + for (NSString* key in @[ @"borderColor", @"backgroundColor" ]) { + CABasicAnimation* fade = [CABasicAnimation animationWithKeyPath:key]; + fade.duration = kTabColorTransitionDuration; + fade.timingFunction = [CAMediaTimingFunction + functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; + actions[key] = fade; + } + return actions; +} + // Scales about the view's center regardless of the layer's anchorPoint. // AppKit pins layer-backed views' anchorPoint to (0,0) and reasserts it on // layout, so the pivot is baked into the transform instead. Used only by @@ -29,8 +48,108 @@ static CATransform3D BroCenteredScale(NSView* view, CGFloat scale) { return CATransform3DTranslate(t, -w / 2.0, -h / 2.0, 0); } +// AppKit may make a clicked pill first responder before delivering its +// mouseDown:. That focus is pointer-driven and must not trigger the immediate +// preview reserved for keyboard traversal. +static BOOL BroCurrentEventIsPointerActivation(void) { + NSEventType type = NSApp.currentEvent.type; + return type == NSEventTypeLeftMouseDown || + type == NSEventTypeRightMouseDown || + type == NSEventTypeOtherMouseDown; +} + #pragma mark - BroHoverButton +NSString* BroShortcutDisplayString(NSString* keyEquivalent, + NSEventModifierFlags modifierMask) { + if (keyEquivalent.length == 0) { + return @""; + } + NSMutableString* display = [NSMutableString string]; + if (modifierMask & NSEventModifierFlagControl) [display appendString:@"⌃"]; + if (modifierMask & NSEventModifierFlagOption) [display appendString:@"⌥"]; + if (modifierMask & NSEventModifierFlagShift) [display appendString:@"⇧"]; + if (modifierMask & NSEventModifierFlagCommand) [display appendString:@"⌘"]; + NSString* key = keyEquivalent.uppercaseString; + if ([keyEquivalent isEqualToString:@"\e"]) { + key = @"Esc"; + } else if ([keyEquivalent + isEqualToString:[NSString stringWithFormat:@"%C", + (unichar)NSLeftArrowFunctionKey]]) { + key = @"←"; + } else if ([keyEquivalent + isEqualToString:[NSString stringWithFormat:@"%C", + (unichar)NSRightArrowFunctionKey]]) { + key = @"→"; + } else if ([keyEquivalent + isEqualToString:[NSString stringWithFormat:@"%C", + (unichar)NSPageUpFunctionKey]]) { + key = @"Page Up"; + } else if ([keyEquivalent + isEqualToString:[NSString stringWithFormat:@"%C", + (unichar)NSPageDownFunctionKey]]) { + key = @"Page Down"; + } + [display appendString:key]; + return display; +} + +static NSString* BroSpokenShortcutString(NSString* keyEquivalent, + NSEventModifierFlags modifierMask) { + NSMutableArray* parts = [NSMutableArray array]; + if (modifierMask & NSEventModifierFlagControl) [parts addObject:@"Control"]; + if (modifierMask & NSEventModifierFlagOption) [parts addObject:@"Option"]; + if (modifierMask & NSEventModifierFlagShift) [parts addObject:@"Shift"]; + if (modifierMask & NSEventModifierFlagCommand) [parts addObject:@"Command"]; + NSString* key = keyEquivalent.uppercaseString; + if ([keyEquivalent isEqualToString:@"["]) { + key = @"Left Bracket"; + } else if ([keyEquivalent isEqualToString:@"]"]) { + key = @"Right Bracket"; + } else if ([keyEquivalent isEqualToString:@"\e"]) { + key = @"Escape"; + } else if ([keyEquivalent + isEqualToString:[NSString stringWithFormat:@"%C", + (unichar)NSLeftArrowFunctionKey]]) { + key = @"Left Arrow"; + } else if ([keyEquivalent + isEqualToString:[NSString stringWithFormat:@"%C", + (unichar)NSRightArrowFunctionKey]]) { + key = @"Right Arrow"; + } else if ([keyEquivalent + isEqualToString:[NSString stringWithFormat:@"%C", + (unichar)NSPageUpFunctionKey]]) { + key = @"Page Up"; + } else if ([keyEquivalent + isEqualToString:[NSString stringWithFormat:@"%C", + (unichar)NSPageDownFunctionKey]]) { + key = @"Page Down"; + } + if (key.length > 0) { + [parts addObject:key]; + } + return [parts componentsJoinedByString:@"-"]; +} + +BOOL BroEventMatchesShortcut(NSEvent* event, + NSString* keyEquivalent, + NSEventModifierFlags modifierMask) { + if (event.type != NSEventTypeKeyDown || keyEquivalent.length == 0) { + return NO; + } + NSEventModifierFlags relevant = + NSEventModifierFlagControl | NSEventModifierFlagOption | + NSEventModifierFlagShift | NSEventModifierFlagCommand; + NSEventModifierFlags actual = + event.modifierFlags & NSEventModifierFlagDeviceIndependentFlagsMask & + relevant; + if (actual != (modifierMask & relevant)) { + return NO; + } + return [event.charactersIgnoringModifiers.lowercaseString + isEqualToString:keyEquivalent.lowercaseString]; +} + // BroHoverButton's @interface is declared in bro_mac_internal.h (shared // with the toolbar, downloads popover, tab search panel, and tab strip). @implementation BroHoverButton { @@ -43,7 +162,8 @@ - (instancetype)initWithFrame:(NSRect)frame { self = [super initWithFrame:frame]; if (self) { self.wantsLayer = YES; - self.layer.cornerRadius = kControlCornerRadius; + self.layer.cornerRadius = + BroCornerRadiusForSize(BroControlCornerRadius(), self.bounds.size); self.layer.actions = BroLayerTransitionActions(); // Keyboard focus shows as the same gray hairline the tab pills use, not // the system's accent-colored ring. @@ -88,10 +208,17 @@ - (void)setBaseBorderColor:(NSColor*)color { } - (BOOL)becomeFirstResponder { + // Compound controls can reveal themselves before AppKit commits focus, so + // an alpha-hidden button is never the invisible first responder. + if (_focusChangedHandler) { + _focusChangedHandler(self, YES); + } BOOL ok = [super becomeFirstResponder]; if (ok) { focused_ = YES; [self refreshBorder]; + } else if (_focusChangedHandler) { + _focusChangedHandler(self, NO); } return ok; } @@ -101,13 +228,34 @@ - (BOOL)resignFirstResponder { if (ok) { focused_ = NO; [self refreshBorder]; + if (_focusChangedHandler) { + _focusChangedHandler(self, NO); + } } return ok; } -// Hover/pressed feedback needs an enabled button, but the selected -// background persists even when the toggle is disabled (a selected viewport -// mode is inert until the other one is chosen). +- (void)configureActionLabel:(NSString*)label + keyEquivalent:(NSString*)keyEquivalent + modifierMask:(NSEventModifierFlags)modifierMask { + NSString* actionLabel = label.length > 0 ? label : @"Action"; + _shortcutKeyEquivalent = [keyEquivalent copy] ?: @""; + _shortcutModifierMask = modifierMask; + self.accessibilityLabel = actionLabel; + NSString* display = + BroShortcutDisplayString(_shortcutKeyEquivalent, modifierMask); + self.toolTip = display.length > 0 + ? [NSString stringWithFormat:@"%@ (%@)", actionLabel, display] + : actionLabel; + NSString* spoken = + BroSpokenShortcutString(_shortcutKeyEquivalent, modifierMask); + self.accessibilityHelp = spoken.length > 0 + ? [NSString stringWithFormat:@"Keyboard shortcut: %@.", spoken] + : nil; +} + +// Hover/pressed feedback layers over a persistent selected background for +// toggle/radio controls such as the viewport buttons. - (void)refreshBackground { CGFloat alpha = 0.0; if (pressed_) { @@ -145,6 +293,11 @@ - (void)setSelectedState:(BOOL)selectedState { - (void)setEnabled:(BOOL)enabled { if (!enabled && self.enabled && hovered_) { [_highlightGroup hoverOffView:self]; + if (_hoverChangedHandler) { + _hoverChangedHandler(self, NO); + } + hovered_ = NO; + pressed_ = NO; } [super setEnabled:enabled]; [self refreshBackground]; @@ -164,9 +317,13 @@ - (void)setFrameSize:(NSSize)newSize { // mouseExited:; reset so it reappears at rest. - (void)viewDidHide { [super viewDidHide]; + BOOL wasHovered = hovered_; hovered_ = NO; pressed_ = NO; [_highlightGroup hoverOffView:self]; + if (wasHovered && _hoverChangedHandler) { + _hoverChangedHandler(self, NO); + } [self refreshBackground]; [self refreshTransform]; } @@ -183,6 +340,9 @@ - (void)mouseEntered:(NSEvent*)event { } hovered_ = YES; [_highlightGroup hoverOnView:self]; + if (_hoverChangedHandler) { + _hoverChangedHandler(self, YES); + } [self refreshBackground]; [self refreshTransform]; } @@ -191,6 +351,9 @@ - (void)mouseExited:(NSEvent*)event { hovered_ = NO; pressed_ = NO; [_highlightGroup hoverOffView:self]; + if (_hoverChangedHandler) { + _hoverChangedHandler(self, NO); + } [self refreshBackground]; [self refreshTransform]; } @@ -210,10 +373,14 @@ - (void)mouseDown:(NSEvent*)event { } - (void)keyDown:(NSEvent*)event { - // Space is handled by NSButton; add Return as an activator too. + if (BroEventMatchesShortcut(event, _shortcutKeyEquivalent, + _shortcutModifierMask)) { + [self performClick:self]; + return; + } NSString* chars = event.charactersIgnoringModifiers; unichar c = chars.length > 0 ? [chars characterAtIndex:0] : 0; - if (c == '\r' || c == NSEnterCharacter) { + if (c == ' ' || c == '\r' || c == NSEnterCharacter) { [self performClick:self]; return; } @@ -243,16 +410,24 @@ void BroFetchFaviconGuarded(NSString* urlString, } @implementation BroTabView { + // Native Liquid Glass backdrop for the active browse input. Older macOS + // versions keep the same view slot with an NSVisualEffectView fallback. + NSView* glassBackdrop_; BOOL hovered_; BOOL focused_; BOOL wasActiveAtMouseDown_; - // Target state of the ✕ fade; guards against the many updateAppearance - // callers restarting an in-flight fade. - BOOL closeButtonShown_; + // Target state of the trailing action fade; guards against the many + // updateAppearance callers restarting an in-flight fade. + BOOL trailingActionShown_; + BOOL trailingActionShowsPin_; + BOOL trailingActionFocused_; // Bumped on every setFaviconURL:; a fetch completion only applies its image // if the generation still matches, so a slow response for a previous page // can't overwrite the current page's favicon. Main-thread only. NSUInteger faviconGeneration_; + // The built-in globe follows tab selection color; downloaded favicons keep + // their authored colors instead of being flattened to a template tint. + BOOL showingDefaultFavicon_; } - (instancetype)initWithFrame:(NSRect)frame browserId:(int)browserId { @@ -264,42 +439,106 @@ - (instancetype)initWithFrame:(NSRect)frame browserId:(int)browserId { _tabURL = @""; self.wantsLayer = YES; - self.layer.cornerRadius = kPillCornerRadius; + CGFloat pillCornerRadius = + BroCornerRadiusForSize(BroSurfaceCornerRadius(), self.bounds.size); + self.layer.cornerRadius = pillCornerRadius; self.layer.borderWidth = 1.0; - self.layer.actions = BroLayerTransitionActions(); + self.layer.actions = BroTabLayerTransitionActions(); // Keyboard focus shows as a white pill border instead of the system's // accent-colored ring. self.focusRingType = NSFocusRingTypeNone; + // The active tab is also the browser's address input, so give that pill + // its own untinted AppKit glass surface. It must remain transparent in + // every resting/editing/loading state; the glass refraction and hairline + // provide separation without an opaque or black fill. Keep it a sibling + // behind the controls: nesting the field in NSGlassEffectView.contentView + // would change hit testing and field-editor ownership. + if (@available(macOS 26.0, *)) { + NSGlassEffectView* glass = + [[NSGlassEffectView alloc] initWithFrame:self.bounds]; + glass.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + glass.cornerRadius = BroNestedCornerRadius(pillCornerRadius, 0.0); + glass.style = BroGlassEffectStyle(); + glass.tintColor = [NSColor clearColor]; + glassBackdrop_ = 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(pillCornerRadius, 0.0); + glass.layer.cornerCurve = kCACornerCurveContinuous; + glass.layer.masksToBounds = YES; + + glassBackdrop_ = glass; + } + glassBackdrop_.hidden = YES; + [self addSubview:glassBackdrop_]; + // Favicon view _faviconView = [[NSImageView alloc] initWithFrame:NSMakeRect(10, (kTabPillHeight - 15) / 2.0, 15, 15)]; + _faviconView.wantsLayer = YES; _faviconView.imageScaling = NSImageScaleProportionallyUpOrDown; + _faviconView.accessibilityElement = NO; // Default globe icon _faviconView.image = RadixIconImage(RadixIconGlobe, 15); - _faviconView.contentTintColor = [NSColor colorWithWhite:0x33 / 255.0 alpha:1.0]; + _faviconView.contentTintColor = BroPlaceholderFaviconColor(); + showingDefaultFavicon_ = YES; [self addSubview:_faviconView]; - // Loading spinner (same position as favicon, hidden by default) - _loadingSpinner = [[NSProgressIndicator alloc] initWithFrame:_faviconView.frame]; - _loadingSpinner.style = NSProgressIndicatorStyleSpinning; - _loadingSpinner.controlSize = NSControlSizeSmall; - _loadingSpinner.hidden = YES; - [self addSubview:_loadingSpinner]; - - // Host label (hidden on the active pill, where the address field shows) - _titleLabel = [[BroTextMorphView alloc] + // The host remains authoritative while loading; its shimmer is the one + // loading treatment, so the favicon never swaps to a second animation. + _titleLabel = [[BroShimmerTextView alloc] initWithFont:BroUIFont(kTabTextFontSize) - color:[NSColor secondaryLabelColor]]; + color:[NSColor labelColor]]; _titleLabel.frame = NSMakeRect( 32, (kTabPillHeight - kTabTextFrameHeight) / 2.0, frame.size.width - 32 - 26, kTabTextFrameHeight); - [_titleLabel setText:kBroBlankTabTitle animated:NO]; + [_titleLabel setText:kBroBlankTabTitle]; _titleLabel.autoresizingMask = NSViewWidthSizable; [self addSubview:_titleLabel]; - // Close button (visible whenever the pill is closable; the tab bar hides - // it on a lone tab) + // Every tab owns its editor permanently. Moving one shared NSTextField + // while AppKit's window-owned field editor was live could leave the old + // editor visible over the compact label. Permanent ownership makes that + // invalid state unrepresentable; layoutPillContents only chooses which of + // the two renderers is visible. + _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.delegate = g_toolbar; + _addressField.cell.scrollable = YES; + _addressField.cell.usesSingleLineMode = YES; + _addressField.cell.lineBreakMode = NSLineBreakByTruncatingTail; + _addressField.cell.truncatesLastVisibleLine = YES; + 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"; + _addressField.hidden = YES; + [self addSubview:_addressField]; + + // Trailing tab action: normal tabs show close; pinned tabs reuse the same + // slot and hover behavior for unpin. BroHoverButton* closeButton = [[BroHoverButton alloc] initWithFrame:NSMakeRect(frame.size.width - 26, (kTabPillHeight - 16) / 2.0, 16, 16)]; @@ -314,16 +553,27 @@ - (instancetype)initWithFrame:(NSRect)frame browserId:(int)browserId { // edges. 8 - 6 = 2. CGFloat closeButtonGap = (kTabPillHeight - NSHeight(closeButton.frame)) / 2.0; - closeButton.layer.cornerRadius = - BroNestedCornerRadius(kPillCornerRadius, closeButtonGap); + closeButton.layer.cornerRadius = BroCornerRadiusForSize( + BroNestedCornerRadius(pillCornerRadius, closeButtonGap), + closeButton.bounds.size); closeButton.target = self; closeButton.action = @selector(handleClose:); closeButton.autoresizingMask = NSViewMinXMargin; - closeButton.accessibilityLabel = @"Close tab"; - closeButton.toolTip = @"Close tab (⌘W)"; + [closeButton configureActionLabel:@"Close Tab" + keyEquivalent:@"w" + modifierMask:NSEventModifierFlagCommand]; + __weak BroTabView* weakSelf = self; + closeButton.focusChangedHandler = + ^(BroHoverButton* button, BOOL focused) { + BroTabView* strongSelf = weakSelf; + if (!strongSelf) { + return; + } + strongSelf->trailingActionFocused_ = focused; + [strongSelf updateAppearance]; + }; _closeButton = closeButton; - // Start hidden to match closeButtonShown_'s NO default, so the guard in - // setCloseButtonShown: can't leave a stale ✕ on a non-closable pill. + // Start hidden to match trailingActionShown_'s NO default. _closeButton.hidden = YES; [self addSubview:_closeButton]; @@ -354,6 +604,7 @@ - (void)setFaviconURL:(NSString*)urlString { }, ^(NSImage* image) { BroTabView* strongSelf = weakSelf; + strongSelf->showingDefaultFavicon_ = NO; strongSelf.faviconView.image = image; strongSelf.faviconView.contentTintColor = nil; }); @@ -369,57 +620,62 @@ - (void)setTabURL:(NSString*)url { 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). -// layoutPillContents owns the geometry — attaching to a collapsed square and -// letting autoresizing stretch the field from a negative width drifted it off -// the text inset and put it on top of the favicon. -- (void)attachAddressField:(NSTextField*)field { - field.autoresizingMask = NSViewNotSizable; - [self addSubview:field]; - _editingAddress = NO; - [self layoutPillContents]; + [_titleLabel setText:display]; + // URL callbacks continue to update canonical tab state while the user is + // typing, but never replace the live edit buffer. + if (!_addressField.currentEditor) { + _addressField.stringValue = BroURLIsBlank(_tabURL) ? @"" : _tabURL; + } } // Single place deciding what the pill shows at its current width: normally the // favicon sits at the left inset with text beside it; collapsed, the favicon // centers and both the host label and the hosted address field step aside. - (void)layoutPillContents { - CGFloat iconX = _iconOnly ? (self.bounds.size.width - 15) / 2.0 : 10.0; + BOOL compactPinned = + _pinned && self.bounds.size.width >= kPinnedTabPillWidth - 0.5; + CGFloat iconX = (_iconOnly && !compactPinned) + ? (self.bounds.size.width - 15) / 2.0 + : 10.0; NSRect iconFrame = NSMakeRect(iconX, (kTabPillHeight - 15) / 2.0, 15, 15); _faviconView.frame = iconFrame; - _loadingSpinner.frame = iconFrame; - // Resting active and inactive tabs deliberately share the morph view. The + CGFloat actionX = self.bounds.size.width - 26.0; + _closeButton.frame = NSMakeRect( + actionX, (self.bounds.size.height - 16.0) / 2.0, 16.0, 16.0); + // Resting active and inactive tabs deliberately share the atomic text view. // 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; + BOOL showCompactText = !_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; + CGFloat textRight = (_closable || _pinned) ? 26.0 : 10.0; NSRect textFrame = NSMakeRect(32, (self.bounds.size.height - kTabTextFrameHeight) / 2.0, MAX(0.0, self.bounds.size.width - 32 - textRight), kTabTextFrameHeight); + // Commit frames and visibility together. At the end of every state change + // exactly one text renderer is eligible to draw (or neither in icon-only + // mode), and neither Core Animation nor AppKit gets an intermediate frame. + [CATransaction begin]; + [CATransaction setDisableActions:YES]; _titleLabel.frame = textFrame; - for (NSView* v in self.subviews) { - if ([v isKindOfClass:[BroAddressField class]]) { - v.hidden = _iconOnly || !showEditor; - v.frame = textFrame; - break; - } - } + _addressField.frame = textFrame; + _titleLabel.hidden = !showCompactText; + _addressField.hidden = !showEditor; + [CATransaction commit]; } - (void)setIconOnly:(BOOL)iconOnly { if (_iconOnly == iconOnly) { return; } + if (iconOnly && _addressField.currentEditor) { + [self finishAddressEditing]; + } _iconOnly = iconOnly; [self layoutPillContents]; } @@ -433,61 +689,93 @@ - (void)setFrameSize:(NSSize)newSize { - (void)setLoading:(BOOL)loading { if (_isLoading == loading) { - return; // Repeated OnLoadingStateChange must not restart the spinner. + return; // Repeated events must not restart the shimmer sweep. } _isLoading = loading; - if (loading) { - _faviconView.hidden = YES; - _loadingSpinner.hidden = NO; - [_loadingSpinner startAnimation:nil]; - } else { - [_loadingSpinner stopAnimation:nil]; - _loadingSpinner.hidden = YES; - _faviconView.hidden = NO; + _titleLabel.loading = loading; +} + +- (void)applyDefaultFaviconColor:(NSColor*)color { + if (!showingDefaultFavicon_ || + [_faviconView.contentTintColor isEqual:color]) { + return; } + if (self.window != nil && !BroMotionReduced()) { + CATransition* fade = [CATransition animation]; + fade.type = kCATransitionFade; + fade.duration = kTabColorTransitionDuration; + fade.timingFunction = [CAMediaTimingFunction + functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; + [_faviconView.layer addAnimation:fade forKey:@"bro.tab.favicon-color"]; + } + _faviconView.contentTintColor = color; } -// Pills sit on pure #000; selection, split-pane, hover, keyboard focus, and -// the address-editing state all share the same 1pt #111 hairline, and only a -// hovered inactive pill lifts its fill off black. Resting inactive borders -// are fainter still. +// Every pill uses the same untinted AppKit glass backdrop. Selection is +// communicated through content and border contrast, never by swapping to an +// opaque fill; this keeps the tab strip one continuous glass material. - (void)updateAppearance { + [CATransaction begin]; + [CATransaction setDisableActions:BroMotionReduced()]; if (_isActive) { - self.layer.backgroundColor = [NSColor blackColor].CGColor; - self.layer.borderColor = BroControlBorderColor().CGColor; - _titleLabel.color = [NSColor labelColor]; + glassBackdrop_.hidden = _dropTarget; + self.layer.backgroundColor = [NSColor clearColor].CGColor; + self.layer.borderColor = + [NSColor colorWithWhite:1.0 alpha:kActiveTabBorderAlpha].CGColor; + self.layer.borderWidth = kBroGlassBorderWidth; + NSColor* foreground = [NSColor labelColor]; + _titleLabel.color = foreground; + [self applyDefaultFaviconColor:foreground]; } 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 - // lift so the two halves of the joined pill are still tellable apart. - CGFloat bg = (!_isSplitPane && hovered_) ? 0.05 : (_isSplitPane ? 0.03 : 0.0); - self.layer.backgroundColor = - bg > 0 ? [NSColor colorWithWhite:1.0 alpha:bg].CGColor - : [NSColor blackColor].CGColor; + glassBackdrop_.hidden = _dropTarget; + self.layer.backgroundColor = [NSColor clearColor].CGColor; + BOOL emphasizeBorder = _isSplitPane || hovered_ || focused_; + CGFloat borderAlpha = + emphasizeBorder ? kHoveredTabBorderAlpha : 0.05; self.layer.borderColor = - (_isSplitPane || hovered_ || focused_) - ? BroControlBorderColor().CGColor - : [NSColor colorWithWhite:1.0 alpha:0.05].CGColor; - _titleLabel.color = [NSColor secondaryLabelColor]; + [NSColor colorWithWhite:1.0 alpha:borderAlpha].CGColor; + NSColor* foreground = + [NSColor colorWithWhite:1.0 alpha:kInactiveTabForegroundAlpha]; + _titleLabel.color = foreground; + [self applyDefaultFaviconColor:foreground]; } // A dragged pill hovering this one (drop = split the two tabs) outshines // every other state so the target is unmistakable. if (_dropTarget) { + glassBackdrop_.hidden = YES; self.layer.backgroundColor = [NSColor colorWithWhite:1.0 alpha:0.14].CGColor; self.layer.borderColor = [NSColor colorWithWhite:1.0 alpha:0.6].CGColor; } - // The active pill always shows ✕; inactive pills reveal it on hover or - // keyboard focus. - [self setCloseButtonShown:(_closable && (_isActive || hovered_ || focused_))]; -} - -// Fades the ✕ in/out instead of snapping, so its hover reveal matches the -// rest of the chrome's transitions. -- (void)setCloseButtonShown:(BOOL)shown { - if (shown == closeButtonShown_) { + [CATransaction commit]; + // The same trailing control closes a normal tab or unpins a pinned one. + // Close remains visible on the active tab; unpin is deliberately revealed + // only on hover/focus so the pin state does not compete with the favicon. + if (trailingActionShowsPin_ != _pinned) { + trailingActionShowsPin_ = _pinned; + _closeButton.image = RadixIconImage( + _pinned ? RadixIconDrawingPinFilled : RadixIconCross2, 10); + _closeButton.action = _pinned ? @selector(handleUnpin:) + : @selector(handleClose:); + [_closeButton configureActionLabel:(_pinned ? @"Unpin Tab" : @"Close Tab") + keyEquivalent:(_pinned ? @"p" : @"w") + modifierMask:(_pinned + ? NSEventModifierFlagCommand | + NSEventModifierFlagOption + : NSEventModifierFlagCommand)]; + } + BOOL showAction = _pinned + ? (hovered_ || focused_ || trailingActionFocused_) + : (_closable && (_isActive || hovered_ || focused_ || + trailingActionFocused_)); + [self setTrailingActionShown:showAction]; +} + +// Fades the close/unpin action in/out instead of snapping. +- (void)setTrailingActionShown:(BOOL)shown { + if (shown == trailingActionShown_) { return; } - closeButtonShown_ = shown; + trailingActionShown_ = shown; // Pills built off-window (init-time updateAppearance) must not fade in. BOOL animate = self.window != nil && !BroMotionReduced(); @@ -498,7 +786,11 @@ - (void)setCloseButtonShown:(BOOL)shown { } if (shown) { // May be mid fade-out; unhide and retarget from the current alpha. + BOOL wasHidden = _closeButton.hidden; _closeButton.hidden = NO; + if (wasHidden) { + _closeButton.alphaValue = 0.0; + } [NSAnimationContext runAnimationGroup:^(NSAnimationContext* ctx) { ctx.duration = kCloseButtonFadeDuration; ctx.timingFunction = [CAMediaTimingFunction @@ -515,7 +807,7 @@ - (void)setCloseButtonShown:(BOOL)shown { } completionHandler:^{ BroTabView* strongSelf = weakSelf; // Only hide if a re-show hasn't retargeted the fade meanwhile. - if (strongSelf && !strongSelf->closeButtonShown_) { + if (strongSelf && !strongSelf->trailingActionShown_) { strongSelf->_closeButton.hidden = YES; } }]; @@ -529,7 +821,19 @@ - (void)setClosable:(BOOL)closable { [self layoutPillContents]; } +- (void)setPinned:(BOOL)pinned { + if (_pinned == pinned) { + return; + } + _pinned = pinned; + [self updateAppearance]; + [self layoutPillContents]; +} + - (void)setIsActive:(BOOL)isActive { + if (!isActive && _addressField.currentEditor) { + [self finishAddressEditing]; + } _isActive = isActive; if (!isActive) { _editingAddress = NO; @@ -544,6 +848,29 @@ - (void)setEditingAddress:(BOOL)editingAddress { [self layoutPillContents]; } +- (void)focusAddressField { + if (!_isActive || _iconOnly || !self.window) { + return; + } + _addressField.stringValue = BroURLIsBlank(_tabURL) ? @"" : _tabURL; + self.editingAddress = YES; + if (![self.window makeFirstResponder:_addressField]) { + self.editingAddress = NO; + } +} + +- (void)finishAddressEditing { + if (_addressField.currentEditor) { + [self.window makeFirstResponder:nil]; + if (_addressField.currentEditor) { + // A formatter or responder veto must never leave AppKit's window-owned + // field editor alive after this pill hides its NSTextField. + [_addressField abortEditing]; + } + } + self.editingAddress = NO; +} + - (void)setDropTarget:(BOOL)dropTarget { _dropTarget = dropTarget; [self refreshCorners]; @@ -587,6 +914,7 @@ - (void)refreshCorners { [self.layer addAnimation:fade forKey:@"cornerShape"]; } self.layer.maskedCorners = mask; + glassBackdrop_.layer.maskedCorners = mask; } - (void)performSelect { @@ -713,6 +1041,10 @@ - (BOOL)becomeFirstResponder { if (ok) { focused_ = YES; [self updateAppearance]; + if (!BroCurrentEventIsPointerActivation() && + [self.superview isKindOfClass:[BroTabBar class]]) { + [(BroTabBar*)self.superview tabFocusBegan:self]; + } } return ok; } @@ -722,6 +1054,9 @@ - (BOOL)resignFirstResponder { if (ok) { focused_ = NO; [self updateAppearance]; + if ([self.superview isKindOfClass:[BroTabBar class]]) { + [(BroTabBar*)self.superview tabFocusEnded:self]; + } } return ok; } @@ -806,6 +1141,12 @@ - (void)handleClose:(id)sender { } } +- (void)handleUnpin:(id)sender { + if (_pinned && [self.superview isKindOfClass:[BroTabBar class]]) { + [(BroTabBar*)self.superview togglePinForTab:self]; + } +} + @end #pragma mark - BroTabHoverCard @@ -829,7 +1170,7 @@ - (void)setTitle:(NSString*)title @end @implementation BroTabHoverCard { - BroTextMorphView* titleLabel_; + BroShimmerTextView* titleLabel_; NSTextField* urlLabel_; NSTextField* descriptionLabel_; } @@ -853,10 +1194,15 @@ - (instancetype)initWithFrame:(NSRect)frame { self = [super initWithFrame:frame]; if (self) { self.wantsLayer = YES; - self.layer.cornerRadius = kSurfaceCornerRadius; + self.layer.cornerRadius = + BroCornerRadiusForSize(BroSurfaceCornerRadius(), self.bounds.size); BroApplyElevation(self, BroElevationOverlay); + BroInstallGlassBackdrop(self, self.layer.cornerRadius); - titleLabel_ = [[BroTextMorphView alloc] + self.accessibilityRole = NSAccessibilityGroupRole; + self.accessibilityLabel = @"Tab preview"; + + titleLabel_ = [[BroShimmerTextView alloc] initWithFont:BroUIFontBold(12.0) color:[NSColor whiteColor]]; urlLabel_ = BroHoverCardLabel(BroUIFont(11.0), 0.55); descriptionLabel_ = BroHoverCardLabel(BroUIFont(11.0), 0.75); @@ -890,6 +1236,15 @@ - (BroHoverButton*)makeActionButton { button.title = @""; button.imagePosition = NSImageOnly; button.contentTintColor = [NSColor colorWithWhite:1.0 alpha:0.85]; + __weak BroTabHoverCard* weakSelf = self; + button.focusChangedHandler = ^(BroHoverButton* focusedButton, BOOL focused) { + BroTabHoverCard* card = weakSelf; + if (focused) { + [card.hoverDelegate cardHoverBegan]; + } else { + [card.hoverDelegate cardHoverEnded]; + } + }; [self addSubview:button]; return button; } @@ -911,11 +1266,13 @@ - (void)setTitle:(NSString*)title const CGFloat rowGap = 3.0; const CGFloat textWidth = width - padX * 2; - [titleLabel_ setText:title ?: @"" - animated:self.window != nil && !self.hidden]; + [titleLabel_ setText:title ?: @""]; urlLabel_.stringValue = url ?: @""; descriptionLabel_.stringValue = desc ?: @""; descriptionLabel_.hidden = desc.length == 0; + self.accessibilityLabel = title.length > 0 + ? [NSString stringWithFormat:@"Tab preview: %@", title] + : @"Tab preview"; CGFloat titleHeight = titleLabel_.desiredSize.height; CGFloat urlHeight = ceil([urlLabel_.cell cellSizeForBounds: @@ -984,6 +1341,12 @@ @implementation BroTabBar { NSTimer* hoverCardHideTimer_; // Tab the visible card belongs to; -1 when hidden. int hoverCardTabId_; + // When keyboard focus opens the card, splice its actions directly after the + // focused pill (and its visible close button) in the key-view loop. + __weak NSView* hoverCardKeyAnchor_; + __weak NSView* hoverCardReturnKeyView_; + __weak BroTabView* hoverCardSourceTab_; + BOOL hidingHoverCard_; } - (instancetype)initWithFrame:(NSRect)frame { @@ -1001,7 +1364,9 @@ - (instancetype)initWithFrame:(NSRect)frame { // rather than drawn over the controls to the right of the strip. self.layer.masksToBounds = YES; - // Bordered rounded-rect "+" button, repositioned after the last pill. + // Borderless "+" control, repositioned after the last pill. It keeps the + // compact hit area and icon size, but uses the same hover/press treatment + // as the rest of the toolbar icons. CGFloat addY = (frame.size.height - kAddTabButtonSize) / 2.0; _addTabButton = [[BroHoverButton alloc] initWithFrame:NSMakeRect(0, addY, kAddTabButtonSize, kAddTabButtonSize)]; @@ -1010,13 +1375,13 @@ - (instancetype)initWithFrame:(NSRect)frame { _addTabButton.image = RadixIconImage(RadixIconPlus, 10); _addTabButton.imagePosition = NSImageOnly; _addTabButton.contentTintColor = [NSColor colorWithWhite:1.0 alpha:0.85]; - _addTabButton.layer.cornerRadius = kCompactControlCornerRadius; - _addTabButton.baseBorderWidth = 1.0; - _addTabButton.baseBorderColor = BroControlBorderColor(); + _addTabButton.layer.cornerRadius = BroCornerRadiusForSize( + BroCompactControlCornerRadius(), _addTabButton.bounds.size); _addTabButton.target = self; _addTabButton.action = @selector(createNewTab:); - _addTabButton.accessibilityLabel = @"New tab"; - _addTabButton.toolTip = @"New tab (⌘N)"; + [_addTabButton configureActionLabel:@"New Tab" + keyEquivalent:@"t" + modifierMask:NSEventModifierFlagCommand]; [self addSubview:_addTabButton]; // Palette search button, pinned at the strip's right edge (the "+" @@ -1035,8 +1400,10 @@ - (instancetype)initWithFrame:(NSRect)frame { _tabSearchButton.contentTintColor = [NSColor colorWithWhite:1.0 alpha:0.85]; _tabSearchButton.target = self; _tabSearchButton.action = @selector(toggleTabSearch:); - _tabSearchButton.accessibilityLabel = @"Search tabs"; - _tabSearchButton.toolTip = @"Search tabs (⇧⌘A)"; + [_tabSearchButton configureActionLabel:@"Search Tabs" + keyEquivalent:@"a" + modifierMask:NSEventModifierFlagCommand | + NSEventModifierFlagShift]; _tabSearchButton.alphaValue = 0.0; [self addSubview:_tabSearchButton]; } @@ -1318,6 +1685,7 @@ - (void)removeTabWithBrowserId:(int)browserId { } if (tabToRemove) { + [tabToRemove finishAddressEditing]; if (tabToRemove == draggingTab_) { // The close is deferred (dispatch_async) so it can land mid-drag; tear // down the drag now, before the pill it was tracking disappears out @@ -1370,40 +1738,27 @@ - (void)removeTabWithBrowserId:(int)browserId { - (void)setActiveTab:(int)browserId { [self hideHoverCard]; + BroTabView* previousActive = [self tabWithBrowserId:_activeTabId]; + if (previousActive && previousActive.browserId != browserId) { + // End editing before changing active flags. The field remains in its own + // pill, so AppKit can tear down its field editor without any reparenting. + [previousActive finishAddressEditing]; + } _activeTabId = browserId; - BroTabView* activeTab = nil; for (BroTabView* tab in _tabs) { tab.isActive = (tab.browserId == browserId); - if (tab.browserId == browserId) { - activeTab = tab; - } } - // The active pill hosts the shared editable address field. + // Each pill owns its address field; activation only updates shared toolbar + // controls and never moves a live editor through the view hierarchy. if (g_toolbar) { - 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)]; } // In a collapsed strip the newly active pill grows out of its square so its // URL can be read and typed, and the one it replaced shrinks back. The same - // swap happens around a pinned pill in a roomy strip (square inactive, + // swap happens around a pinned pill in a roomy strip (compact inactive, // 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_) { @@ -1472,9 +1827,9 @@ - (NSUInteger)pinnedCount { return count; } -// Pinned pills that render as squares: all of them except an active one, -// which expands so the hosted address field stays usable. -- (NSUInteger)squarePinnedCount { +// Compact pinned pills: all of them except an active one, which expands so +// the hosted address field stays usable. +- (NSUInteger)compactPinnedCount { NSUInteger count = 0; for (BroTabView* tab in _tabs) { if (!tab.pinned) { @@ -1487,39 +1842,42 @@ - (NSUInteger)squarePinnedCount { return count; } -// Strip width left for expandable pills once the pinned squares took theirs. +// Strip width left for expandable pills once compact pinned tabs took theirs. - (CGFloat)expandableStripWidth { return [self availableStripWidth] - - (CGFloat)[self squarePinnedCount] * (kTabPillSquareWidth + kTabGap); + (CGFloat)[self compactPinnedCount] * + (kPinnedTabPillWidth + kTabGap); } // Fits expandable pills to the remaining strip width (minus the "+" button -// and the pinned squares), capped at the mockup's pill width. Only meaningful +// and compact pinned tabs), capped at the mockup's pill width. Only meaningful // while the strip is roomy — once pills would go narrower than // kTabPillTextMinWidth the layout switches to squares and this stops // describing every pill (see -tabWidths). - (CGFloat)fittedTabWidth { - NSUInteger count = MAX(_tabs.count - [self squarePinnedCount], (NSUInteger)1); + NSUInteger count = + MAX(_tabs.count - [self compactPinnedCount], (NSUInteger)1); CGFloat fitWidth = [self expandableStripWidth] / count - kTabGap; return MIN(MAX(fitWidth, kTabPillSquareWidth), kTabPillMaxWidth); } // YES once an even split would leave no room for text: pills become squares. - (BOOL)isCollapsed { - NSUInteger count = MAX(_tabs.count - [self squarePinnedCount], (NSUInteger)1); + NSUInteger count = + MAX(_tabs.count - [self compactPinnedCount], (NSUInteger)1); return [self expandableStripWidth] / count - kTabGap < kTabPillTextMinWidth; } -// Width of one drag slot for `tab`. Pinned pills only travel their group of -// squares. Collapsed (or pinned), the pills the dragged pill travels past are -// squares, so slots are square-sized even though the carried pill may not be. +// Width of one drag slot for `tab`. Pinned pills only travel their compact +// group; collapsed unpinned pills travel square-sized slots. - (CGFloat)dragSlotWidthForTab:(BroTabView*)tab { - return (tab.pinned || [self isCollapsed] ? kTabPillSquareWidth - : [self fittedTabWidth]) + + return (tab.pinned ? kPinnedTabPillWidth + : ([self isCollapsed] ? kTabPillSquareWidth + : [self fittedTabWidth])) + kTabGap; } -// Per-pill widths in strip order. Pinned inactive pills are always squares; +// Per-pill widths in strip order. Pinned inactive pills stay compact; // the rest are uniform while there is room. Collapsed, every inactive pill is // a square and the active one takes the leftover space so its URL stays // readable and editable. @@ -1529,22 +1887,35 @@ - (CGFloat)dragSlotWidthForTab:(BroTabView*)tab { if (![self isCollapsed]) { CGFloat uniform = [self fittedTabWidth]; for (BroTabView* tab in _tabs) { - [widths addObject:@(tab.pinned && !tab.isActive ? kTabPillSquareWidth + [widths addObject:@(tab.pinned && !tab.isActive ? kPinnedTabPillWidth : uniform)]; } return widths; } - CGFloat squares = - (CGFloat)(_tabs.count - 1) * (kTabPillSquareWidth + kTabGap); - CGFloat slack = [self availableStripWidth] - squares - kTabGap; + CGFloat fixedInactiveWidth = 0.0; + BroTabView* activeTab = nil; + for (BroTabView* tab in _tabs) { + if (tab.isActive) { + activeTab = tab; + } else { + fixedInactiveWidth += + (tab.pinned ? kPinnedTabPillWidth : kTabPillSquareWidth) + kTabGap; + } + } + CGFloat slack = + [self availableStripWidth] - fixedInactiveWidth - kTabGap; // Open the active pill to the full pill width when the slack allows, so the // URL has as much room to be typed as it would in a roomy strip. Below the // comfortable minimum expanding buys nothing readable, so it stays square. + CGFloat compactActiveWidth = + activeTab.pinned ? kPinnedTabPillWidth : kTabPillSquareWidth; CGFloat activeWidth = slack >= kTabPillMinWidth ? MIN(kTabPillMaxWidth, slack) - : kTabPillSquareWidth; + : compactActiveWidth; for (BroTabView* tab in _tabs) { - [widths addObject:@(tab.isActive ? activeWidth : kTabPillSquareWidth)]; + CGFloat compactWidth = + tab.pinned ? kPinnedTabPillWidth : kTabPillSquareWidth; + [widths addObject:@(tab.isActive ? activeWidth : compactWidth)]; } return widths; } @@ -1648,7 +2019,13 @@ - (void)tabHoverBegan:(BroTabView*)tab { hoverCardTimer_ = nil; // Once a card is up, moving across pills retargets it without a new dwell // (like every browser's tab strip). - if (hoverCard_ && !hoverCard_.hidden) { + // hoverCardTabId_ is cleared as soon as hiding begins. AppKit leaves the + // view unhidden for the 120ms fade, so hidden alone would mistake a + // dismissing card for a presented one and retarget it immediately. + if (hoverCard_ && hoverCardTabId_ >= 0 && !hoverCard_.hidden) { + if ([self hoverCardHasKeyboardFocus]) { + return; + } [self showHoverCardForTab:tab]; return; } @@ -1660,9 +2037,11 @@ - (void)tabHoverBegan:(BroTabView*)tab { block:^(NSTimer* timer) { BroTabBar* bar = weakSelf; BroTabView* hoveredTab = weakTab; - if (!bar || !hoveredTab || hoveredTab.superview != bar) { + if (!bar || timer != bar->hoverCardTimer_ || !hoveredTab || + hoveredTab.superview != bar) { return; } + bar->hoverCardTimer_ = nil; [bar showHoverCardForTab:hoveredTab]; }]; } @@ -1684,6 +2063,60 @@ - (void)cardHoverEnded { [self scheduleHoverCardHide]; } +- (BOOL)hoverCardHasKeyboardFocus { + NSResponder* responder = self.window.firstResponder; + if (![responder isKindOfClass:[NSView class]] || !hoverCard_) { + return NO; + } + NSView* view = (NSView*)responder; + return view == hoverCard_ || [view isDescendantOf:hoverCard_]; +} + +- (void)unwireHoverCardKeyLoop { + if (hoverCardKeyAnchor_.nextKeyView == hoverCard_.pinButton) { + hoverCardKeyAnchor_.nextKeyView = hoverCardReturnKeyView_; + } + hoverCardKeyAnchor_ = nil; + hoverCardReturnKeyView_ = nil; + hoverCardSourceTab_ = nil; +} + +- (void)wireHoverCardKeyLoopForTab:(BroTabView*)tab { + [self unwireHoverCardKeyLoop]; + if (!tab || !hoverCard_ || hoverCard_.hidden || !self.window) { + return; + } + [self.window recalculateKeyViewLoop]; + NSView* anchor = tab; + if (!tab.closeButton.hidden && tab.nextKeyView == tab.closeButton) { + anchor = tab.closeButton; + } + hoverCardKeyAnchor_ = anchor; + hoverCardReturnKeyView_ = anchor.nextKeyView; + hoverCardSourceTab_ = tab; + anchor.nextKeyView = hoverCard_.pinButton; + hoverCard_.pinButton.nextKeyView = hoverCard_.splitButton; + hoverCard_.splitButton.nextKeyView = hoverCardReturnKeyView_; +} + +- (void)tabFocusBegan:(BroTabView*)tab { + if (hidingHoverCard_) { + return; + } + [hoverCardTimer_ invalidate]; + hoverCardTimer_ = nil; + [hoverCardHideTimer_ invalidate]; + hoverCardHideTimer_ = nil; + [self showHoverCardForTab:tab]; + [self wireHoverCardKeyLoopForTab:tab]; +} + +- (void)tabFocusEnded:(BroTabView*)tab { + if (hoverCardSourceTab_ == tab) { + [self scheduleHoverCardHide]; + } +} + - (void)scheduleHoverCardHide { [hoverCardHideTimer_ invalidate]; hoverCardHideTimer_ = nil; @@ -1695,7 +2128,10 @@ - (void)scheduleHoverCardHide { [NSTimer scheduledTimerWithTimeInterval:kHoverCardHideGrace repeats:NO block:^(NSTimer* timer) { - [weakSelf hideHoverCard]; + BroTabBar* bar = weakSelf; + if (bar && ![bar hoverCardHasKeyboardFocus]) { + [bar hideHoverCard]; + } }]; } @@ -1704,8 +2140,17 @@ - (void)hideHoverCard { hoverCardTimer_ = nil; [hoverCardHideTimer_ invalidate]; hoverCardHideTimer_ = nil; + BOOL cardHadFocus = [self hoverCardHasKeyboardFocus]; + BroTabView* sourceTab = hoverCardSourceTab_; + hidingHoverCard_ = YES; + [self unwireHoverCardKeyLoop]; BroOverlayHide(hoverCard_); hoverCardTabId_ = -1; + [self.window recalculateKeyViewLoop]; + if (cardHadFocus && sourceTab && sourceTab.superview == self) { + [self.window makeFirstResponder:sourceTab]; + } + hidingHoverCard_ = NO; } // Title shown on the card; falls back to the display host for pages that @@ -1728,8 +2173,10 @@ - (void)configureHoverCardButtonsForTab:(BroTabView*)tab { BroHoverButton* pin = hoverCard_.pinButton; pin.image = RadixIconImage( tab.pinned ? RadixIconDrawingPinFilled : RadixIconDrawingPin, 12); - pin.toolTip = tab.pinned ? @"Unpin Tab" : @"Pin Tab"; - pin.accessibilityLabel = pin.toolTip; + [pin configureActionLabel:(tab.pinned ? @"Unpin Tab" : @"Pin Tab") + keyEquivalent:@"p" + modifierMask:NSEventModifierFlagCommand | + NSEventModifierFlagOption]; pin.target = self; pin.action = @selector(hoverCardPinPressed:); @@ -1742,8 +2189,10 @@ - (void)configureHoverCardButtonsForTab:(BroTabView*)tab { // next neighbor; either pane's button exits the split. Only a lone tab has // nothing to split with. split.enabled = isPane || _tabs.count > 1; - split.toolTip = isPane ? @"Exit Split Screen" : @"Split Screen"; - split.accessibilityLabel = split.toolTip; + [split configureActionLabel:(isPane ? @"Exit Split Screen" : @"Split Screen") + keyEquivalent:@"s" + modifierMask:NSEventModifierFlagCommand | + NSEventModifierFlagShift]; split.target = self; split.action = @selector(hoverCardSplitPressed:); } @@ -1771,6 +2220,22 @@ - (void)hoverCardSplitPressed:(id)sender { ToggleSplitForTab(browserId); } +- (BOOL)performContextualPinShortcut { + if (!hoverCard_ || hoverCard_.hidden || hoverCardTabId_ < 0) { + return NO; + } + [hoverCard_.pinButton performClick:nil]; + return YES; +} + +- (BOOL)performContextualSplitShortcut { + if (!hoverCard_ || hoverCard_.hidden || hoverCardTabId_ < 0) { + return NO; + } + [hoverCard_.splitButton performClick:nil]; + return YES; +} + - (void)splitActiveTabWithNextTab { NSInteger count = (NSInteger)_tabs.count; if (count < 2) { @@ -1834,8 +2299,6 @@ - (void)showHoverCardForTab:(BroTabView*)tab { [hoverCard_ removeFromSuperview]; [container addSubview:hoverCard_]; hoverCard_.hoverDelegate = self; - hoverCardTabId_ = tab.browserId; - [self configureHoverCardButtonsForTab:tab]; // The card matches the hovered pill's width, reading as a dropdown of the // pill; collapsed squares get the pill minimum so the text stays legible. @@ -1844,6 +2307,8 @@ - (void)showHoverCardForTab:(BroTabView*)tab { if (width < 100.0) { return; // window too narrow for a useful card } + hoverCardTabId_ = tab.browserId; + [self configureHoverCardButtonsForTab:tab]; if (BroURLIsBlank(tab.tabURL)) { // Blank tabs have no URL or metadata worth showing; the card invites // instead (mirrors the address field's empty state). diff --git a/src/bro_text_morph.h b/src/bro_text_morph.h index 33a64d0..659b488 100644 --- a/src/bro_text_morph.h +++ b/src/bro_text_morph.h @@ -1,17 +1,21 @@ -// Native word/glyph text morphing for Bro's AppKit chrome. +// Atomic text rendering with a loading shimmer for Bro's AppKit chrome. #ifndef BRO_TEXT_MORPH_H_ #define BRO_TEXT_MORPH_H_ #import -@interface BroTextMorphView : NSView +// The filename is retained to avoid churn in the build manifest, but the old +// word/glyph morphing implementation is intentionally gone. This view always +// owns one complete string and may overlay only a same-string shimmer mask. +@interface BroShimmerTextView : NSView - (instancetype)initWithFont:(NSFont*)font color:(NSColor*)color; -- (void)setText:(NSString*)text animated:(BOOL)animated; +- (void)setText:(NSString*)text; @property(nonatomic, copy, readonly) NSString* text; @property(nonatomic, strong) NSColor* color; +@property(nonatomic, assign, getter=isLoading) BOOL loading; @property(nonatomic, assign, readonly) NSSize desiredSize; @end diff --git a/src/bro_text_morph.mm b/src/bro_text_morph.mm index facc31c..ba39eeb 100644 --- a/src/bro_text_morph.mm +++ b/src/bro_text_morph.mm @@ -3,25 +3,26 @@ #import #import -#include #include -#include -#include #import "bro_motion.h" -@interface BroTextToken : NSObject -@property(nonatomic, copy) NSString* value; -@property(nonatomic, assign) NSRange range; -@end +namespace { -@implementation BroTextToken -@end +static NSString* const kShimmerAnimationKey = @"bro.text.loading-shimmer"; +static NSString* const kTextColorAnimationKey = @"bro.text.color"; +static const CFTimeInterval kShimmerSweepDuration = 1.25; +static const CFTimeInterval kTextColorTransitionDuration = 0.18; + +CGFloat LineHeight(NSFont* font) { + return ceil(font.ascender - font.descender + font.leading); +} + +} // namespace // 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. +// NSTextField by a backing pixel at the same nominal font size. Core Text at +// an explicit baseline keeps compact and editable text precisely aligned. @interface BroTextLayer : CALayer @property(nonatomic, copy) NSString* text; @property(nonatomic, strong) NSFont* textFont; @@ -78,110 +79,12 @@ - (void)drawInContext:(CGContextRef)context { @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 { +@implementation BroShimmerTextView { NSFont* font_; - NSMutableArray* segments_; - NSMutableArray* exitingLayers_; - NSUInteger generation_; + BroTextLayer* baseTextLayer_; + BroTextLayer* shimmerMaskLayer_; + CAGradientLayer* shimmerLayer_; } - (instancetype)initWithFont:(NSFont*)font color:(NSColor*)color { @@ -190,403 +93,217 @@ - (instancetype)initWithFont:(NSFont*)font color:(NSColor*)color { font_ = font ?: [NSFont systemFontOfSize:12.0]; _color = color ?: [NSColor labelColor]; _text = @""; - segments_ = [NSMutableArray array]; - exitingLayers_ = [NSMutableArray array]; + _desiredSize = NSMakeSize(0.0, LineHeight(font_)); + self.wantsLayer = YES; self.layer.masksToBounds = YES; - _desiredSize = NSMakeSize(0.0, LineHeight(font_)); + + baseTextLayer_ = [BroTextLayer layer]; + baseTextLayer_.textFont = font_; + baseTextLayer_.textColor = _color; + baseTextLayer_.baselineFromBottom = ceil(-font_.descender); + [self.layer addSublayer:baseTextLayer_]; + + // The shimmer is a gradient clipped by a second Core Text layer. Both + // layers receive the same complete string in one disabled transaction, + // so the overlay can brighten the glyphs but can never show stale text. + shimmerMaskLayer_ = [BroTextLayer layer]; + shimmerMaskLayer_.textFont = font_; + shimmerMaskLayer_.textColor = [NSColor whiteColor]; + shimmerMaskLayer_.baselineFromBottom = ceil(-font_.descender); + + NSColor* clear = [NSColor colorWithWhite:1.0 alpha:0.0]; + NSColor* highlight = [NSColor colorWithWhite:1.0 alpha:0.24]; + shimmerLayer_ = [CAGradientLayer layer]; + shimmerLayer_.colors = @[ + (__bridge id)clear.CGColor, + (__bridge id)clear.CGColor, + (__bridge id)highlight.CGColor, + (__bridge id)clear.CGColor, + (__bridge id)clear.CGColor, + ]; + shimmerLayer_.locations = @[ @0.0, @0.42, @0.5, @0.58, @1.0 ]; + shimmerLayer_.startPoint = CGPointMake(1.0, 0.5); + shimmerLayer_.endPoint = CGPointMake(2.0, 0.5); + shimmerLayer_.mask = shimmerMaskLayer_; + shimmerLayer_.hidden = YES; + [self.layer addSublayer:shimmerLayer_]; + + [self updateBackingScale]; + [[NSWorkspace sharedWorkspace].notificationCenter + addObserver:self + selector:@selector(accessibilityDisplayOptionsChanged:) + name:NSWorkspaceAccessibilityDisplayOptionsDidChangeNotification + object:nil]; } return self; } +- (void)dealloc { + [[NSWorkspace sharedWorkspace].notificationCenter removeObserver:self]; +} + - (CGFloat)backingScale { return self.window ? self.window.backingScaleFactor : NSScreen.mainScreen.backingScaleFactor ?: 2.0; } -- (void)viewDidChangeBackingProperties { - [super viewDidChangeBackingProperties]; +- (void)updateBackingScale { 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); + baseTextLayer_.contentsScale = scale; + shimmerMaskLayer_.contentsScale = scale; + shimmerLayer_.contentsScale = scale; } -- (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; +- (void)viewDidChangeBackingProperties { + [super viewDidChangeBackingProperties]; + [self updateBackingScale]; } -- (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; +- (void)viewDidMoveToWindow { + [super viewDidMoveToWindow]; + [self updateBackingScale]; + [self refreshShimmer]; } -- (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]; - } +- (void)setHidden:(BOOL)hidden { + if (self.hidden == hidden) { + return; } - return result; + [super setHidden:hidden]; + [self refreshShimmer]; } -- (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; - } +- (void)setFrameSize:(NSSize)newSize { + [super setFrameSize:newSize]; + [self layoutTextLayers]; + [self refreshShimmer]; +} - 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; +- (void)layout { + [super layout]; + [self layoutTextLayers]; } -- (BroTextMorphSegment*)segmentForRange:(NSRange)range - among:(NSArray*)segments { - for (BroTextMorphSegment* segment in segments) { - if (NSEqualRanges(segment.range, range)) { - return segment; - } - } - return nil; +- (void)layoutTextLayers { + CGFloat lineHeight = LineHeight(font_); + CGFloat y = MAX(0.0, floor((NSHeight(self.bounds) - lineHeight) / 2.0)); + CGRect textFrame = CGRectMake(0.0, y, NSWidth(self.bounds), lineHeight); + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + baseTextLayer_.frame = textFrame; + shimmerLayer_.frame = self.bounds; + shimmerMaskLayer_.frame = textFrame; + [CATransaction commit]; } -- (void)rebuildImmediately:(NSString*)text line:(CTLineRef)line { - for (BroTextMorphSegment* segment in segments_) { - [segment.layer removeFromSuperlayer]; - } - for (BroTextLayer* layer in exitingLayers_) { - [layer removeFromSuperlayer]; +- (void)setColor:(NSColor*)color { + color = color ?: [NSColor labelColor]; + if ([_color isEqual:color]) { + return; } - [segments_ removeAllObjects]; - [exitingLayers_ removeAllObjects]; - for (BroTextToken* token in WordTokens(text)) { - BroTextMorphSegment* segment = [self newSegmentForToken:token line:line]; - segment.entering = NO; - [segments_ addObject:segment]; + if (self.window != nil && !self.hidden && !BroMotionReduced()) { + CATransition* fade = [CATransition animation]; + fade.type = kCATransitionFade; + fade.duration = kTextColorTransitionDuration; + fade.timingFunction = [CAMediaTimingFunction + functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; + [baseTextLayer_ addAnimation:fade forKey:kTextColorAnimationKey]; } + _color = color; + baseTextLayer_.textColor = _color; } -- (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 { +- (void)setText:(NSString*)text { 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; - } + _text = text; + NSAttributedString* attributed = [[NSAttributedString alloc] + initWithString:_text + attributes:@{NSFontAttributeName : font_}]; + CTLineRef line = CTLineCreateWithAttributedString( + (__bridge CFAttributedStringRef)attributed); + CGFloat width = ceil(CTLineGetTypographicBounds(line, nullptr, nullptr, + nullptr)); + CFRelease(line); + _desiredSize = NSMakeSize(MAX(0.0, width), LineHeight(font_)); - 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; - } - } + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + baseTextLayer_.text = _text; + shimmerMaskLayer_.text = _text; + [CATransaction commit]; + [self refreshShimmer]; +} - [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]; - } +- (void)setLoading:(BOOL)loading { + if (_loading == loading) { + return; } + _loading = loading; + [self refreshShimmer]; +} - _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; - } +- (void)accessibilityDisplayOptionsChanged:(NSNotification*)notification { + [self refreshShimmer]; +} - [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"]; - } +- (BOOL)shouldShimmer { + return _loading && self.window != nil && !self.hidden && _text.length > 0 && + NSWidth(self.bounds) > 0.0 && !BroMotionReduced(); +} - 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; +- (void)refreshShimmer { + if (![self shouldShimmer]) { + [shimmerLayer_ removeAnimationForKey:kShimmerAnimationKey]; [CATransaction begin]; [CATransaction setDisableActions:YES]; - segment.layer.opacity = 0.0; - segment.layer.transform = ScaleTransform(0.95); + shimmerLayer_.hidden = YES; [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]; + return; + } + if ([shimmerLayer_ animationForKey:kShimmerAnimationKey]) { + return; } - 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); + // Move a narrow highlight through the text for 0.9s, then leave it fully + // off the right edge for 0.35s before the next pass. The base text never + // moves or fades, so stopping the animation is immediate and artifact-free. + CAKeyframeAnimation* startSweep = + [CAKeyframeAnimation animationWithKeyPath:@"startPoint"]; + startSweep.values = @[ + [NSValue valueWithPoint:NSMakePoint(-1.0, 0.5)], + [NSValue valueWithPoint:NSMakePoint(1.0, 0.5)], + [NSValue valueWithPoint:NSMakePoint(1.0, 0.5)], + ]; + startSweep.keyTimes = @[ @0.0, @0.72, @1.0 ]; + startSweep.calculationMode = kCAAnimationLinear; + + CAKeyframeAnimation* endSweep = + [CAKeyframeAnimation animationWithKeyPath:@"endPoint"]; + endSweep.values = @[ + [NSValue valueWithPoint:NSMakePoint(0.0, 0.5)], + [NSValue valueWithPoint:NSMakePoint(2.0, 0.5)], + [NSValue valueWithPoint:NSMakePoint(2.0, 0.5)], + ]; + endSweep.keyTimes = startSweep.keyTimes; + endSweep.calculationMode = kCAAnimationLinear; + + CAAnimationGroup* sweep = [CAAnimationGroup animation]; + sweep.animations = @[ startSweep, endSweep ]; + sweep.duration = kShimmerSweepDuration; + sweep.repeatCount = HUGE_VALF; + + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + shimmerLayer_.hidden = NO; + shimmerLayer_.startPoint = CGPointMake(1.0, 0.5); + shimmerLayer_.endPoint = CGPointMake(2.0, 0.5); + [CATransaction commit]; + [shimmerLayer_ addAnimation:sweep forKey:kShimmerAnimationKey]; } @end diff --git a/src/bro_toolbar.mm b/src/bro_toolbar.mm index 4899e7a..335ad34 100644 --- a/src/bro_toolbar.mm +++ b/src/bro_toolbar.mm @@ -70,7 +70,7 @@ - (NSView*)hitTest:(NSPoint)point { - (BOOL)becomeFirstResponder { BOOL ok = [super becomeFirstResponder]; if (ok && [self.delegate isKindOfClass:[BroToolbar class]]) { - [(BroToolbar*)self.delegate addressFieldDidFocus]; + [(BroToolbar*)self.delegate addressFieldDidFocus:self]; } return ok; } @@ -82,6 +82,9 @@ @implementation BroToolbar { // (alpha 0) until the pointer enters it. NSTrackingArea* revealTrackingArea_; BOOL revealZoneHovered_; + BOOL trailingGroupFocused_; + __weak BroHoverButton* tabSearchButton_; + NSArray* trailingButtons_; // Invalidates a pending auto-hide scheduled by pulseDownloadsButton. NSUInteger revealGeneration_; } @@ -95,8 +98,6 @@ - (instancetype)initWithFrame:(NSRect)frame { self.wantsLayer = YES; self.layerContentsRedrawPolicy = NSViewLayerContentsRedrawOnSetNeedsDisplay; - _fullURL = @""; - // Navigation buttons, inline with the traffic lights. CGFloat x = kTrafficLightInset; CGFloat y = (frame.size.height - kButtonSize) / 2.0; @@ -104,24 +105,27 @@ - (instancetype)initWithFrame:(NSRect)frame { _backButton = [self createButtonWithFrame:NSMakeRect(x, y, kButtonSize, kButtonSize) icon:RadixIconArrowLeft action:@selector(goBack:) - label:@"Back"]; - _backButton.toolTip = @"Back (⌘[)"; + label:@"Back" + keyEquivalent:[NSString stringWithFormat:@"%C", (unichar)NSLeftArrowFunctionKey] + modifierMask:NSEventModifierFlagCommand]; [self addSubview:_backButton]; x += kButtonSize + kButtonSpacing; _forwardButton = [self createButtonWithFrame:NSMakeRect(x, y, kButtonSize, kButtonSize) icon:RadixIconArrowRight action:@selector(goForward:) - label:@"Forward"]; - _forwardButton.toolTip = @"Forward (⌘])"; + label:@"Forward" + keyEquivalent:[NSString stringWithFormat:@"%C", (unichar)NSRightArrowFunctionKey] + modifierMask:NSEventModifierFlagCommand]; [self addSubview:_forwardButton]; x += kButtonSize + kButtonSpacing; _refreshButton = [self createButtonWithFrame:NSMakeRect(x, y, kButtonSize, kButtonSize) icon:RadixIconReload action:@selector(refresh:) - label:@"Reload page"]; - _refreshButton.toolTip = @"Reload page (⌘R)"; + label:@"Reload Page" + keyEquivalent:@"r" + modifierMask:NSEventModifierFlagCommand]; [self addSubview:_refreshButton]; _navigationHighlightGroup = @@ -130,67 +134,41 @@ - (instancetype)initWithFrame:(NSRect)frame { _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, kTabTextFrameHeight)]; - _addressField.font = BroUIFont(kTabTextFontSize); - _addressField.bezeled = NO; - _addressField.bordered = NO; - _addressField.drawsBackground = NO; - _addressField.focusRingType = NSFocusRingTypeNone; - _addressField.textColor = [NSColor labelColor]; - _addressField.delegate = self; - _addressField.cell.scrollable = YES; - _addressField.cell.usesSingleLineMode = YES; - // Long URLs/hosts show an ellipsis at rest; the field editor still - // 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 // group: exactly one of desktop/mobile is selected at a time. - CGFloat rightX = frame.size.width - 12.0 - kButtonSize; + CGFloat rightX = BroTrailingControlX(frame.size.width, 0); _mobileButton = [self createButtonWithFrame:NSMakeRect(rightX, y, kButtonSize, kButtonSize) icon:RadixIconMobile action:@selector(selectMobileMode:) - label:@"Mobile viewport"]; + label:@"Mobile Viewport" + keyEquivalent:@"m" + modifierMask:NSEventModifierFlagCommand | + NSEventModifierFlagShift]; _mobileButton.autoresizingMask = NSViewMinXMargin; [_mobileButton setAccessibilityRole:NSAccessibilityRadioButtonRole]; [self addSubview:_mobileButton]; - rightX -= kButtonSize + kButtonSpacing; + rightX = BroTrailingControlX(frame.size.width, 1); _desktopButton = [self createButtonWithFrame:NSMakeRect(rightX, y, kButtonSize, kButtonSize) icon:RadixIconDesktop action:@selector(selectDesktopMode:) - label:@"Desktop viewport"]; + label:@"Desktop Viewport" + keyEquivalent:@"d" + modifierMask:NSEventModifierFlagCommand | + NSEventModifierFlagShift]; _desktopButton.autoresizingMask = NSViewMinXMargin; [_desktopButton setAccessibilityRole:NSAccessibilityRadioButtonRole]; [self addSubview:_desktopButton]; // 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; + rightX = BroTrailingControlX(frame.size.width, 2); _downloadsButton = [self createButtonWithFrame:NSMakeRect(rightX, y, kButtonSize, kButtonSize) icon:RadixIconDownload action:@selector(toggleDownloads:) - label:@"Downloads"]; + label:@"Downloads" + keyEquivalent:@"j" + modifierMask:NSEventModifierFlagCommand | + NSEventModifierFlagShift]; _downloadsButton.autoresizingMask = NSViewMinXMargin; // Hidden until the pointer enters the shared reveal zone (see // updateTrackingAreas), together with the tab strip's search button. @@ -201,10 +179,7 @@ - (instancetype)initWithFrame:(NSRect)frame { _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; - ((NSButtonCell*)_desktopButton.cell).imageDimsWhenDisabled = NO; + trailingButtons_ = @[ _downloadsButton, _desktopButton, _mobileButton ]; [self setViewportMode:NO]; // Initial button states @@ -217,7 +192,9 @@ - (instancetype)initWithFrame:(NSRect)frame { - (BroHoverButton*)createButtonWithFrame:(NSRect)frame icon:(RadixIcon)icon action:(SEL)action - label:(NSString*)label { + label:(NSString*)label + keyEquivalent:(NSString*)keyEquivalent + modifierMask:(NSEventModifierFlags)modifierMask { BroHoverButton* button = [[BroHoverButton alloc] initWithFrame:frame]; button.bezelStyle = NSBezelStyleTexturedRounded; button.bordered = NO; @@ -227,11 +204,73 @@ - (BroHoverButton*)createButtonWithFrame:(NSRect)frame button.contentTintColor = [NSColor colorWithWhite:1.0 alpha:0.85]; button.target = self; button.action = action; - button.accessibilityLabel = label; - button.toolTip = label; + [button configureActionLabel:label + keyEquivalent:keyEquivalent + modifierMask:modifierMask]; return button; } +- (void)layoutTrailingControls { + CGFloat y = (NSHeight(self.bounds) - kButtonSize) / 2.0; + _mobileButton.frame = NSMakeRect(BroTrailingControlX(NSWidth(self.bounds), 0), + y, kButtonSize, kButtonSize); + _desktopButton.frame = NSMakeRect(BroTrailingControlX(NSWidth(self.bounds), 1), + y, kButtonSize, kButtonSize); + _downloadsButton.frame = NSMakeRect(BroTrailingControlX(NSWidth(self.bounds), 2), + y, kButtonSize, kButtonSize); +} + +- (void)resizeSubviewsWithOldSize:(NSSize)oldSize { + [super resizeSubviewsWithOldSize:oldSize]; + [self layoutTrailingControls]; + [self updateTrackingAreas]; +} + +- (BOOL)isTrailingControlFirstResponder { + NSResponder* responder = self.window.firstResponder; + return [responder isKindOfClass:[BroHoverButton class]] && + [trailingButtons_ containsObject:(BroHoverButton*)responder]; +} + +- (void)trailingButton:(BroHoverButton*)button focusChanged:(BOOL)focused { + revealGeneration_++; + if (focused) { + trailingGroupFocused_ = YES; + [self setSearchDownloadsRevealed:YES]; + return; + } + __weak BroToolbar* weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + BroToolbar* toolbar = weakSelf; + if (!toolbar) { + return; + } + toolbar->trailingGroupFocused_ = + [toolbar isTrailingControlFirstResponder]; + if (!toolbar->trailingGroupFocused_ && !toolbar->revealZoneHovered_) { + [toolbar setSearchDownloadsRevealed:NO]; + } + }); +} + +- (void)registerTabSearchButton:(BroHoverButton*)button { + if (!button || tabSearchButton_ == button) { + return; + } + tabSearchButton_ = button; + button.highlightGroup = _trailingHighlightGroup; + trailingButtons_ = + @[ button, _downloadsButton, _desktopButton, _mobileButton ]; + __weak BroToolbar* weakSelf = self; + for (BroHoverButton* trailingButton in trailingButtons_) { + trailingButton.focusChangedHandler = + ^(BroHoverButton* focusedButton, BOOL focused) { + [weakSelf trailingButton:focusedButton focusChanged:focused]; + }; + } + [self updateTrackingAreas]; +} + #pragma mark - Search/downloads hover reveal // One tracking rect covers both hideable icons — the tab strip's search @@ -246,8 +285,8 @@ - (void)updateTrackingAreas { [self removeTrackingArea:revealTrackingArea_]; revealTrackingArea_ = nil; } - NSRect zone = _downloadsButton.frame; - NSView* searchButton = g_tab_bar.tabSearchButton; + NSRect zone = NSUnionRect(_downloadsButton.frame, _mobileButton.frame); + NSView* searchButton = tabSearchButton_ ?: g_tab_bar.tabSearchButton; if (searchButton && searchButton.window == self.window) { zone = NSUnionRect(zone, [self convertRect:searchButton.bounds @@ -266,7 +305,7 @@ - (void)updateTrackingAreas { - (void)setSearchDownloadsRevealed:(BOOL)revealed { CGFloat alpha = revealed ? 1.0 : 0.0; - NSView* searchButton = g_tab_bar.tabSearchButton; + NSView* searchButton = tabSearchButton_ ?: g_tab_bar.tabSearchButton; if (BroMotionReduced()) { _downloadsButton.alphaValue = alpha; searchButton.alphaValue = alpha; @@ -286,7 +325,9 @@ - (void)mouseEntered:(NSEvent*)event { - (void)mouseExited:(NSEvent*)event { revealZoneHovered_ = NO; - [self setSearchDownloadsRevealed:NO]; + if (!trailingGroupFocused_) { + [self setSearchDownloadsRevealed:NO]; + } } // Nudge when a download starts or finishes. With the icons hidden at rest, @@ -297,7 +338,7 @@ - (void)mouseExited:(NSEvent*)event { - (void)pulseDownloadsButton { revealGeneration_++; NSUInteger generation = revealGeneration_; - if (revealZoneHovered_) { + if (revealZoneHovered_ || trailingGroupFocused_) { if (!BroMotionReduced()) { _downloadsButton.alphaValue = 0.25; [NSAnimationContext runAnimationGroup:^(NSAnimationContext* ctx) { @@ -311,7 +352,8 @@ - (void)pulseDownloadsButton { dispatch_after( dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - if (generation == revealGeneration_ && !revealZoneHovered_) { + if (generation == revealGeneration_ && !revealZoneHovered_ && + !trailingGroupFocused_) { [self setSearchDownloadsRevealed:NO]; } }); @@ -360,7 +402,8 @@ - (void)navigateToURL:(NSString*)urlString { CefRefPtr browser = handler->GetBrowser(); if (browser) { // Show the destination immediately; OnAddressChange confirms it later. - self.fullURL = target; + BroTabView* tab = [g_tab_bar tabWithBrowserId:browser->GetIdentifier()]; + [tab setTabURL:target]; browser->GetMainFrame()->LoadURL([target UTF8String]); } } @@ -412,20 +455,16 @@ - (void)setViewportMode:(BOOL)mobile { _desktopButton.selectedState = !mobile; _mobileButton.selectedState = mobile; - // The mode already in effect is inert: no hover, no click, no tab stop. - BroHoverButton* selected = mobile ? _mobileButton : _desktopButton; - BroHoverButton* other = mobile ? _desktopButton : _mobileButton; - selected.enabled = NO; - other.enabled = YES; - if (self.window.firstResponder == selected) { - [self.window makeFirstResponder:other]; - } + // Keep both radio buttons focusable so hover/focus motion is identical. + // applyViewportMode: already makes choosing the current mode a no-op. + _desktopButton.enabled = YES; + _mobileButton.enabled = YES; } #pragma mark - NSTextFieldDelegate -- (BOOL)configureAddressFieldEditor { - NSTextView* editor = (NSTextView*)[_addressField currentEditor]; +- (BOOL)configureAddressFieldEditor:(BroAddressField*)field { + NSTextView* editor = (NSTextView*)[field currentEditor]; if (![editor isKindOfClass:[NSTextView class]]) { return NO; } @@ -442,7 +481,7 @@ - (BOOL)configureAddressFieldEditor { // 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 + // Use the exact Core Text baseline equation from BroShimmerTextView. 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 @@ -451,7 +490,7 @@ - (BOOL)configureAddressFieldEditor { ceil(font.ascender - font.descender + font.leading); CGFloat baselineFromBottom = ceil(-font.descender); CGFloat baselineFromTop = - (_addressField.bounds.size.height + lineHeight) / 2.0 - + (field.bounds.size.height + lineHeight) / 2.0 - baselineFromBottom; editor.textContainerInset = NSMakeSize(0.0, MAX(0.0, baselineFromTop - font.ascender)); @@ -467,63 +506,63 @@ - (BOOL)configureAddressFieldEditor { return YES; } -- (void)addressFieldDidFocus { +- (void)addressFieldDidFocus:(BroAddressField*)field { + BroTabView* tab = [field.superview isKindOfClass:[BroTabView class]] + ? (BroTabView*)field.superview + : nil; + if (!tab || !tab.isActive) { + return; + } // Show the full URL for editing; the host pill shows the focused look // (gray hairline border, pure white text) from click-in through typing. - if (_fullURL.length > 0) { - _addressField.stringValue = _fullURL; - } - if ([_addressField.superview isKindOfClass:[BroTabView class]]) { - ((BroTabView*)_addressField.superview).editingAddress = YES; - } - _addressField.textColor = [NSColor whiteColor]; + field.stringValue = BroURLIsBlank(tab.tabURL) ? @"" : tab.tabURL; + tab.editingAddress = YES; + field.textColor = [NSColor whiteColor]; // 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]) { + if (![self configureAddressFieldEditor:field]) { + __weak BroAddressField* weakField = field; dispatch_async(dispatch_get_main_queue(), ^{ - [self configureAddressFieldEditor]; + BroAddressField* strongField = weakField; + if (strongField) { + [self configureAddressFieldEditor:strongField]; + } }); } } - (void)controlTextDidBeginEditing:(NSNotification*)notification { - if (notification.object == _addressField) { - [self configureAddressFieldEditor]; + if ([notification.object isKindOfClass:[BroAddressField class]]) { + [self configureAddressFieldEditor:(BroAddressField*)notification.object]; } } - (void)controlTextDidEndEditing:(NSNotification*)notification { - NSTextField* textField = notification.object; - if (textField == _addressField) { - NSNumber* reason = notification.userInfo[@"NSTextMovement"]; - if (reason && reason.integerValue == NSReturnTextMovement) { - [self navigateToURL:_addressField.stringValue]; - dispatch_async(dispatch_get_main_queue(), ^{ - [self.window makeFirstResponder:nil]; - }); - } - if ([_addressField.superview isKindOfClass:[BroTabView class]]) { - ((BroTabView*)_addressField.superview).editingAddress = NO; - } - _addressField.textColor = [NSColor labelColor]; - [self displayCompactURL]; + if (![notification.object isKindOfClass:[BroAddressField class]]) { + return; } + BroAddressField* field = (BroAddressField*)notification.object; + BroTabView* tab = [field.superview isKindOfClass:[BroTabView class]] + ? (BroTabView*)field.superview + : nil; + NSNumber* reason = notification.userInfo[@"NSTextMovement"]; + if (tab.isActive && reason && + reason.integerValue == NSReturnTextMovement) { + [self navigateToURL:field.stringValue]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self.window makeFirstResponder:nil]; + }); + } + tab.editingAddress = NO; + field.textColor = [NSColor labelColor]; } - (void)focusAddressField { - if (_addressField.superview) { - 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; - } - } + BroHandler* handler = BroHandler::GetInstance(); + int activeId = handler ? handler->GetActiveBrowserId() : -1; + [[g_tab_bar tabWithBrowserId:activeId] focusAddressField]; } #pragma mark - State Updates @@ -538,17 +577,9 @@ - (void)updateURL:(NSString*)url { if (BroURLIsBlank(url)) { url = @""; } - self.fullURL = url ?: @""; - // Don't clobber text the user is currently typing. - if ([_addressField currentEditor]) { - return; - } - [self displayCompactURL]; -} - -// Shows just the host (like the mockup); the full URL appears on focus. -- (void)displayCompactURL { - _addressField.stringValue = BroDisplayHostForURL(_fullURL); + BroHandler* handler = BroHandler::GetInstance(); + int activeId = handler ? handler->GetActiveBrowserId() : -1; + [[g_tab_bar tabWithBrowserId:activeId] setTabURL:url ?: @""]; } @end