From 79ea432b2b149618340fbb73ce4e719a1c079aaa Mon Sep 17 00:00:00 2001 From: Jose Rago <13522179+ragojose@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:03:14 -0300 Subject: [PATCH] Keep the mobile shell stable across tab changes New tabs now inherit the active tab's viewport mode. The shell hugs the mobile viewport for as long as a mobile-emulated tab is active, so a desktop-mode new tab snapped the window back to the desktop frame on every Cmd+T / + click. Adoption records the inherited mode synchronously (the shell and the tab's container lay out from it in the same turn) and posts the DevTools overrides to the next UI-thread turn: calling ExecuteDevToolsMethod on a browser still inside its own OnAfterCreated dereferences null in CEF 151 and takes the process down. Activating a desktop tab from the mobile shell also left the tab strip overlapping itself. The activation spring animates pill frames through the animator; the window resize that follows re-laid the strip with plain `frame` sets, which do not stop an animation already in flight, so those pills sprang back to the targets computed for the 512pt strip inside a 1200pt window. Only pills whose slot happened not to move came out right. Unanimated layouts now go through the animator inside a zero-duration group, which lands the frame and cancels the spring. Also in this branch: the Reading Mode crash gate moves to ImmersiveReadAnything (the earlier "ReadAnything" name is not a base::Feature in CEF 151, so that switch was a silent no-op), and the tab strip gains horizontal scrolling with a pinned search control. Co-Authored-By: Claude Opus 5 (1M context) --- src/bro_app.cc | 22 +++- src/bro_handler.cc | 31 +++++ src/bro_handler.h | 11 ++ src/bro_mac.mm | 47 ++++++- src/bro_mac_internal.h | 10 +- src/bro_tabstrip.mm | 284 ++++++++++++++++++++++++++++++----------- src/bro_toolbar.mm | 2 - 7 files changed, 317 insertions(+), 90 deletions(-) diff --git a/src/bro_app.cc b/src/bro_app.cc index 58626bb..ffeb801 100644 --- a/src/bro_app.cc +++ b/src/bro_app.cc @@ -31,12 +31,20 @@ void BroApp::OnBeforeCommandLineProcessing( command_line->AppendSwitch("ignore-gpu-blocklist"); // Chrome's Reading Mode ships in CEF without the profile plumbing it - // expects: its ReadAnythingSoftNavigationObserver dereferences a null - // coordinator the moment any page performs a soft navigation (History API / - // same-document), taking the whole browser process down. Symbolicated from - // a field crash on wakawaka.world, 2026-08-11 (build 85). Disabling the - // feature removes the observer; merge with any existing list rather than - // clobbering the features CEF disables on its own. + // expects: its page-load-metrics observer dereferences a null side-panel + // coordinator on ordinary page loads and soft navigations, taking the whole + // browser process down. Field crashes on wakawaka.world (build 85) and + // basement.studio (build 88). + // + // The gate is ImmersiveReadAnything, which is enabled by default. An earlier + // attempt disabled "ReadAnything", which is not a base::Feature at all in CEF + // 151 -- verified by scanning every feature struct in the shipped framework; + // the only "ReadAnything" string there is a SidePanelEntry::Id name. Chromium + // ignores unknown names in disable-features, so that switch was a no-op. If + // this name ever needs changing, verify it against the CEF binary first. + // + // Merge with any existing list rather than clobbering the features CEF + // disables on its own. const char kDisableFeatures[] = "disable-features"; std::string disabled = command_line->HasSwitch(kDisableFeatures) @@ -45,7 +53,7 @@ void BroApp::OnBeforeCommandLineProcessing( if (!disabled.empty()) { disabled += ","; } - disabled += "ReadAnything"; + disabled += "ImmersiveReadAnything"; command_line->AppendSwitchWithValue(kDisableFeatures, disabled); } diff --git a/src/bro_handler.cc b/src/bro_handler.cc index 7041b92..e4cfbd4 100644 --- a/src/bro_handler.cc +++ b/src/bro_handler.cc @@ -147,6 +147,37 @@ void BroHandler::SetTabMobileEmulation(int browser_id, bool enabled) { } } +void BroHandler::AdoptTabMobileEmulation(int browser_id) { + CEF_REQUIRE_UI_THREAD(); + + if (IsTabMobile(browser_id)) { + return; + } + // Recorded synchronously: the caller lays the shell and the tab's container + // out from this state in the same turn. + mobile_tab_ids_.insert(browser_id); + + CefPostTask(TID_UI, + base::BindOnce(&BroHandler::ApplyPendingMobileEmulation, this, + browser_id)); +} + +void BroHandler::ApplyPendingMobileEmulation(int browser_id) { + CEF_REQUIRE_UI_THREAD(); + + // The tab may have closed or been switched back to desktop while the task + // was queued. + if (!IsTabMobile(browser_id)) { + return; + } + CefRefPtr browser = browser_registry_.GetById(browser_id); + if (browser) { + // No reload: a freshly adopted tab has not made its first user-driven + // navigation yet, which is when the user agent override takes effect. + ApplyEmulationToBrowser(browser, /*mobile=*/true, /*reload=*/false); + } +} + void BroHandler::ReloadTab(int browser_id) { if (!CefCurrentlyOn(TID_UI)) { CefPostTask(TID_UI, diff --git a/src/bro_handler.h b/src/bro_handler.h index ec00601..ac89f50 100644 --- a/src/bro_handler.h +++ b/src/bro_handler.h @@ -99,6 +99,13 @@ class BroHandler : public CefClient, return mobile_tab_ids_.count(browser_id) > 0; } + // Marks a just-created tab as mobile so the shell can lay it out + // immediately, and applies the DevTools overrides on a later UI-thread + // turn. Used by the adoption path, which runs inside OnAfterCreated -- + // ExecuteDevToolsMethod on a browser still inside its creation callback + // dereferences null in CEF 151 (SIGSEGV on CrBrowserMain). + void AdoptTabMobileEmulation(int browser_id); + // Reload a tab. Used to apply a pending user agent override once the // viewport toggle animation has finished (the UA only takes effect on the // next navigation). @@ -227,6 +234,10 @@ class BroHandler : public CefClient, bool mobile, bool reload); + // Deferred half of AdoptTabMobileEmulation: applies the overrides unless the + // tab was closed or switched back to desktop in the meantime. + void ApplyPendingMobileEmulation(int browser_id); + // True if using Alloy style (native windows) const bool is_alloy_style_; diff --git a/src/bro_mac.mm b/src/bro_mac.mm index dd86e5d..991577e 100644 --- a/src/bro_mac.mm +++ b/src/bro_mac.mm @@ -1610,8 +1610,29 @@ - (void)createApplication:(id)object { g_main_window.delegate = self; g_main_window.title = @"Bro Computer"; - // Show the window + // Order the window while transparent so the behind-window material can + // attach and AppKit can commit the complete shell before any pixels become + // visible. Cold launches from a DMG otherwise expose the window server's + // black initial backing store for a frame before the glass surface resolves. + g_main_window.alphaValue = 0.0; [g_main_window makeKeyAndOrderFront:nil]; + [g_main_window.contentView layoutSubtreeIfNeeded]; + [g_main_window displayIfNeeded]; + [CATransaction flush]; + + // Reveal without animation on the next main-queue turn, after the initial + // layer transaction has been submitted. Keep this independent of CEF's + // asynchronous browser creation so a slow or failed browser startup cannot + // leave the native window invisible. + dispatch_async(dispatch_get_main_queue(), ^{ + if (!g_main_window) { + return; + } + [g_main_window.contentView layoutSubtreeIfNeeded]; + [g_main_window displayIfNeeded]; + [CATransaction flush]; + g_main_window.alphaValue = 1.0; + }); // Create the CEF browser in our window [self performSelectorOnMainThread:@selector(createBrowserInWindow) @@ -2528,6 +2549,23 @@ bool OnTabCreated(int browser_id, const std::string& url, void* native_view) { [g_blank_tab_ids addObject:@(browser_id)]; } + // A tab opened while a mobile-emulated tab is active inherits that mode. + // The shell hugs the mobile viewport for as long as such a tab is active, + // so a desktop-mode new tab would snap the window back to the desktop frame + // and re-lay the chrome on every ⌘T / + click. The outgoing tab is still + // the active one here: OnAfterCreated promotes this browser only after + // adoption returns. Applied before the tab bar work below so the toolbar's + // viewport toggles (updated from setActiveTab:) show the inherited mode. + // The DevTools overrides themselves land a turn later -- see + // AdoptTabMobileEmulation; issuing them from inside OnAfterCreated crashes. + BroHandler* creating_handler = BroHandler::GetInstance(); + int outgoing_id = + creating_handler ? creating_handler->GetActiveBrowserId() : -1; + if (creating_handler && outgoing_id >= 0 && outgoing_id != browser_id && + creating_handler->IsTabMobile(outgoing_id)) { + creating_handler->AdoptTabMobileEmulation(browser_id); + } + // Add tab to tab bar if (g_tab_bar) { [g_tab_bar addTabWithBrowserId:browser_id title:kBroBlankTabTitle]; @@ -2538,8 +2576,11 @@ bool OnTabCreated(int browser_id, const std::string& url, void* native_view) { UpdateTabContainerVisibility(browser_id); // A newly adopted tab becomes active without going through - // OnActiveTabChanged; the shell follows its viewport mode (desktop). - UpdateWindowForViewportMode(TabIsMobile(browser_id), YES); + // OnActiveTabChanged; the shell follows its viewport mode (inherited from + // the tab it replaced, above). While split the shell stays desktop-shaped, + // as it does on every other tab switch. + UpdateWindowForViewportMode(SplitActive() ? NO : TabIsMobile(browser_id), + YES); if (shouldFocusAddress) { // Let adoption and layout finish before asking AppKit for its field diff --git a/src/bro_mac_internal.h b/src/bro_mac_internal.h index e8e5edc..4477553 100644 --- a/src/bro_mac_internal.h +++ b/src/bro_mac_internal.h @@ -284,6 +284,9 @@ extern NSTextField* BroHoverCardLabel(NSFont* font, CGFloat whiteAlpha); // the active pill reveals it only while editing. @interface BroTabView : NSView @property (nonatomic, assign) int browserId; +// The tab bar remains the interaction owner even though pills live inside its +// scroll view's document view rather than as direct children. +@property (nonatomic, weak) BroTabBar* owningTabBar; @property (nonatomic, strong) NSImageView* faviconView; @property (nonatomic, strong) BroShimmerTextView* titleLabel; // Each pill owns its editor for its entire lifetime. Only the active pill may @@ -336,9 +339,10 @@ extern NSTextField* BroHoverCardLabel(NSFont* font, CGFloat whiteAlpha); #pragma mark - BroTabBar -// The tab strip lives INSIDE the toolbar row: each tab is a pill (favicon + -// host + close), the active pill hosts the editable address field, and the -// "+" button trails the last pill. +// The tab strip lives INSIDE the toolbar row: tab pills scroll horizontally, +// the active pill hosts the editable address field, the "+" trails the last +// pill until the pills overflow, and the search control stays pinned at the +// trailing edge. @interface BroTabBar : NSView @property (nonatomic, strong) NSMutableArray* tabs; @property (nonatomic, strong) BroHoverButton* addTabButton; diff --git a/src/bro_tabstrip.mm b/src/bro_tabstrip.mm index 758ffb8..58a0af7 100644 --- a/src/bro_tabstrip.mm +++ b/src/bro_tabstrip.mm @@ -510,7 +510,11 @@ - (instancetype)initWithFrame:(NSRect)frame browserId:(int)browserId { _addressField.bordered = NO; _addressField.drawsBackground = NO; _addressField.focusRingType = NSFocusRingTypeNone; - _addressField.textColor = [NSColor labelColor]; + // The window-owned NSTextView is transparent, so a visible cell value + // would remain underneath it and draw every URL twice. The cell owns only + // the explicitly colored placeholder; live text is colored on the field + // editor in configureAddressFieldEditor:. + _addressField.textColor = [NSColor clearColor]; _addressField.delegate = g_toolbar; _addressField.cell.scrollable = YES; _addressField.cell.usesSingleLineMode = YES; @@ -969,21 +973,21 @@ - (void)mouseDown:(NSEvent*)event { if (!_isActive) { [self performSelect]; } - if ([self.superview isKindOfClass:[BroTabBar class]]) { - [(BroTabBar*)self.superview beginPotentialDragForTab:self withEvent:event]; + if (_owningTabBar) { + [_owningTabBar beginPotentialDragForTab:self withEvent:event]; } } - (void)mouseDragged:(NSEvent*)event { - if ([self.superview isKindOfClass:[BroTabBar class]]) { - [(BroTabBar*)self.superview dragTab:self withEvent:event]; + if (_owningTabBar) { + [_owningTabBar dragTab:self withEvent:event]; } } - (void)mouseUp:(NSEvent*)event { BOOL dragged = NO; - if ([self.superview isKindOfClass:[BroTabBar class]]) { - dragged = [(BroTabBar*)self.superview endDragForTab:self]; + if (_owningTabBar) { + dragged = [_owningTabBar endDragForTab:self]; } if (!dragged && wasActiveAtMouseDown_) { [self performSelect]; @@ -1016,16 +1020,16 @@ - (void)otherMouseUp:(NSEvent*)event { - (void)mouseEntered:(NSEvent*)event { hovered_ = YES; [self updateAppearance]; - if ([self.superview isKindOfClass:[BroTabBar class]]) { - [(BroTabBar*)self.superview tabHoverBegan:self]; + if (_owningTabBar) { + [_owningTabBar tabHoverBegan:self]; } } - (void)mouseExited:(NSEvent*)event { hovered_ = NO; [self updateAppearance]; - if ([self.superview isKindOfClass:[BroTabBar class]]) { - [(BroTabBar*)self.superview tabHoverEnded:self]; + if (_owningTabBar) { + [_owningTabBar tabHoverEnded:self]; } } @@ -1044,9 +1048,8 @@ - (BOOL)becomeFirstResponder { if (ok) { focused_ = YES; [self updateAppearance]; - if (!BroCurrentEventIsPointerActivation() && - [self.superview isKindOfClass:[BroTabBar class]]) { - [(BroTabBar*)self.superview tabFocusBegan:self]; + if (!BroCurrentEventIsPointerActivation() && _owningTabBar) { + [_owningTabBar tabFocusBegan:self]; } } return ok; @@ -1057,8 +1060,8 @@ - (BOOL)resignFirstResponder { if (ok) { focused_ = NO; [self updateAppearance]; - if ([self.superview isKindOfClass:[BroTabBar class]]) { - [(BroTabBar*)self.superview tabFocusEnded:self]; + if (_owningTabBar) { + [_owningTabBar tabFocusEnded:self]; } } return ok; @@ -1072,10 +1075,9 @@ - (void)keyDown:(NSEvent*)event { return; } if (c == NSLeftArrowFunctionKey || c == NSRightArrowFunctionKey) { - if ([self.superview isKindOfClass:[BroTabBar class]]) { - [(BroTabBar*)self.superview - focusTabRelativeTo:self - offset:(c == NSRightArrowFunctionKey ? 1 : -1)]; + if (_owningTabBar) { + [_owningTabBar focusTabRelativeTo:self + offset:(c == NSRightArrowFunctionKey ? 1 : -1)]; return; } } @@ -1145,8 +1147,8 @@ - (void)handleClose:(id)sender { } - (void)handleUnpin:(id)sender { - if (_pinned && [self.superview isKindOfClass:[BroTabBar class]]) { - [(BroTabBar*)self.superview togglePinForTab:self]; + if (_pinned && _owningTabBar) { + [_owningTabBar togglePinForTab:self]; } } @@ -1334,7 +1336,40 @@ - (void)setTitle:(NSString*)title #pragma mark - BroTabBar +// Scrolls only the pill document. The fixed New Tab and Search Tabs controls +// remain siblings in BroTabBar, so no amount of overflow can cover them. +@interface BroHorizontalTabScrollView : NSScrollView +@property (nonatomic, weak) BroTabBar* tabBar; +@end + +@implementation BroHorizontalTabScrollView + +- (void)scrollWheel:(NSEvent*)event { + [_tabBar hideHoverCard]; + + // Precision devices provide deltaX directly. Traditional mouse wheels do + // not, so Shift+wheel maps their vertical delta onto the horizontal axis. + if ((event.modifierFlags & NSEventModifierFlagShift) != 0 && + fabs(event.scrollingDeltaX) < fabs(event.scrollingDeltaY)) { + NSClipView* clip = self.contentView; + NSPoint origin = clip.bounds.origin; + CGFloat multiplier = event.hasPreciseScrollingDeltas ? 1.0 : 12.0; + origin.x -= event.scrollingDeltaY * multiplier; + CGFloat maxX = MAX(0.0, NSWidth(self.documentView.frame) - + NSWidth(clip.bounds)); + origin.x = MIN(MAX(0.0, origin.x), maxX); + [clip scrollToPoint:origin]; + [self reflectScrolledClipView:clip]; + return; + } + [super scrollWheel:event]; +} + +@end + @implementation BroTabBar { + BroHorizontalTabScrollView* tabScrollView_; + NSView* tabContentView_; BroTabView* draggingTab_; BOOL dragging_; CGFloat dragStartX_; @@ -1368,13 +1403,29 @@ - (instancetype)initWithFrame:(NSRect)frame { // Performance: Enable layer-backing for GPU compositing self.wantsLayer = YES; self.layerContentsRedrawPolicy = NSViewLayerContentsRedrawOnSetNeedsDisplay; - // Pills that still don't fit at their hard minimum width are clipped - // rather than drawn over the controls to the right of the strip. self.layer.masksToBounds = YES; - // 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. + // Only the pills scroll. The document is widened by applyTabLayout: when + // compact pills overflow, while the two controls below remain fixed. + tabScrollView_ = [[BroHorizontalTabScrollView alloc] + initWithFrame:NSMakeRect(0, 0, 0, frame.size.height)]; + tabScrollView_.tabBar = self; + tabScrollView_.drawsBackground = NO; + tabScrollView_.borderType = NSNoBorder; + tabScrollView_.hasHorizontalScroller = NO; + tabScrollView_.hasVerticalScroller = NO; + tabScrollView_.horizontalScrollElasticity = NSScrollElasticityAutomatic; + tabScrollView_.verticalScrollElasticity = NSScrollElasticityNone; + tabScrollView_.automaticallyAdjustsContentInsets = NO; + tabScrollView_.contentView.drawsBackground = NO; + tabContentView_ = + [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 0, frame.size.height)]; + tabContentView_.wantsLayer = YES; + tabScrollView_.documentView = tabContentView_; + [self addSubview:tabScrollView_]; + + // Borderless "+" control, pinned between the scroll viewport and Search. + // It keeps the compact hit area and icon size used by the original strip. CGFloat addY = (frame.size.height - kAddTabButtonSize) / 2.0; _addTabButton = [[BroHoverButton alloc] initWithFrame:NSMakeRect(0, addY, kAddTabButtonSize, kAddTabButtonSize)]; @@ -1392,9 +1443,8 @@ - (instancetype)initWithFrame:(NSRect)frame { modifierMask:NSEventModifierFlagCommand]; [self addSubview:_addTabButton]; - // Palette search button, pinned at the strip's right edge (the "+" - // button trails the last pill; this one never moves). Sized and styled - // like the toolbar buttons to its right — it reads as part of that + // Palette search button, pinned at the strip's right edge. Sized and + // styled like the toolbar buttons to its right — it reads as part of that // trailing cluster, and hides/reveals with the downloads button (the // toolbar owns the shared hover zone). _tabSearchButton = [[BroHoverButton alloc] @@ -1414,6 +1464,8 @@ - (instancetype)initWithFrame:(NSRect)frame { NSEventModifierFlagShift]; _tabSearchButton.alphaValue = 0.0; [self addSubview:_tabSearchButton]; + + [self applyTabLayout:NO]; } return self; } @@ -1445,18 +1497,25 @@ - (void)addTabWithBrowserId:(int)browserId title:(NSString*)title { BroTabView* tab = [[BroTabView alloc] initWithFrame:NSMakeRect(0, pillY, kTabPillMaxWidth, kTabPillHeight) browserId:browserId]; + tab.owningTabBar = self; tab.pageTitle = BroURLIsBlank(title) ? kBroBlankTabTitle : title; tab.target = self; tab.selectAction = @selector(tabSelected:); tab.closeAction = @selector(tabClosed:); [_tabs addObject:tab]; - [self addSubview:tab]; + [tabContentView_ addSubview:tab]; - // The new pill starts at its final slot fully transparent and fades in - // while its neighbors and the "+" button slide over to make room. - CGFloat tabWidth = [self fittedTabWidth]; - tab.frame = NSMakeRect((_tabs.count - 1) * (tabWidth + kTabGap), pillY, - tabWidth, kTabPillHeight); + // The new pill starts at its final slot fully transparent and fades in while + // its neighbors slide over to make room. + NSArray* widths = [self tabWidths]; + CGFloat tabX = 0.0; + for (NSUInteger i = 0; i + 1 < _tabs.count; i++) { + BroTabView* preceding = _tabs[i]; + tabX += widths[i].doubleValue - + (preceding.joinedSide == 1 ? kSplitJoinedOverlap : -kTabGap); + } + CGFloat tabWidth = widths.lastObject.doubleValue; + tab.frame = NSMakeRect(tabX, pillY, tabWidth, kTabPillHeight); tab.alphaValue = 0.0; BroRunLayoutSpring(^{ [self applyTabLayout:YES]; @@ -1487,7 +1546,7 @@ - (void)addTabWithBrowserId:(int)browserId title:(NSString*)title { - (void)beginPotentialDragForTab:(BroTabView*)tab withEvent:(NSEvent*)event { [self hideHoverCard]; - NSPoint p = [self convertPoint:event.locationInWindow fromView:nil]; + NSPoint p = [tabContentView_ convertPoint:event.locationInWindow fromView:nil]; draggingTab_ = tab; dragging_ = NO; dragStartX_ = p.x; @@ -1498,7 +1557,7 @@ - (void)dragTab:(BroTabView*)tab withEvent:(NSEvent*)event { if (tab != draggingTab_) { return; } - NSPoint p = [self convertPoint:event.locationInWindow fromView:nil]; + NSPoint p = [tabContentView_ convertPoint:event.locationInWindow fromView:nil]; if (!dragging_) { // A few points of slop separates a click from a drag. if (fabs(p.x - dragStartX_) < 4.0) { @@ -1512,6 +1571,11 @@ - (void)dragTab:(BroTabView*)tab withEvent:(NSEvent*)event { [self.window disableCursorRects]; [[NSCursor closedHandCursor] push]; } + // NSView's native edge autoscroll keeps distant slots reachable without a + // separate timer. Convert again after it runs because the document origin + // may have changed. + [tabContentView_ autoscroll:event]; + p = [tabContentView_ convertPoint:event.locationInWindow fromView:nil]; // AppKit still resets the cursor as the pointer crosses tracking areas, so // reassert it on every drag event. [[NSCursor closedHandCursor] set]; @@ -1636,6 +1700,7 @@ - (void)focusTabRelativeTo:(BroTabView*)tab offset:(NSInteger)offset { if (next < 0 || next >= (NSInteger)_tabs.count) { return; } + [self scrollTabToVisible:_tabs[next]]; [self.window makeFirstResponder:_tabs[next]]; } @@ -1774,6 +1839,7 @@ - (void)setActiveTab:(int)browserId { [self applyTabLayout:YES]; }, nil); } + [self revealActiveTab]; } - (void)updateTabTitle:(int)browserId title:(NSString*)title { @@ -1816,10 +1882,10 @@ - (void)updateTabLoading:(int)browserId loading:(BOOL)loading { } - (CGFloat)availableStripWidth { - // The right edge reserves the trailing "+" button and the pinned search - // button (toolbar-button sized). - return self.frame.size.width - (kAddTabButtonSize + 8.0) - - (kButtonSize + 8.0); + // The right edge reserves the pinned "+" and Search Tabs controls. This is + // the visible width of the pill document, not its potentially wider content. + return MAX(0.0, NSWidth(self.bounds) - (kAddTabButtonSize + 8.0) - + (kButtonSize + 8.0)); } // Pinned pills are kept as a strict prefix of _tabs; every layout and drag @@ -1852,9 +1918,9 @@ - (NSUInteger)compactPinnedCount { // Strip width left for expandable pills once compact pinned tabs took theirs. - (CGFloat)expandableStripWidth { - return [self availableStripWidth] - - (CGFloat)[self compactPinnedCount] * - (kPinnedTabPillWidth + kTabGap); + return MAX(0.0, [self availableStripWidth] - + (CGFloat)[self compactPinnedCount] * + (kPinnedTabPillWidth + kTabGap)); } // Fits expandable pills to the remaining strip width (minus the "+" button @@ -1869,11 +1935,13 @@ - (CGFloat)fittedTabWidth { return MIN(MAX(fitWidth, kTabPillSquareWidth), kTabPillMaxWidth); } -// YES once an even split would leave no room for text: pills become squares. +// YES once equal pills would make the active URL narrower than its comfortable +// minimum. In that state inactive pills become compact and the document may +// scroll so the active pill never has to collapse. - (BOOL)isCollapsed { NSUInteger count = MAX(_tabs.count - [self compactPinnedCount], (NSUInteger)1); - return [self expandableStripWidth] / count - kTabGap < kTabPillTextMinWidth; + return [self expandableStripWidth] / count - kTabGap < kTabPillMinWidth; } // Width of one drag slot for `tab`. Pinned pills only travel their compact @@ -1910,16 +1978,11 @@ - (CGFloat)dragSlotWidthForTab:(BroTabView*)tab { (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) - : compactActiveWidth; + CGFloat slack = [self availableStripWidth] - fixedInactiveWidth - kTabGap; + // When slack runs out, overflow belongs to the scroll document—not to the + // active address pill. Keep it readable even with dozens of tabs. + CGFloat activeWidth = + MIN(kTabPillMaxWidth, MAX(kTabPillMinWidth, slack)); for (BroTabView* tab in _tabs) { CGFloat compactWidth = tab.pinned ? kPinnedTabPillWidth : kTabPillSquareWidth; @@ -1928,10 +1991,58 @@ - (CGFloat)dragSlotWidthForTab:(BroTabView*)tab { return widths; } -// Positions pills and the "+" button. When `animated`, frames move through -// the animator proxy, so this must run inside an NSAnimationContext group. -- (void)applyTabLayout:(BOOL)animated { - CGFloat pillY = (self.frame.size.height - kTabPillHeight) / 2.0; +// Keeps the current scroll offset inside the document after tabs close or the +// window widens enough that overflow disappears. +- (void)clampScrollOffset { + NSClipView* clip = tabScrollView_.contentView; + NSPoint origin = clip.bounds.origin; + CGFloat maxX = MAX(0.0, NSWidth(tabContentView_.frame) - + NSWidth(clip.bounds)); + origin.x = MIN(MAX(0.0, origin.x), maxX); + origin.y = 0.0; + [clip scrollToPoint:origin]; + [tabScrollView_ reflectScrolledClipView:clip]; +} + +// Reveals only when needed, preserving the user's position when the complete +// pill is already visible. +- (void)scrollTabToVisible:(BroTabView*)tab { + if (!tab || ![_tabs containsObject:tab] || dragging_) { + return; + } + NSClipView* clip = tabScrollView_.contentView; + NSRect visible = clip.bounds; + NSRect target = NSInsetRect(tab.frame, -4.0, 0.0); + if (NSContainsRect(visible, target)) { + return; + } + CGFloat x = NSMinX(visible); + if (NSMinX(target) < NSMinX(visible)) { + x = NSMinX(target); + } else if (NSMaxX(target) > NSMaxX(visible)) { + x = NSMaxX(target) - NSWidth(visible); + } + CGFloat maxX = MAX(0.0, NSWidth(tabContentView_.frame) - NSWidth(visible)); + NSPoint origin = NSMakePoint(MIN(MAX(0.0, x), maxX), 0.0); + [clip scrollToPoint:origin]; + [tabScrollView_ reflectScrolledClipView:clip]; +} + +- (void)revealActiveTab { + [self scrollTabToVisible:[self tabWithBrowserId:_activeTabId]]; +} + +// Positions pills and sizes their scroll document. Pill frames and the +// trailing "+" always move through the animator proxy: inside an animated +// caller's group they spring to their slots, and inside applyTabLayout:'s +// zero-duration group they land immediately AND cancel any spring still in +// flight. The search button never participates. +- (void)layOutTabsThroughAnimator { + CGFloat height = NSHeight(self.bounds); + CGFloat viewportWidth = [self availableStripWidth]; + tabScrollView_.frame = NSMakeRect(0, 0, viewportWidth, height); + + CGFloat pillY = (height - kTabPillHeight) / 2.0; NSArray* widths = [self tabWidths]; CGFloat x = 0.0; @@ -1947,10 +2058,8 @@ - (void)applyTabLayout:(BOOL)animated { NSRect target = NSMakeRect(x, pillY, tabWidth, kTabPillHeight); if (dragging_ && tab == draggingTab_) { // The dragged pill follows the mouse; its slot stays reserved. - } else if (animated) { - tab.animator.frame = target; } else { - tab.frame = target; + tab.animator.frame = target; } // The split pair overlaps by 1pt so the two borders coincide and the // pair reads as one continuous pill; every other neighbor keeps the @@ -1958,21 +2067,45 @@ - (void)applyTabLayout:(BOOL)animated { x += tabWidth - (tab.joinedSide == 1 ? kSplitJoinedOverlap : -kTabGap); } + CGFloat documentWidth = MAX(viewportWidth, x); + tabContentView_.frame = NSMakeRect(0, 0, documentWidth, height); + [self clampScrollOffset]; + + // The "+" trails the last pill by one gap (`x` already carries it) so it + // reads as part of the strip. Once the pills overflow it stops advancing and + // parks at the viewport's clipped edge, where it stays put while scrolling. NSRect addTarget = - NSMakeRect(x, (self.frame.size.height - kAddTabButtonSize) / 2.0, + NSMakeRect(MIN(x, viewportWidth), (height - kAddTabButtonSize) / 2.0, kAddTabButtonSize, kAddTabButtonSize); - if (animated) { - _addTabButton.animator.frame = addTarget; - } else { - _addTabButton.frame = addTarget; - } + _addTabButton.animator.frame = addTarget; // The search button never trails the pills; it stays pinned at the right // edge. _tabSearchButton.frame = - NSMakeRect(self.frame.size.width - kButtonSize, - (self.frame.size.height - kButtonSize) / 2.0, + NSMakeRect(NSWidth(self.bounds) - kButtonSize, + (height - kButtonSize) / 2.0, kButtonSize, kButtonSize); + + [self revealActiveTab]; +} + +// An unanimated layout must WIN over any spring still animating a pill -- +// otherwise the spring keeps ticking and lands that pill back on the target +// it was given for the old strip width, overlapping its neighbors. (Seen when +// activating a desktop tab from the mobile shell: the activation spring and +// the window's resize-driven relayout ran together, and only the pills whose +// slot happened not to move came out right.) Assigning through the animator +// inside a zero-duration group both sets the frame now and cancels the +// in-flight animation; a plain `frame` set does not. +- (void)applyTabLayout:(BOOL)animated { + if (animated) { + [self layOutTabsThroughAnimator]; + return; + } + [NSAnimationContext runAnimationGroup:^(NSAnimationContext* context) { + context.duration = 0.0; + [self layOutTabsThroughAnimator]; + }]; } - (void)layoutTabs { @@ -2046,7 +2179,7 @@ - (void)tabHoverBegan:(BroTabView*)tab { BroTabBar* bar = weakSelf; BroTabView* hoveredTab = weakTab; if (!bar || timer != bar->hoverCardTimer_ || !hoveredTab || - hoveredTab.superview != bar) { + ![bar.tabs containsObject:hoveredTab]) { return; } bar->hoverCardTimer_ = nil; @@ -2111,6 +2244,7 @@ - (void)tabFocusBegan:(BroTabView*)tab { if (hidingHoverCard_) { return; } + [self scrollTabToVisible:tab]; [hoverCardTimer_ invalidate]; hoverCardTimer_ = nil; [hoverCardHideTimer_ invalidate]; @@ -2155,7 +2289,7 @@ - (void)hideHoverCard { BroOverlayHide(hoverCard_); hoverCardTabId_ = -1; [self.window recalculateKeyViewLoop]; - if (cardHadFocus && sourceTab && sourceTab.superview == self) { + if (cardHadFocus && sourceTab && [_tabs containsObject:sourceTab]) { [self.window makeFirstResponder:sourceTab]; } hidingHoverCard_ = NO; @@ -2347,7 +2481,7 @@ - (void)showHoverCardForTab:(BroTabView*)tab { // to the container edges. - (void)positionHoverCardForTab:(BroTabView*)tab animated:(BOOL)animated { NSView* container = hoverCard_.superview; - if (!container || tab.superview != self) { + if (!container || ![_tabs containsObject:tab]) { return; } NSRect pill = [container convertRect:tab.bounds fromView:tab]; diff --git a/src/bro_toolbar.mm b/src/bro_toolbar.mm index 3b4c023..6dc4c1a 100644 --- a/src/bro_toolbar.mm +++ b/src/bro_toolbar.mm @@ -516,7 +516,6 @@ - (void)addressFieldDidFocus:(BroAddressField*)field { // (adaptive hairline border and primary text) from click-in through typing. field.stringValue = BroURLIsBlank(tab.tabURL) ? @"" : tab.tabURL; tab.editingAddress = YES; - field.textColor = [NSColor labelColor]; // 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; @@ -555,7 +554,6 @@ - (void)controlTextDidEndEditing:(NSNotification*)notification { }); } tab.editingAddress = NO; - field.textColor = [NSColor labelColor]; } - (void)focusAddressField {