From 95961dad110f5de3f3b510cd92d1af79f5f55999 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 13 Aug 2026 09:54:55 -0300 Subject: [PATCH] fix: prevent large-window glass corruption --- src/bro_mac.mm | 50 +++++++++++++++++++++---------- src/bro_motion.h | 18 +++++------ src/bro_motion.mm | 47 +++++++++-------------------- src/bro_tabstrip.mm | 19 +++++------- tests/bro_glass_surface_tests.mm | 51 ++++++++++++++------------------ 5 files changed, 86 insertions(+), 99 deletions(-) diff --git a/src/bro_mac.mm b/src/bro_mac.mm index 6a324c6..8862727 100644 --- a/src/bro_mac.mm +++ b/src/bro_mac.mm @@ -301,6 +301,7 @@ @interface BroWindow : NSWindow { @property (nonatomic, strong) BroToolbar* navToolbar; @property (nonatomic, strong) BroTabBar* tabBar; @property (nonatomic, strong) NSView* shellSurface; +@property (nonatomic, strong) NSView* chromeSurface; @property (nonatomic, strong) NSView* borderOverlay; - (BOOL)glassBackdropVisible; - (void)setGlassBackdropVisible:(BOOL)visible; @@ -346,22 +347,40 @@ - (instancetype)init { self.contentView = content; g_shell_corner_radius = BroResolveShellCornerRadius(self); - // A single stable shell-wide blur removes the material boundary at the - // page edge without stretching Liquid Glass lensing across the content - // area. Opaque CEF pages are composited above it in the browser area. + // A single stable, non-lensing shell blur removes the material boundary + // at the page edge. Window-sized Liquid Glass corrupts its refractive + // backing texture on large Retina windows, so native lensing is reserved + // for the constant-height chrome and bounded controls above this surface. + // Opaque CEF pages are composited above it in the browser area. NSView* shellSurface = [[NSView alloc] initWithFrame:frame]; shellSurface.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; - NSView* shellContent = - BroInstallShellSurface(shellSurface, BroShellCornerRadius()); + BroInstallShellSurface(shellSurface, BroShellCornerRadius()); [content addSubview:shellSurface]; _shellSurface = shellSurface; + // The native refractive surface is restricted to the constant-height + // chrome band. Its texture budget therefore stays stable as the window + // grows vertically, while AppKit can still render the same Liquid Glass + // treatment across the full toolbar width. Older systems use the shell + // backdrop directly and avoid stacking two visual-effect materials. + CGFloat toolbarY = frame.size.height - kToolbarHeight; + NSView* chromeSurface = [[NSView alloc] + initWithFrame:NSMakeRect(0, toolbarY, frame.size.width, kToolbarHeight)]; + chromeSurface.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin; + NSView* chromeContent = chromeSurface; + if (@available(macOS 26.0, *)) { + if (BroShouldUseNativeGlass()) { + chromeContent = BroInstallGlassSurface(chromeSurface, 0.0); + } + } + [content addSubview:chromeSurface]; + _chromeSurface = chromeSurface; + // Single chrome row at the very top; its content is inset past the // traffic lights. - CGFloat toolbarY = frame.size.height - kToolbarHeight; _navToolbar = [[BroToolbar alloc] - initWithFrame:NSMakeRect(0, toolbarY, frame.size.width, kToolbarHeight)]; - [shellContent addSubview:_navToolbar]; + initWithFrame:NSMakeRect(0, 0, frame.size.width, kToolbarHeight)]; + [chromeContent addSubview:_navToolbar]; g_toolbar = _navToolbar; // Tab strip inline in the toolbar row: after the nav buttons, before the @@ -1072,9 +1091,8 @@ void UpdateWindowForViewportMode(BOOL mobile, BOOL animate, // Cmd+S (View menu) hides the toolbar -- and the tab strip nested inside it, // since BroTabBar is one of its subviews -- and the traffic lights, leaving // an Arc-style frame: the page sits as a rounded card inset by -// kScreenshotModeGutter on all four sides, with the window's existing glass -// (the same material the chrome row already wears -- setGlassBackdropVisible: -// below) showing through the gutter, and the existing hairline +// kScreenshotModeGutter on all four sides, with the window's stable shell +// material showing through the gutter, and the existing hairline // (BroWindowBorderView) still tracing the true window edge outside all of // it. The active tab's container(s) already fill 100% of browserContainer's // bounds in every mode -- desktop, mobile, split -- so animating just @@ -1161,10 +1179,11 @@ static void ToggleScreenshotMode(void) { contentBounds.size.height - kToolbarHeight); if (!active) { - // Make the toolbar visible again before fading it in -- a hidden view's - // alpha animation never draws. + // Make the whole glass band visible again before fading it in -- a hidden + // view's alpha animation never draws. + g_main_window.chromeSurface.hidden = NO; + g_main_window.chromeSurface.alphaValue = 0.0; g_toolbar.hidden = NO; - g_toolbar.alphaValue = 0.0; } // The card's own corner radius derives from the window's (bro_geometry's @@ -1182,10 +1201,11 @@ static void ToggleScreenshotMode(void) { : 0.0; BroRunLayoutSpring(^{ - g_toolbar.animator.alphaValue = active ? 0.0 : 1.0; + g_main_window.chromeSurface.animator.alphaValue = active ? 0.0 : 1.0; g_main_window.browserContainer.animator.frame = containerTarget; containerLayer.cornerRadius = cardRadius; }, ^{ + g_main_window.chromeSurface.hidden = active; g_toolbar.hidden = active; UpdateChromeLayout(); // final snap: exact per-tab/divider frames BroHandler* handler = BroHandler::GetInstance(); diff --git a/src/bro_motion.h b/src/bro_motion.h index 98df43d..1a7ab61 100644 --- a/src/bro_motion.h +++ b/src/bro_motion.h @@ -52,7 +52,7 @@ extern void BroRunLayoutSpring(void (^changes)(void), extern void BroApplyElevation(NSView* view, BroElevation elevation); -// Shared material tokens for every glass surface. macOS 26 uses untinted +// Shared material tokens for bounded glass surfaces. macOS 26 uses untinted // Regular glass; older systems retain an appearance-adaptive HUD fallback. extern const CGFloat kBroGlassTintAlpha; extern const CGFloat kBroGlassBorderWidth; @@ -60,13 +60,15 @@ extern NSColor* BroGlassTintColor(void); extern NSColor* BroGlassBorderColor(void); extern NSGlassEffectViewStyle BroGlassEffectStyle(void) API_AVAILABLE(macos(26.0)); -// Runtime gate shared by every native surface. BRO_FORCE_LEGACY_GLASS=1 is a -// test seam for exercising the macOS 12–25 fallback on a macOS 26 machine. +// Runtime gate shared by native control and overlay surfaces. +// BRO_FORCE_LEGACY_GLASS=1 is a test seam for exercising the macOS 12–25 +// fallback on a macOS 26 machine. extern BOOL BroShouldUseNativeGlass(void); -// Installs the shell-sized native Liquid Glass backdrop on macOS 26. The -// returned host is NSGlassEffectView.contentView there; older systems receive -// the same content over the existing visual-effect fallback. +// Installs the stable window-sized AppKit backdrop. The shell deliberately +// stays non-lensing at every OS version because a full-window +// NSGlassEffectView corrupts its backing texture on large Retina windows; +// bounded controls and overlays still use native Liquid Glass on macOS 26. extern NSView* BroInstallShellSurface(NSView* panel, CGFloat cornerRadius); // Replaces the elevation's flat fill with the shared glass surface and @@ -75,10 +77,6 @@ extern NSView* BroInstallShellSurface(NSView* panel, CGFloat cornerRadius); // the existing HUD blur + tint. // Call after BroApplyElevation and add all visible content to the result. extern NSView* BroInstallGlassSurface(NSView* panel, CGFloat cornerRadius); -// Wraps related glass descendants in AppKit's batching/merging container on -// macOS 26 and returns |contentView| unchanged on the legacy path. -extern NSView* BroGlassContainerForContentView(NSView* contentView, - CGFloat spacing); extern void BroOverlayShow(NSView* view); extern void BroOverlayHide(NSView* view); diff --git a/src/bro_motion.mm b/src/bro_motion.mm index 751c796..32be73b 100644 --- a/src/bro_motion.mm +++ b/src/bro_motion.mm @@ -329,23 +329,22 @@ - (void)viewDidChangeEffectiveAppearance { CGFloat backdropRadius = BroCornerRadiusForSize( BroNestedCornerRadius(cornerRadius, 0.0), panel.bounds.size); - if (@available(macOS 26.0, *)) { - if (BroShouldUseNativeGlass()) { - NSGlassEffectView* glass = - [[NSGlassEffectView alloc] initWithFrame:panel.bounds]; - glass.cornerRadius = backdropRadius; - glass.style = BroGlassEffectStyle(); - glass.tintColor = nil; - NSView* contentHost = [[NSView alloc] initWithFrame:glass.bounds]; - contentHost.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; - glass.contentView = contentHost; - BroPinView(glass, panel); - return contentHost; - } - } + // Keep the window-sized shell on AppKit's non-lensing material. A regular + // NSGlassEffectView allocates a refractive backdrop for its whole bounds; + // once a Retina window grows large enough, that surface starts dropping or + // stretching texture tiles during live move/resize. Liquid Glass remains on + // the bounded controls and overlays above this backdrop, where AppKit can + // render and batch it reliably. + // + // Under-window background is the native macOS 26 shell material. Older + // systems retain the established HUD blur. NSVisualEffectView* backdrop = [[NSVisualEffectView alloc] initWithFrame:panel.bounds]; - backdrop.material = NSVisualEffectMaterialHUDWindow; + if (@available(macOS 26.0, *)) { + backdrop.material = NSVisualEffectMaterialUnderWindowBackground; + } else { + backdrop.material = NSVisualEffectMaterialHUDWindow; + } backdrop.blendingMode = NSVisualEffectBlendingModeBehindWindow; backdrop.state = NSVisualEffectStateActive; backdrop.wantsLayer = YES; @@ -417,24 +416,6 @@ - (void)viewDidChangeEffectiveAppearance { return contentHost; } -NSView* BroGlassContainerForContentView(NSView* contentView, - CGFloat spacing) { - if (!contentView) { - return nil; - } - if (@available(macOS 26.0, *)) { - if (BroShouldUseNativeGlass()) { - NSGlassEffectContainerView* container = - [[NSGlassEffectContainerView alloc] - initWithFrame:contentView.frame]; - container.spacing = MAX(0.0, spacing); - container.contentView = contentView; - return container; - } - } - return contentView; -} - void BroOverlayShow(NSView* view) { if (!view) { return; diff --git a/src/bro_tabstrip.mm b/src/bro_tabstrip.mm index e771d56..beac327 100644 --- a/src/bro_tabstrip.mm +++ b/src/bro_tabstrip.mm @@ -20,7 +20,6 @@ static const CGFloat kHoveredTabBorderAlpha = 0.18; // Resting pills keep the strip's 8pt gap. As a dragged pill comes within 6pt // of a neighbor, AppKit's glass container performs the native merge/split. -static const CGFloat kTabGlassMergeSpacing = 6.0; static const CFTimeInterval kTabColorTransitionDuration = 0.18; static NSDictionary* BroTabLayerTransitionActions(void) { @@ -419,9 +418,10 @@ void BroFetchFaviconGuarded(NSString* urlString, } @implementation BroTabView { - // Every macOS 26 pill owns a native glass descendant so the tab strip's - // NSGlassEffectContainerView can batch it and merge neighboring pills - // during drag. Older systems retain the established HUD fallback. + // Every macOS 26 pill owns a bounded native glass descendant. Keeping each + // effect independent prevents AppKit's rectangular batch texture from + // leaking behind the strip on large Retina windows. Older systems retain + // the established HUD fallback. NSView* glassBackdrop_; NSView* pillContentHost_; BOOL nativeGlassBackdrop_; @@ -461,8 +461,7 @@ - (instancetype)initWithFrame:(NSRect)frame browserId:(int)browserId { self.focusRingType = NSFocusRingTypeNone; // Native pills stay neutral; selection is layered through adaptive - // content and a restrained fill so all descendants remain sufficiently - // similar for NSGlassEffectContainerView to merge them. + // content and a restrained fill. if (@available(macOS 26.0, *)) { if (BroShouldUseNativeGlass()) { NSGlassEffectView* glass = @@ -1411,7 +1410,6 @@ - (void)scrollWheel:(NSEvent*)event { @implementation BroTabBar { BroHorizontalTabScrollView* tabScrollView_; - NSView* tabDocumentView_; NSView* tabContentView_; BroTabView* draggingTab_; BOOL dragging_; @@ -1464,9 +1462,7 @@ - (instancetype)initWithFrame:(NSRect)frame { tabContentView_ = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 0, frame.size.height)]; tabContentView_.wantsLayer = YES; - tabDocumentView_ = BroGlassContainerForContentView( - tabContentView_, kTabGlassMergeSpacing); - tabScrollView_.documentView = tabDocumentView_; + tabScrollView_.documentView = tabContentView_; [self addSubview:tabScrollView_]; // Borderless "+" control, pinned between the scroll viewport and Search. @@ -2113,8 +2109,7 @@ - (void)layOutTabsThroughAnimator { } CGFloat documentWidth = MAX(viewportWidth, x); - tabDocumentView_.frame = NSMakeRect(0, 0, documentWidth, height); - tabContentView_.frame = tabDocumentView_.bounds; + tabContentView_.frame = NSMakeRect(0, 0, documentWidth, height); [self clampScrollOffset]; // The "+" trails the last pill by one gap (`x` already carries it) so it diff --git a/tests/bro_glass_surface_tests.mm b/tests/bro_glass_surface_tests.mm index 0d57929..7853fbc 100644 --- a/tests/bro_glass_surface_tests.mm +++ b/tests/bro_glass_surface_tests.mm @@ -32,19 +32,29 @@ int main(void) { if (@available(macOS 26.0, *)) { unsetenv("BRO_FORCE_LEGACY_GLASS"); NSView* shell = [[NSView alloc] - initWithFrame:NSMakeRect(0, 0, 640, 480)]; + initWithFrame:NSMakeRect(0, 0, 2560, 1440)]; NSView* shellContent = BroInstallShellSurface(shell, 12.0); - NSGlassEffectView* shellGlass = (NSGlassEffectView*) - FindSubviewOfClass(shell, [NSGlassEffectView class]); - Require(shellGlass != nil, @"shell must use NSGlassEffectView"); - Require(shellGlass.contentView == shellContent, - @"shell controls must live in native contentView"); - Require(shellGlass.style == NSGlassEffectViewStyleRegular, - @"shell must use Regular glass"); - Require(shellGlass.tintColor == nil, - @"shell glass must remain neutral"); - Require(fabs(shellGlass.cornerRadius - 12.0) < 0.01, - @"shell radius must be handed to AppKit"); + NSVisualEffectView* shellBackdrop = (NSVisualEffectView*) + FindSubviewOfClass(shell, [NSVisualEffectView class]); + Require(shellBackdrop != nil, + @"large shell must use the stable visual-effect backdrop"); + Require(FindSubviewOfClass(shell, [NSGlassEffectView class]) == nil, + @"large shell must not allocate a window-sized glass texture"); + Require(shellBackdrop.material == + NSVisualEffectMaterialUnderWindowBackground, + @"macOS 26 shell must use the under-window material"); + Require(shellContent.superview == shell, + @"shell content host must remain above the stable backdrop"); + + NSView* chrome = [[NSView alloc] + initWithFrame:NSMakeRect(0, 0, 2560, 52)]; + NSView* chromeContent = BroInstallGlassSurface(chrome, 0.0); + NSGlassEffectView* chromeGlass = (NSGlassEffectView*) + FindSubviewOfClass(chrome, [NSGlassEffectView class]); + Require(chromeGlass != nil, + @"wide constant-height chrome must retain native glass"); + Require(chromeGlass.contentView == chromeContent, + @"chrome controls must live in native contentView"); NSView* panel = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 320, 180)]; @@ -58,18 +68,6 @@ int main(void) { Require(fabs(panelGlass.cornerRadius - 10.0) < 0.01, @"overlay radius must be handed to AppKit"); - NSView* tabs = [[NSView alloc] - initWithFrame:NSMakeRect(0, 0, 600, 52)]; - NSView* tabDocument = BroGlassContainerForContentView(tabs, 6.0); - Require([tabDocument isKindOfClass:[NSGlassEffectContainerView class]], - @"tab content must use NSGlassEffectContainerView"); - NSGlassEffectContainerView* tabContainer = - (NSGlassEffectContainerView*)tabDocument; - Require(tabContainer.contentView == tabs, - @"tab content must be owned by the native container"); - Require(fabs(tabContainer.spacing - 6.0) < 0.01, - @"tab merge proximity must be handed to AppKit"); - setenv("BRO_FORCE_LEGACY_GLASS", "1", 1); NSView* fallback = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 320, 180)]; @@ -80,11 +78,6 @@ int main(void) { @"forced fallback must retain NSVisualEffectView"); Require(fallbackContent.superview == fallback, @"fallback content host must remain usable"); - NSView* fallbackTabs = [[NSView alloc] - initWithFrame:NSMakeRect(0, 0, 600, 52)]; - Require(BroGlassContainerForContentView(fallbackTabs, 6.0) == - fallbackTabs, - @"legacy tab content must bypass the unavailable container"); } } return 0;