From 19255c6e85224f32bb1363914478113063427cf3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 24 Apr 2026 05:46:11 -0700 Subject: [PATCH 01/29] Support external file drops --- .../Internal/Views/PaneContainerView.swift | 124 +++++++++++++----- .../Bonsplit/Public/BonsplitController.swift | 14 ++ 2 files changed, 105 insertions(+), 33 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/PaneContainerView.swift b/Sources/Bonsplit/Internal/Views/PaneContainerView.swift index 5e0a2577..e48f4756 100644 --- a/Sources/Bonsplit/Internal/Views/PaneContainerView.swift +++ b/Sources/Bonsplit/Internal/Views/PaneContainerView.swift @@ -299,7 +299,7 @@ struct PaneContainerView: View { .allowsHitTesting(!isTabDragActive) Color.clear - .onDrop(of: [.tabTransfer], delegate: UnifiedPaneDropDelegate( + .onDrop(of: [.tabTransfer, .fileURL], delegate: UnifiedPaneDropDelegate( size: size, pane: pane, controller: controller, @@ -408,29 +408,28 @@ struct UnifiedPaneDropDelegate: DropDelegate { // may not have propagated yet when performDrop runs. guard let draggedTab = controller.activeDragTab ?? controller.draggingTab, let sourcePaneId = controller.activeDragSourcePaneId ?? controller.dragSourcePaneId else { - guard let transfer = decodeTransfer(from: info), - transfer.isFromCurrentProcess else { - return false - } - let destination: BonsplitController.ExternalTabDropRequest.Destination - if zone == .center { - destination = .insert(targetPane: pane.id, targetIndex: nil) - } else if let orientation = zone.orientation { - destination = .split( - targetPane: pane.id, - orientation: orientation, - insertFirst: zone.insertsFirst + if info.hasItemsConforming(to: [.tabTransfer]) { + guard let transfer = decodeTransfer(from: info), + transfer.isFromCurrentProcess, + let destination = destination(for: zone) else { + return false + } + + let request = BonsplitController.ExternalTabDropRequest( + tabId: TabID(id: transfer.tab.id), + sourcePaneId: PaneID(id: transfer.sourcePaneId), + destination: destination ) - } else { - return false + let handled = bonsplitController.onExternalTabDrop?(request) ?? false + if handled { + dropLifecycle = .idle + activeDropZone = nil + } + return handled } - let request = BonsplitController.ExternalTabDropRequest( - tabId: TabID(id: transfer.tab.id), - sourcePaneId: PaneID(id: transfer.sourcePaneId), - destination: destination - ) - let handled = bonsplitController.onExternalTabDrop?(request) ?? false + guard info.hasItemsConforming(to: [.fileURL]) else { return false } + let handled = performFileDrop(info: info, zone: zone) if handled { dropLifecycle = .idle activeDropZone = nil @@ -509,14 +508,14 @@ struct UnifiedPaneDropDelegate: DropDelegate { #if DEBUG dlog("pane.dropUpdated.skip pane=\(pane.id.id.uuidString.prefix(5)) reason=lifecycle_idle") #endif - return DropProposal(operation: .move) + return DropProposal(operation: dropOperation(for: info)) } let zone = effectiveZone(for: info) activeDropZone = zone #if DEBUG dlog("pane.dropUpdated pane=\(pane.id.id.uuidString.prefix(5)) zone=\(zone)") #endif - return DropProposal(operation: .move) + return DropProposal(operation: dropOperation(for: info)) } func validateDrop(info: DropInfo) -> Bool { @@ -527,33 +526,92 @@ struct UnifiedPaneDropDelegate: DropDelegate { #endif return false } - // The custom UTType alone is sufficient — only Bonsplit tab drags produce it. - // Do NOT gate on draggingTab != nil: @Observable changes from createItemProvider - // may not have propagated to the drop delegate yet, causing false rejections. - let hasType = info.hasItemsConforming(to: [.tabTransfer]) - guard hasType else { return false } + let hasTabTransfer = info.hasItemsConforming(to: [.tabTransfer]) + let hasFileURL = info.hasItemsConforming(to: [.fileURL]) + guard hasTabTransfer || hasFileURL else { return false } // Local drags use in-memory state and are always same-process. if controller.activeDragTab != nil || controller.draggingTab != nil { return true } - // External drags (another Bonsplit controller) must include a payload from this process. - guard let transfer = decodeTransfer(from: info), - transfer.isFromCurrentProcess else { - return false + if hasTabTransfer { + // External drags (another Bonsplit controller) must include a payload from this process. + guard let transfer = decodeTransfer(from: info), + transfer.isFromCurrentProcess else { + return false + } + } else if hasFileURL { + guard bonsplitController.onExternalFileDrop != nil || controller.onFileDrop != nil else { + return false + } } #if DEBUG let hasDrag = controller.draggingTab != nil let hasActive = controller.activeDragTab != nil dlog( "pane.validateDrop pane=\(pane.id.id.uuidString.prefix(5)) " + - "allowed=\(hasType ? 1 : 0) hasDrag=\(hasDrag ? 1 : 0) hasActive=\(hasActive ? 1 : 0)" + "hasTab=\(hasTabTransfer ? 1 : 0) hasFile=\(hasFileURL ? 1 : 0) " + + "hasDrag=\(hasDrag ? 1 : 0) hasActive=\(hasActive ? 1 : 0)" ) #endif return true } + private func dropOperation(for info: DropInfo) -> DropOperation { + info.hasItemsConforming(to: [.fileURL]) && !info.hasItemsConforming(to: [.tabTransfer]) + ? .copy + : .move + } + + private func destination( + for zone: DropZone + ) -> BonsplitController.ExternalTabDropRequest.Destination? { + if zone == .center { + return .insert(targetPane: pane.id, targetIndex: nil) + } + guard let orientation = zone.orientation else { return nil } + return .split( + targetPane: pane.id, + orientation: orientation, + insertFirst: zone.insertsFirst + ) + } + + private func performFileDrop(info: DropInfo, zone: DropZone) -> Bool { + guard let destination = destination(for: zone) else { return false } + let urls = fileURLs(from: info) + guard !urls.isEmpty else { return false } + + if let handler = bonsplitController.onExternalFileDrop { + return handler(BonsplitController.ExternalFileDropRequest(urls: urls, destination: destination)) + } + + if case .insert = destination { + return controller.onFileDrop?(urls, pane.id) ?? false + } + return false + } + + private func fileURLs(from info: DropInfo) -> [URL] { + _ = info + let pasteboard = NSPasteboard(name: .drag) + let objects = pasteboard.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true] + ) ?? [] + return objects.compactMap { object -> URL? in + if let url = object as? URL { + return url.isFileURL ? url : nil + } + if let url = object as? NSURL { + let bridged = url as URL + return bridged.isFileURL ? bridged : nil + } + return nil + } + } + private func decodeTransfer(from string: String) -> TabTransferData? { guard let data = string.data(using: .utf8), let transfer = try? JSONDecoder().decode(TabTransferData.self, from: data) else { diff --git a/Sources/Bonsplit/Public/BonsplitController.swift b/Sources/Bonsplit/Public/BonsplitController.swift index 20e9bcb8..dc26debe 100644 --- a/Sources/Bonsplit/Public/BonsplitController.swift +++ b/Sources/Bonsplit/Public/BonsplitController.swift @@ -23,6 +23,16 @@ public final class BonsplitController { } } + public struct ExternalFileDropRequest { + public let urls: [URL] + public let destination: ExternalTabDropRequest.Destination + + public init(urls: [URL], destination: ExternalTabDropRequest.Destination) { + self.urls = urls + self.destination = destination + } + } + // MARK: - Delegate /// Delegate for receiving callbacks about tab bar events @@ -51,6 +61,10 @@ public final class BonsplitController { /// Return `true` when the drop has been handled by the host application. @ObservationIgnored public var onExternalTabDrop: ((ExternalTabDropRequest) -> Bool)? + /// Handler for file drops from external apps, routed through pane drop zones. + /// Return `true` when the drop has been handled by the host application. + @ObservationIgnored public var onExternalFileDrop: ((ExternalFileDropRequest) -> Bool)? + /// Called when the user explicitly requests to close a tab from the tab strip UI. /// Internal host-driven closes should not use this hook. @ObservationIgnored public var onTabCloseRequest: ((_ tabId: TabID, _ paneId: PaneID) -> Void)? From be79a65655b8aeafb1a6c0bafaa1934ae16dc96f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 27 Apr 2026 20:28:22 -0700 Subject: [PATCH 02/29] Harden external file drop validation --- .../Internal/Views/PaneContainerView.swift | 31 ++++++++++-- Tests/BonsplitTests/BonsplitTests.swift | 47 +++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/PaneContainerView.swift b/Sources/Bonsplit/Internal/Views/PaneContainerView.swift index e48f4756..1a00eccb 100644 --- a/Sources/Bonsplit/Internal/Views/PaneContainerView.swift +++ b/Sources/Bonsplit/Internal/Views/PaneContainerView.swift @@ -542,7 +542,11 @@ struct UnifiedPaneDropDelegate: DropDelegate { return false } } else if hasFileURL { - guard bonsplitController.onExternalFileDrop != nil || controller.onFileDrop != nil else { + guard Self.acceptsFileDrop( + zone: effectiveZone(for: info), + hasExternalFileDropHandler: bonsplitController.onExternalFileDrop != nil, + hasLegacyFileDropHandler: controller.onFileDrop != nil + ) else { return false } } @@ -558,6 +562,20 @@ struct UnifiedPaneDropDelegate: DropDelegate { return true } + static func acceptsFileDrop( + zone: DropZone, + hasExternalFileDropHandler: Bool, + hasLegacyFileDropHandler: Bool + ) -> Bool { + if hasExternalFileDropHandler { + return true + } + guard hasLegacyFileDropHandler else { + return false + } + return zone == .center + } + private func dropOperation(for info: DropInfo) -> DropOperation { info.hasItemsConforming(to: [.fileURL]) && !info.hasItemsConforming(to: [.tabTransfer]) ? .copy @@ -594,8 +612,15 @@ struct UnifiedPaneDropDelegate: DropDelegate { } private func fileURLs(from info: DropInfo) -> [URL] { - _ = info - let pasteboard = NSPasteboard(name: .drag) + guard info.hasItemsConforming(to: [.fileURL]) else { return [] } + + // DropInfo exposes file URLs through asynchronous item providers, but + // DropDelegate.performDrop needs a synchronous result. AppKit publishes + // Finder/local file drags on the drag pasteboard for this callback. + return Self.fileURLs(from: NSPasteboard(name: .drag)) + } + + static func fileURLs(from pasteboard: NSPasteboard) -> [URL] { let objects = pasteboard.readObjects( forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true] diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 602004f0..b095adbf 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -1302,6 +1302,53 @@ final class BonsplitTests: XCTestCase { XCTAssertFalse(controller.internalController.tabShortcutHintsEnabled) } + func testLegacyFileDropsOnlyValidateCenterZone() { + XCTAssertTrue( + UnifiedPaneDropDelegate.acceptsFileDrop( + zone: .center, + hasExternalFileDropHandler: false, + hasLegacyFileDropHandler: true + ) + ) + XCTAssertFalse( + UnifiedPaneDropDelegate.acceptsFileDrop( + zone: .left, + hasExternalFileDropHandler: false, + hasLegacyFileDropHandler: true + ) + ) + XCTAssertFalse( + UnifiedPaneDropDelegate.acceptsFileDrop( + zone: .center, + hasExternalFileDropHandler: false, + hasLegacyFileDropHandler: false + ) + ) + XCTAssertTrue( + UnifiedPaneDropDelegate.acceptsFileDrop( + zone: .right, + hasExternalFileDropHandler: true, + hasLegacyFileDropHandler: false + ) + ) + } + + func testFileURLPasteboardReaderReturnsFileURLs() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("bonsplit-file-drop-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let fileURL = directory.appendingPathComponent("sample.txt") + try "sample".write(to: fileURL, atomically: true, encoding: .utf8) + + let pasteboard = NSPasteboard(name: NSPasteboard.Name("bonsplit.file-drop.\(UUID().uuidString)")) + pasteboard.clearContents() + XCTAssertTrue(pasteboard.writeObjects([fileURL as NSURL])) + + XCTAssertEqual(UnifiedPaneDropDelegate.fileURLs(from: pasteboard), [fileURL]) + } + func testSelectedTabNeverShowsHoverBackground() { XCTAssertFalse( TabItemStyling.shouldShowHoverBackground(isHovered: true, isSelected: true) From 625c3703738a40c72948f76710e1578027466e34 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 30 Apr 2026 04:35:03 -0700 Subject: [PATCH 03/29] Add split button tab indicator regression test --- Tests/BonsplitTests/BonsplitTests.swift | 52 ++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 7ef29bb7..a0d653a8 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -1454,6 +1454,16 @@ final class BonsplitTests: XCTestCase { XCTAssertLessThan(unfocusedSaturation, 0.1) } + @MainActor + func testSplitButtonLaneDoesNotExposeSelectedTabIndicator() { + guard let saturation = renderedSplitButtonLaneTopSaturation() else { + XCTFail("Expected rendered split button lane colors") + return + } + + XCTAssertLessThan(saturation, 0.2) + } + func testTabBarSeparatorSegmentsClampGapIntoBounds() { var segments = TabBarStyling.separatorSegments(totalWidth: 100, gap: -20...40) XCTAssertEqual(segments.left, 0, accuracy: 0.0001) @@ -2067,6 +2077,42 @@ final class BonsplitTests: XCTestCase { } } + @MainActor + private func renderedSplitButtonLaneTopSaturation() -> CGFloat? { + let buttonCount = BonsplitConfiguration.SplitActionButton.defaults.count + let splitButtonLaneWidth = TabBarStyling.splitButtonsBackdropWidth(buttonCount: buttonCount) + let size = NSSize(width: 240, height: 28) + let appearance = BonsplitConfiguration.Appearance( + tabBarHeight: size.height, + splitButtonBackdropEffect: .default, + chromeColors: .init( + backgroundHex: "#111111", + tabBarBackgroundHex: "#181818", + splitButtonBackdropHex: "#242424", + borderHex: "#666666" + ) + ) + + return renderedTabBarValue( + isFocused: true, + appearance: appearance, + showSplitButtons: true, + size: size, + configurePane: { pane in + let selected = TabItem( + title: "selected tab title that reaches under the controls", + icon: nil + ) + pane.tabs = [selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + let laneStartX = size.width - splitButtonLaneWidth + let sampleRect = NSRect(x: laneStartX + 4, y: 0, width: 40, height: 4) + return maximumSaturation(in: hostingView, sampleRect: sampleRect) + } + } + @MainActor private func renderedSelectedTabLeftSeparatorAlphas() -> (top: CGFloat, bottom: CGFloat)? { let appearance = BonsplitConfiguration.Appearance( @@ -2123,6 +2169,8 @@ final class BonsplitTests: XCTestCase { private func renderedTabBarValue( isFocused: Bool, appearance: BonsplitConfiguration.Appearance = .default, + showSplitButtons: Bool = false, + size: NSSize? = nil, configurePane: ((PaneState) -> Void)? = nil, extract: (NSView) -> T? ) -> T? { @@ -2136,9 +2184,9 @@ final class BonsplitTests: XCTestCase { pane.selectedTabId = tab.id } - let size = NSSize(width: 160, height: TabBarMetrics.barHeight) + let size = size ?? NSSize(width: 160, height: TabBarMetrics.barHeight) let hostingView = NSHostingView( - rootView: TabBarView(pane: pane, isFocused: isFocused, showSplitButtons: false) + rootView: TabBarView(pane: pane, isFocused: isFocused, showSplitButtons: showSplitButtons) .environment(controller) .environment(controller.internalController) ) From e0227183de199edafbcae8fba28ad4ad8ea70c58 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 30 Apr 2026 04:37:21 -0700 Subject: [PATCH 04/29] Unify tab bar button lane layout --- .../Bonsplit/Internal/Views/TabBarView.swift | 122 ++++++++++++++---- 1 file changed, 99 insertions(+), 23 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 430f6517..5d5ad98f 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -276,6 +276,80 @@ enum TabBarStyling { } } +struct TabBarLayout: Equatable { + let barHeight: CGFloat + let splitButtonCount: Int + let splitButtonLaneVisible: Bool + let reservesSplitButtonLane: Bool + + init( + tabBarHeight: CGFloat, + splitButtonCount: Int, + splitButtonLaneVisible: Bool, + reservesSplitButtonLane: Bool + ) { + self.barHeight = max(1, tabBarHeight) + self.splitButtonCount = max(0, splitButtonCount) + self.splitButtonLaneVisible = splitButtonLaneVisible + self.reservesSplitButtonLane = reservesSplitButtonLane + } + + var fullSplitButtonLaneWidth: CGFloat { + TabBarStyling.splitButtonsBackdropWidth(buttonCount: splitButtonCount) + } + + var visibleSplitButtonLaneWidth: CGFloat { + splitButtonLaneVisible ? fullSplitButtonLaneWidth : 0 + } + + var trailingTabContentInset: CGFloat { + reservesSplitButtonLane ? fullSplitButtonLaneWidth : 0 + } + + var splitActionButtonWidth: CGFloat { + TabBarStyling.splitActionButtonReservedWidth + } + + var splitActionButtonHeight: CGFloat { + barHeight + } + + func selectedSeparatorGap( + selectedTabFrame: CGRect?, + totalWidth: CGFloat + ) -> ClosedRange? { + guard let selectedTabFrame, totalWidth > 0 else { return nil } + + let visibleContentMaxX = max(0, totalWidth - visibleSplitButtonLaneWidth) + let minX = min(max(selectedTabFrame.minX, 0), visibleContentMaxX) + let maxX = min(max(selectedTabFrame.maxX, 0), visibleContentMaxX) + guard maxX > minX else { return nil } + return minX...maxX + } + + func selectedIndicatorFrame( + selectedTabFrame: CGRect?, + totalWidth: CGFloat + ) -> CGRect? { + guard let gap = selectedSeparatorGap( + selectedTabFrame: selectedTabFrame, + totalWidth: totalWidth + ) else { return nil } + + let minX = gap.lowerBound + let maxX = gap.upperBound + let width = max(0, maxX - minX - TabBarMetrics.activeIndicatorTrailingInset) + guard width > 0 else { return nil } + + return CGRect( + x: minX, + y: 0, + width: width, + height: TabBarMetrics.activeIndicatorHeight + ) + } +} + struct TabContextMenuState { let isPinned: Bool let isUnread: Bool @@ -349,7 +423,16 @@ struct TabBarView: View { } private var tabBarHeight: CGFloat { - max(1, appearance.tabBarHeight) + tabBarLayout.barHeight + } + + private var tabBarLayout: TabBarLayout { + TabBarLayout( + tabBarHeight: appearance.tabBarHeight, + splitButtonCount: visibleSplitButtons.count, + splitButtonLaneVisible: shouldShowSplitButtons, + reservesSplitButtonLane: showSplitButtons && !isMinimalMode + ) } private var visibleSplitButtons: [BonsplitConfiguration.SplitActionButton] { @@ -397,7 +480,7 @@ struct TabBarView: View { } private var splitButtonsBackdropWidth: CGFloat { - TabBarStyling.splitButtonsBackdropWidth(buttonCount: visibleSplitButtons.count) + tabBarLayout.fullSplitButtonLaneWidth } private var splitButtonBackdropFadeWidth: CGFloat { @@ -429,11 +512,7 @@ struct TabBarView: View { } private var trailingTabContentInset: CGFloat { - TabBarStyling.trailingTabContentInset( - showSplitButtons: showSplitButtons, - isMinimalMode: isMinimalMode, - buttonCount: visibleSplitButtons.count - ) + tabBarLayout.trailingTabContentInset } private var leadingScrollAnchorId: String { @@ -590,8 +669,7 @@ struct TabBarView: View { if shouldRenderSplitButtons { splitButtons .saturation(tabBarSaturation) - .padding(.bottom, 1) - .frame(width: splitButtonsBackdropWidth, alignment: .trailing) + .frame(width: splitButtonsBackdropWidth, height: tabBarHeight, alignment: .trailing) .opacity(shouldShowSplitButtons ? 1 : 0) .allowsHitTesting(shouldShowSplitButtons) .animation(.easeInOut(duration: 0.14), value: shouldShowSplitButtons) @@ -920,13 +998,14 @@ struct TabBarView: View { } label: { splitActionButtonIcon(button.icon) } - .buttonStyle(SplitActionButtonStyle(appearance: appearance)) + .buttonStyle(SplitActionButtonStyle(appearance: appearance, layout: tabBarLayout)) .accessibilityIdentifier(splitActionButtonAccessibilityIdentifier(button)) .safeHelp(splitActionButtonTooltip(button, tooltips: tooltips)) } } .padding(.leading, TabBarStyling.splitButtonsLeadingPadding) .padding(.trailing, TabBarStyling.splitButtonsTrailingPadding) + .frame(height: tabBarHeight, alignment: .center) } private func splitActionButtonAccessibilityIdentifier(_ button: BonsplitConfiguration.SplitActionButton) -> String { @@ -1253,9 +1332,10 @@ struct TabBarView: View { Spacer(minLength: 0) HStack(spacing: 0) { let separator = TabBarColors.separator(for: appearance) - let gapRange: ClosedRange? = selectedTabFrameInBar.map { frame in - frame.minX...frame.maxX - } + let gapRange = tabBarLayout.selectedSeparatorGap( + selectedTabFrame: selectedTabFrameInBar, + totalWidth: totalWidth + ) let segments = TabBarStyling.separatorSegments( totalWidth: totalWidth, gap: gapRange @@ -1272,16 +1352,9 @@ struct TabBarView: View { } private func selectedIndicatorFrame(totalWidth: CGFloat) -> CGRect? { - guard let selectedTabFrameInBar, totalWidth > 0 else { return nil } - let minX = min(max(selectedTabFrameInBar.minX, 0), totalWidth) - let maxX = min(max(selectedTabFrameInBar.maxX, 0), totalWidth) - let width = max(0, maxX - minX - TabBarMetrics.activeIndicatorTrailingInset) - guard width > 0 else { return nil } - return CGRect( - x: minX, - y: 0, - width: width, - height: TabBarMetrics.activeIndicatorHeight + tabBarLayout.selectedIndicatorFrame( + selectedTabFrame: selectedTabFrameInBar, + totalWidth: totalWidth ) } } @@ -1371,9 +1444,12 @@ private final class SplitActionButtonImageCache { private struct SplitActionButtonStyle: ButtonStyle { let appearance: BonsplitConfiguration.Appearance + let layout: TabBarLayout func makeBody(configuration: Configuration) -> some View { configuration.label + .frame(width: layout.splitActionButtonWidth, height: layout.splitActionButtonHeight) + .contentShape(Rectangle()) .foregroundStyle(TabBarColors.splitActionIcon(for: appearance, isPressed: configuration.isPressed)) .opacity(configuration.isPressed ? 0.72 : 1.0) .animation(.easeOut(duration: 0.08), value: configuration.isPressed) From 77a6f8ce8bbaad031a029d54687431db8c4db931 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 30 Apr 2026 05:00:57 -0700 Subject: [PATCH 05/29] Preserve split action button horizontal sizing --- .../Bonsplit/Internal/Views/TabBarView.swift | 46 ++++++++++++--- Tests/BonsplitTests/BonsplitTests.swift | 56 +++++++++++++++++++ 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 5d5ad98f..bd05f8b3 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -65,6 +65,25 @@ private struct TabFramePreferenceKey: PreferenceKey { } } +private struct SplitButtonLaneWidthPreferenceKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +private struct SplitButtonLaneWidthReader: View { + var body: some View { + GeometryReader { geometry in + Color.clear.preference( + key: SplitButtonLaneWidthPreferenceKey.self, + value: geometry.size.width + ) + } + } +} + @MainActor private final class TabBarScrollViewBridge: ObservableObject { private struct ScrollMetrics { @@ -281,23 +300,32 @@ struct TabBarLayout: Equatable { let splitButtonCount: Int let splitButtonLaneVisible: Bool let reservesSplitButtonLane: Bool + let measuredSplitButtonLaneWidth: CGFloat init( tabBarHeight: CGFloat, splitButtonCount: Int, splitButtonLaneVisible: Bool, - reservesSplitButtonLane: Bool + reservesSplitButtonLane: Bool, + measuredSplitButtonLaneWidth: CGFloat = 0 ) { self.barHeight = max(1, tabBarHeight) self.splitButtonCount = max(0, splitButtonCount) self.splitButtonLaneVisible = splitButtonLaneVisible self.reservesSplitButtonLane = reservesSplitButtonLane + self.measuredSplitButtonLaneWidth = self.splitButtonCount > 0 + ? max(0, measuredSplitButtonLaneWidth) + : 0 } - var fullSplitButtonLaneWidth: CGFloat { + var minimumSplitButtonLaneWidth: CGFloat { TabBarStyling.splitButtonsBackdropWidth(buttonCount: splitButtonCount) } + var fullSplitButtonLaneWidth: CGFloat { + max(minimumSplitButtonLaneWidth, measuredSplitButtonLaneWidth) + } + var visibleSplitButtonLaneWidth: CGFloat { splitButtonLaneVisible ? fullSplitButtonLaneWidth : 0 } @@ -306,10 +334,6 @@ struct TabBarLayout: Equatable { reservesSplitButtonLane ? fullSplitButtonLaneWidth : 0 } - var splitActionButtonWidth: CGFloat { - TabBarStyling.splitActionButtonReservedWidth - } - var splitActionButtonHeight: CGFloat { barHeight } @@ -395,6 +419,7 @@ struct TabBarView: View { @State private var containerWidth: CGFloat = 0 @State private var selectedTabFrameInBar: CGRect? @State private var tabFramesInBar: [UUID: CGRect] = [:] + @State private var measuredSplitButtonLaneWidth: CGFloat = 0 @StateObject private var controlKeyMonitor = TabControlShortcutKeyMonitor() @StateObject private var scrollViewBridge = TabBarScrollViewBridge() @@ -431,7 +456,8 @@ struct TabBarView: View { tabBarHeight: appearance.tabBarHeight, splitButtonCount: visibleSplitButtons.count, splitButtonLaneVisible: shouldShowSplitButtons, - reservesSplitButtonLane: showSplitButtons && !isMinimalMode + reservesSplitButtonLane: showSplitButtons && !isMinimalMode, + measuredSplitButtonLaneWidth: measuredSplitButtonLaneWidth ) } @@ -669,6 +695,7 @@ struct TabBarView: View { if shouldRenderSplitButtons { splitButtons .saturation(tabBarSaturation) + .background(SplitButtonLaneWidthReader()) .frame(width: splitButtonsBackdropWidth, height: tabBarHeight, alignment: .trailing) .opacity(shouldShowSplitButtons ? 1 : 0) .allowsHitTesting(shouldShowSplitButtons) @@ -730,6 +757,9 @@ struct TabBarView: View { .onPreferenceChange(TabFramePreferenceKey.self) { frames in tabFramesInBar = frames } + .onPreferenceChange(SplitButtonLaneWidthPreferenceKey.self) { width in + measuredSplitButtonLaneWidth = width + } .onDisappear { controlKeyMonitor.stop() } @@ -1448,7 +1478,7 @@ private struct SplitActionButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label - .frame(width: layout.splitActionButtonWidth, height: layout.splitActionButtonHeight) + .frame(height: layout.splitActionButtonHeight) .contentShape(Rectangle()) .foregroundStyle(TabBarColors.splitActionIcon(for: appearance, isPressed: configuration.isPressed)) .opacity(configuration.isPressed ? 0.72 : 1.0) diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index a0d653a8..08a41c75 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -372,6 +372,62 @@ final class BonsplitTests: XCTestCase { ) } + func testTabBarLayoutKeepsDefaultSplitButtonLaneWidthAsMinimum() { + let compactMeasuredWidth = + TabBarStyling.splitButtonsLeadingPadding + + TabBarStyling.splitButtonsTrailingPadding + + (4 * CGFloat(14)) + + (3 * TabBarStyling.splitButtonsSpacing) + let layout = TabBarLayout( + tabBarHeight: 28, + splitButtonCount: 4, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: compactMeasuredWidth + ) + + XCTAssertEqual( + layout.fullSplitButtonLaneWidth, + TabBarStyling.splitButtonsBackdropWidth(buttonCount: 4) + ) + } + + func testTabBarLayoutExpandsForMeasuredSplitButtonLaneWidth() { + let layout = TabBarLayout( + tabBarHeight: 28, + splitButtonCount: 4, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: 160 + ) + + XCTAssertEqual(layout.fullSplitButtonLaneWidth, 160) + XCTAssertEqual(layout.trailingTabContentInset, 160) + let indicatorFrame = layout.selectedIndicatorFrame( + selectedTabFrame: CGRect(x: 0, y: 0, width: 240, height: 28), + totalWidth: 240 + ) + XCTAssertNotNil(indicatorFrame) + XCTAssertEqual( + indicatorFrame?.maxX ?? 0, + 79, + accuracy: 0.001 + ) + } + + func testTabBarLayoutIgnoresMeasuredSplitButtonLaneWidthWithoutButtons() { + let layout = TabBarLayout( + tabBarHeight: 28, + splitButtonCount: 0, + splitButtonLaneVisible: false, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: 160 + ) + + XCTAssertEqual(layout.fullSplitButtonLaneWidth, 0) + XCTAssertEqual(layout.trailingTabContentInset, 0) + } + func testTabBarKeepsNonOverflowingTabsLeadingAligned() { let tabId = UUID() From f462bf5dd0c2d656507a7109583da041b1b60637 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 30 Apr 2026 05:06:21 -0700 Subject: [PATCH 06/29] Make tab bar fade full height --- .../Bonsplit/Internal/Views/TabBarView.swift | 22 +++++--- Tests/BonsplitTests/BonsplitTests.swift | 54 +++++++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index bd05f8b3..aa69376c 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -1252,24 +1252,26 @@ struct TabBarView: View { HStack(spacing: 0) { // Left scroll fade LinearGradient(colors: [.clear, .black], startPoint: .leading, endPoint: .trailing) - .frame(width: canScrollLeft ? fadeWidth : 0) + .frame(width: canScrollLeft ? fadeWidth : 0, height: tabBarHeight) // Visible content area (always opaque so hit testing reaches the tabs) Rectangle().fill(Color.black) + .frame(height: tabBarHeight) if shouldMaskTabsUnderSplitButtonBackdrop { LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing) - .frame(width: splitButtonContentFadeWidth) + .frame(width: splitButtonContentFadeWidth, height: tabBarHeight) // Content is already fully faded before the backdrop ramp starts. This keeps the // beginning of a transparent backdrop fade from blending over bright tab text. Color.clear - .frame(width: splitButtonContentOcclusionWidth) + .frame(width: splitButtonContentOcclusionWidth, height: tabBarHeight) } else { // Right scroll fade only when scroll content actually overflows. LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing) - .frame(width: canScrollRight ? fadeWidth : 0) + .frame(width: canScrollRight ? fadeWidth : 0, height: tabBarHeight) } } + .frame(height: tabBarHeight) } // MARK: - Fade Overlays @@ -1282,13 +1284,15 @@ struct TabBarView: View { let fadeWidth: CGFloat = 24 HStack(spacing: 0) { LinearGradient(colors: [.clear, .black], startPoint: .leading, endPoint: .trailing) - .frame(width: canScrollLeft ? fadeWidth : 0) + .frame(width: canScrollLeft ? fadeWidth : 0, height: tabBarHeight) Rectangle().fill(Color.black) + .frame(height: tabBarHeight) LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing) - .frame(width: canScrollRight ? fadeWidth : 0) + .frame(width: canScrollRight ? fadeWidth : 0, height: tabBarHeight) } + .frame(height: tabBarHeight) } // MARK: - Background @@ -1302,6 +1306,7 @@ struct TabBarView: View { HStack(spacing: 0) { TabBarLayerBackedColor(color: barColor) .frame(maxWidth: .infinity) + .frame(height: tabBarHeight) if shouldPaintSplitButtonBackdrop { let effect = splitButtonBackdropEffect let targetColor = Self.buttonBackdropColor( @@ -1327,12 +1332,13 @@ struct TabBarView: View { startPoint: .leading, endPoint: .trailing ) - .frame(width: splitButtonBackdropFadeWidth) + .frame(width: splitButtonBackdropFadeWidth, height: tabBarHeight) } TabBarLayerBackedColor(color: colors.trailing) - .frame(width: splitButtonBackdropSolidWidth) + .frame(width: splitButtonBackdropSolidWidth, height: tabBarHeight) } } + .frame(height: tabBarHeight) .overlay { GeometryReader { geometry in // Bar-edge chrome stays in tab-bar coordinates so item content diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 08a41c75..29166087 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -1520,6 +1520,16 @@ final class BonsplitTests: XCTestCase { XCTAssertLessThan(saturation, 0.2) } + @MainActor + func testSplitButtonBackdropFadePaintsFullTabBarHeight() { + guard let delta = renderedSplitButtonBackdropFadeVerticalColorDelta() else { + XCTFail("Expected rendered split button backdrop fade colors") + return + } + + XCTAssertLessThan(delta, 0.08) + } + func testTabBarSeparatorSegmentsClampGapIntoBounds() { var segments = TabBarStyling.separatorSegments(totalWidth: 100, gap: -20...40) XCTAssertEqual(segments.left, 0, accuracy: 0.0001) @@ -2169,6 +2179,50 @@ final class BonsplitTests: XCTestCase { } } + @MainActor + private func renderedSplitButtonBackdropFadeVerticalColorDelta() -> CGFloat? { + let size = NSSize(width: 360, height: 28) + let appearance = BonsplitConfiguration.Appearance( + tabBarHeight: size.height, + splitButtonBackdropEffect: .default, + chromeColors: .init( + backgroundHex: "#000000", + tabBarBackgroundHex: "#000000", + splitButtonBackdropHex: "#FFFFFF", + borderHex: "#00000000" + ) + ) + + return renderedTabBarValue( + isFocused: true, + appearance: appearance, + showSplitButtons: true, + size: size, + configurePane: { pane in + let selected = TabItem(title: "", icon: nil) + pane.tabs = [selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + let sampleX = size.width - 124 + guard let top = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: sampleX, y: 6) + )?.usingColorSpace(.sRGB), + let bottom = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: sampleX, y: size.height - 6) + )?.usingColorSpace(.sRGB) else { + return nil + } + + return abs(top.redComponent - bottom.redComponent) + + abs(top.greenComponent - bottom.greenComponent) + + abs(top.blueComponent - bottom.blueComponent) + + abs(top.alphaComponent - bottom.alphaComponent) + } + } + @MainActor private func renderedSelectedTabLeftSeparatorAlphas() -> (top: CGFloat, bottom: CGFloat)? { let appearance = BonsplitConfiguration.Appearance( From e625083879591331a24eef022956ef2ebcb62a0e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 30 Apr 2026 05:24:51 -0700 Subject: [PATCH 07/29] Share tab bar chrome fade mask --- .../Bonsplit/Internal/Views/TabBarView.swift | 68 +++++++--------- Tests/BonsplitTests/BonsplitTests.swift | 77 ++++++++++++++++++- 2 files changed, 102 insertions(+), 43 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index aa69376c..423b9f55 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -326,10 +326,6 @@ struct TabBarLayout: Equatable { max(minimumSplitButtonLaneWidth, measuredSplitButtonLaneWidth) } - var visibleSplitButtonLaneWidth: CGFloat { - splitButtonLaneVisible ? fullSplitButtonLaneWidth : 0 - } - var trailingTabContentInset: CGFloat { reservesSplitButtonLane ? fullSplitButtonLaneWidth : 0 } @@ -344,9 +340,8 @@ struct TabBarLayout: Equatable { ) -> ClosedRange? { guard let selectedTabFrame, totalWidth > 0 else { return nil } - let visibleContentMaxX = max(0, totalWidth - visibleSplitButtonLaneWidth) - let minX = min(max(selectedTabFrame.minX, 0), visibleContentMaxX) - let maxX = min(max(selectedTabFrame.maxX, 0), visibleContentMaxX) + let minX = min(max(selectedTabFrame.minX, 0), totalWidth) + let maxX = min(max(selectedTabFrame.maxX, 0), totalWidth) guard maxX > minX else { return nil } return minX...maxX } @@ -689,7 +684,7 @@ struct TabBarView: View { .frame(height: tabBarHeight) .mask(combinedMask) // Split buttons sit on top of the tab strip. Their backing surface is - // painted by `tabBarBackground` so translucent colors are composited once, + // painted by `tabBarSurface` so translucent colors are composited once, // while `combinedMask` fades overflowing tab content out below them. .overlay(alignment: .trailing) { if shouldRenderSplitButtons { @@ -707,7 +702,8 @@ struct TabBarView: View { .frame(maxWidth: .infinity, alignment: .leading) .frame(height: tabBarHeight) .coordinateSpace(name: "tabBar") - .background(tabBarBackground) + .background(tabBarSurface) + .overlay(maskedTabBarChrome) .background(TabBarDragAndHoverView( isMinimalMode: isMinimalMode, onDoubleClick: { @@ -1274,31 +1270,10 @@ struct TabBarView: View { .frame(height: tabBarHeight) } - // MARK: - Fade Overlays + // MARK: - Chrome - /// Mask that fades scroll content at the edges instead of overlaying - /// a colored gradient. The mask uses black (visible) → clear (hidden), - /// so the tab bar background shows through naturally with no compositing. @ViewBuilder - private var fadeOverlays: some View { - let fadeWidth: CGFloat = 24 - HStack(spacing: 0) { - LinearGradient(colors: [.clear, .black], startPoint: .leading, endPoint: .trailing) - .frame(width: canScrollLeft ? fadeWidth : 0, height: tabBarHeight) - - Rectangle().fill(Color.black) - .frame(height: tabBarHeight) - - LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing) - .frame(width: canScrollRight ? fadeWidth : 0, height: tabBarHeight) - } - .frame(height: tabBarHeight) - } - - // MARK: - Background - - @ViewBuilder - private var tabBarBackground: some View { + private var tabBarSurface: some View { let baseBarColor = TabBarColors.nsColorBarBackground(for: appearance) let barColor = appearance.usesSharedBackdrop || isFocused ? baseBarColor @@ -1339,17 +1314,26 @@ struct TabBarView: View { } } .frame(height: tabBarHeight) - .overlay { - GeometryReader { geometry in - // Bar-edge chrome stays in tab-bar coordinates so item content - // offsets and padding cannot move it. - ZStack(alignment: .topLeading) { - selectedTabIndicator(totalWidth: geometry.size.width) - tabBarBottomSeparator(totalWidth: geometry.size.width) - } - } - .allowsHitTesting(false) + } + + @ViewBuilder + private var maskedTabBarChrome: some View { + GeometryReader { geometry in + tabBarChrome(totalWidth: geometry.size.width) + .mask(combinedMask) } + .allowsHitTesting(false) + } + + @ViewBuilder + private func tabBarChrome(totalWidth: CGFloat) -> some View { + // Bar-edge chrome uses the same mask as scroll content, so selected + // indicators, separators, and tab titles fade together under controls. + ZStack(alignment: .topLeading) { + selectedTabIndicator(totalWidth: totalWidth) + tabBarBottomSeparator(totalWidth: totalWidth) + } + .frame(height: tabBarHeight) } @ViewBuilder diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 29166087..d0671d5c 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -403,6 +403,16 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(layout.fullSplitButtonLaneWidth, 160) XCTAssertEqual(layout.trailingTabContentInset, 160) + } + + func testTabBarLayoutDoesNotHardClipSelectedChromeAtSplitButtonLane() { + let layout = TabBarLayout( + tabBarHeight: 28, + splitButtonCount: 4, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: 160 + ) let indicatorFrame = layout.selectedIndicatorFrame( selectedTabFrame: CGRect(x: 0, y: 0, width: 240, height: 28), totalWidth: 240 @@ -410,7 +420,7 @@ final class BonsplitTests: XCTestCase { XCTAssertNotNil(indicatorFrame) XCTAssertEqual( indicatorFrame?.maxX ?? 0, - 79, + 239, accuracy: 0.001 ) } @@ -1530,6 +1540,17 @@ final class BonsplitTests: XCTestCase { XCTAssertLessThan(delta, 0.08) } + @MainActor + func testSelectedTabIndicatorFadesIntoSplitButtonLane() { + guard let brightnesses = renderedSelectedIndicatorFadeBrightnesses() else { + XCTFail("Expected rendered selected indicator fade colors") + return + } + + XCTAssertGreaterThan(brightnesses.leading, 0.15) + XCTAssertLessThan(brightnesses.trailing, brightnesses.leading - 0.08) + } + func testTabBarSeparatorSegmentsClampGapIntoBounds() { var segments = TabBarStyling.separatorSegments(totalWidth: 100, gap: -20...40) XCTAssertEqual(segments.left, 0, accuracy: 0.0001) @@ -2223,6 +2244,56 @@ final class BonsplitTests: XCTestCase { } } + @MainActor + private func renderedSelectedIndicatorFadeBrightnesses() -> (leading: CGFloat, trailing: CGFloat)? { + let buttonCount = BonsplitConfiguration.SplitActionButton.defaults.count + let splitButtonLaneWidth = TabBarStyling.splitButtonsBackdropWidth(buttonCount: buttonCount) + let fadeWidth = BonsplitConfiguration.Appearance.SplitButtonBackdropEffect.default.contentFadeWidth + let size = NSSize(width: 240, height: 28) + let appearance = BonsplitConfiguration.Appearance( + tabBarHeight: size.height, + splitButtonBackdropEffect: .default, + chromeColors: .init( + backgroundHex: "#000000", + tabBarBackgroundHex: "#000000", + splitButtonBackdropHex: "#000000", + borderHex: "#00000000" + ) + ) + + return renderedTabBarValue( + isFocused: true, + appearance: appearance, + showSplitButtons: true, + size: size, + configurePane: { pane in + let selected = TabItem( + title: "selected tab title that reaches under the controls", + icon: nil + ) + pane.tabs = [selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + let laneStartX = size.width - splitButtonLaneWidth + guard let leading = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: laneStartX - fadeWidth + 4, y: 0) + )?.usingColorSpace(.sRGB), + let trailing = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: laneStartX - 4, y: 0) + )?.usingColorSpace(.sRGB) else { + return nil + } + + return ( + leading: brightness(of: leading), + trailing: brightness(of: trailing) + ) + } + } + @MainActor private func renderedSelectedTabLeftSeparatorAlphas() -> (top: CGFloat, bottom: CGFloat)? { let appearance = BonsplitConfiguration.Appearance( @@ -2359,6 +2430,10 @@ final class BonsplitTests: XCTestCase { return maximum } + private func brightness(of color: NSColor) -> CGFloat { + max(color.redComponent, color.greenComponent, color.blueComponent) + } + @MainActor private func highSaturationWidth(in view: NSView, sampleRect: NSRect) -> CGFloat? { let integralBounds = view.bounds.integral From 7aef42cd10551d4f5b27828c4372fd0975272b8c Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 30 Apr 2026 17:02:09 -0700 Subject: [PATCH 08/29] Cap split action lane width --- .../Bonsplit/Internal/Views/TabBarView.swift | 32 ++++++++++-- Tests/BonsplitTests/BonsplitTests.swift | 49 +++++++++++++++++-- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 423b9f55..6b40f3cc 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -193,6 +193,7 @@ private final class TabBarScrollViewBridge: ObservableObject { } enum TabBarStyling { + static let maximumSplitButtonLaneWidthFraction: CGFloat = 0.25 static let splitActionButtonReservedWidth: CGFloat = 22 static let splitButtonsSpacing: CGFloat = 4 static let splitButtonsLeadingPadding: CGFloat = 6 @@ -297,6 +298,7 @@ enum TabBarStyling { struct TabBarLayout: Equatable { let barHeight: CGFloat + let availableWidth: CGFloat let splitButtonCount: Int let splitButtonLaneVisible: Bool let reservesSplitButtonLane: Bool @@ -304,12 +306,14 @@ struct TabBarLayout: Equatable { init( tabBarHeight: CGFloat, + availableWidth: CGFloat = 0, splitButtonCount: Int, splitButtonLaneVisible: Bool, reservesSplitButtonLane: Bool, measuredSplitButtonLaneWidth: CGFloat = 0 ) { self.barHeight = max(1, tabBarHeight) + self.availableWidth = max(0, availableWidth) self.splitButtonCount = max(0, splitButtonCount) self.splitButtonLaneVisible = splitButtonLaneVisible self.reservesSplitButtonLane = reservesSplitButtonLane @@ -326,8 +330,17 @@ struct TabBarLayout: Equatable { max(minimumSplitButtonLaneWidth, measuredSplitButtonLaneWidth) } + var maximumSplitButtonLaneWidth: CGFloat { + guard availableWidth > 0 else { return fullSplitButtonLaneWidth } + return availableWidth * TabBarStyling.maximumSplitButtonLaneWidthFraction + } + + var visibleSplitButtonLaneWidth: CGFloat { + min(fullSplitButtonLaneWidth, maximumSplitButtonLaneWidth) + } + var trailingTabContentInset: CGFloat { - reservesSplitButtonLane ? fullSplitButtonLaneWidth : 0 + reservesSplitButtonLane ? visibleSplitButtonLaneWidth : 0 } var splitActionButtonHeight: CGFloat { @@ -449,6 +462,7 @@ struct TabBarView: View { private var tabBarLayout: TabBarLayout { TabBarLayout( tabBarHeight: appearance.tabBarHeight, + availableWidth: containerWidth, splitButtonCount: visibleSplitButtons.count, splitButtonLaneVisible: shouldShowSplitButtons, reservesSplitButtonLane: showSplitButtons && !isMinimalMode, @@ -501,7 +515,7 @@ struct TabBarView: View { } private var splitButtonsBackdropWidth: CGFloat { - tabBarLayout.fullSplitButtonLaneWidth + tabBarLayout.visibleSplitButtonLaneWidth } private var splitButtonBackdropFadeWidth: CGFloat { @@ -690,8 +704,6 @@ struct TabBarView: View { if shouldRenderSplitButtons { splitButtons .saturation(tabBarSaturation) - .background(SplitButtonLaneWidthReader()) - .frame(width: splitButtonsBackdropWidth, height: tabBarHeight, alignment: .trailing) .opacity(shouldShowSplitButtons ? 1 : 0) .allowsHitTesting(shouldShowSplitButtons) .animation(.easeInOut(duration: 0.14), value: shouldShowSplitButtons) @@ -1014,6 +1026,18 @@ struct TabBarView: View { @ViewBuilder private var splitButtons: some View { + ScrollView(.horizontal, showsIndicators: false) { + splitButtonRow + .fixedSize(horizontal: true, vertical: false) + .frame(minWidth: splitButtonsBackdropWidth, alignment: .trailing) + .background(SplitButtonLaneWidthReader()) + } + .frame(width: splitButtonsBackdropWidth, height: tabBarHeight, alignment: .trailing) + .clipped() + } + + @ViewBuilder + private var splitButtonRow: some View { let tooltips = controller.configuration.appearance.splitButtonTooltips let buttons = visibleSplitButtons HStack(spacing: TabBarStyling.splitButtonsSpacing) { diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index d0671d5c..89b724a4 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -405,6 +405,38 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(layout.trailingTabContentInset, 160) } + func testTabBarLayoutCapsSplitButtonLaneToQuarterOfAvailableWidth() { + let layout = TabBarLayout( + tabBarHeight: 28, + availableWidth: 240, + splitButtonCount: 12, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: 400 + ) + + XCTAssertEqual(layout.fullSplitButtonLaneWidth, 400) + XCTAssertEqual(layout.maximumSplitButtonLaneWidth, 60) + XCTAssertEqual(layout.visibleSplitButtonLaneWidth, 60) + XCTAssertEqual(layout.trailingTabContentInset, 60) + } + + func testTabBarLayoutKeepsMeasuredLaneWhenItFitsQuarterOfAvailableWidth() { + let layout = TabBarLayout( + tabBarHeight: 28, + availableWidth: 800, + splitButtonCount: 4, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: 160 + ) + + XCTAssertEqual(layout.fullSplitButtonLaneWidth, 160) + XCTAssertEqual(layout.maximumSplitButtonLaneWidth, 200) + XCTAssertEqual(layout.visibleSplitButtonLaneWidth, 160) + XCTAssertEqual(layout.trailingTabContentInset, 160) + } + func testTabBarLayoutDoesNotHardClipSelectedChromeAtSplitButtonLane() { let layout = TabBarLayout( tabBarHeight: 28, @@ -2167,8 +2199,8 @@ final class BonsplitTests: XCTestCase { @MainActor private func renderedSplitButtonLaneTopSaturation() -> CGFloat? { let buttonCount = BonsplitConfiguration.SplitActionButton.defaults.count - let splitButtonLaneWidth = TabBarStyling.splitButtonsBackdropWidth(buttonCount: buttonCount) let size = NSSize(width: 240, height: 28) + let splitButtonLaneWidth = visibleSplitButtonLaneWidth(size: size, buttonCount: buttonCount) let appearance = BonsplitConfiguration.Appearance( tabBarHeight: size.height, splitButtonBackdropEffect: .default, @@ -2247,9 +2279,9 @@ final class BonsplitTests: XCTestCase { @MainActor private func renderedSelectedIndicatorFadeBrightnesses() -> (leading: CGFloat, trailing: CGFloat)? { let buttonCount = BonsplitConfiguration.SplitActionButton.defaults.count - let splitButtonLaneWidth = TabBarStyling.splitButtonsBackdropWidth(buttonCount: buttonCount) - let fadeWidth = BonsplitConfiguration.Appearance.SplitButtonBackdropEffect.default.contentFadeWidth let size = NSSize(width: 240, height: 28) + let splitButtonLaneWidth = visibleSplitButtonLaneWidth(size: size, buttonCount: buttonCount) + let fadeWidth = BonsplitConfiguration.Appearance.SplitButtonBackdropEffect.default.contentFadeWidth let appearance = BonsplitConfiguration.Appearance( tabBarHeight: size.height, splitButtonBackdropEffect: .default, @@ -2294,6 +2326,17 @@ final class BonsplitTests: XCTestCase { } } + private func visibleSplitButtonLaneWidth(size: NSSize, buttonCount: Int) -> CGFloat { + TabBarLayout( + tabBarHeight: size.height, + availableWidth: size.width, + splitButtonCount: buttonCount, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: TabBarStyling.splitButtonsBackdropWidth(buttonCount: buttonCount) + ).visibleSplitButtonLaneWidth + } + @MainActor private func renderedSelectedTabLeftSeparatorAlphas() -> (top: CGFloat, bottom: CGFloat)? { let appearance = BonsplitConfiguration.Appearance( From bfaad67526b9b9108aa012bb9e4017e2722b6903 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 30 Apr 2026 17:08:34 -0700 Subject: [PATCH 09/29] Extend split action backdrop and scroll fades --- .../Bonsplit/Internal/Views/TabBarView.swift | 89 ++++++++++++++++- Tests/BonsplitTests/BonsplitTests.swift | 99 +++++++++++++++++++ 2 files changed, 187 insertions(+), 1 deletion(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 6b40f3cc..51d1a2c1 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -194,6 +194,7 @@ private final class TabBarScrollViewBridge: ObservableObject { enum TabBarStyling { static let maximumSplitButtonLaneWidthFraction: CGFloat = 0.25 + static let splitButtonScrollFadeWidth: CGFloat = 12 static let splitActionButtonReservedWidth: CGFloat = 22 static let splitButtonsSpacing: CGFloat = 4 static let splitButtonsLeadingPadding: CGFloat = 6 @@ -211,6 +212,26 @@ enum TabBarStyling { + (CGFloat(max(0, buttonCount - 1)) * splitButtonsSpacing) } + static func splitButtonBackdropSolidSurfaceWidth( + effectSolidWidth: CGFloat, + visibleLaneWidth: CGFloat + ) -> CGFloat { + max(max(0, effectSolidWidth), max(0, visibleLaneWidth)) + } + + static func splitButtonScrollAffordances( + scrollOffset: CGFloat, + contentWidth: CGFloat, + viewportWidth: CGFloat + ) -> (left: Bool, right: Bool) { + let overflowThreshold: CGFloat = 1 + let maxOffset = max(0, contentWidth - viewportWidth) + return ( + left: scrollOffset > overflowThreshold, + right: scrollOffset < maxOffset - overflowThreshold + ) + } + static func imageDataShouldRenderAsTemplate(_ data: Data) -> Bool { let text = String(decoding: data.prefix(4096), as: UTF8.self) let lowercased = text.lowercased() @@ -428,6 +449,9 @@ struct TabBarView: View { @State private var selectedTabFrameInBar: CGRect? @State private var tabFramesInBar: [UUID: CGRect] = [:] @State private var measuredSplitButtonLaneWidth: CGFloat = 0 + @State private var splitButtonScrollOffset: CGFloat = 0 + @State private var splitButtonContentWidth: CGFloat = 0 + @State private var splitButtonViewportWidth: CGFloat = 0 @StateObject private var controlKeyMonitor = TabControlShortcutKeyMonitor() @StateObject private var scrollViewBridge = TabBarScrollViewBridge() @@ -523,7 +547,10 @@ struct TabBarView: View { } private var splitButtonBackdropSolidWidth: CGFloat { - max(0, splitButtonBackdropEffect.solidWidth) + TabBarStyling.splitButtonBackdropSolidSurfaceWidth( + effectSolidWidth: splitButtonBackdropEffect.solidWidth, + visibleLaneWidth: splitButtonsBackdropWidth + ) } private var splitButtonBackdropFadeRampStartFraction: CGFloat { @@ -554,6 +581,18 @@ struct TabBarView: View { "tab-bar-leading-\(pane.id.id.uuidString)" } + private var splitButtonScrollCoordinateSpaceName: String { + "split-button-scroll-\(pane.id.id.uuidString)" + } + + private var splitButtonScrollAffordances: (left: Bool, right: Bool) { + TabBarStyling.splitButtonScrollAffordances( + scrollOffset: splitButtonScrollOffset, + contentWidth: splitButtonContentWidth, + viewportWidth: splitButtonViewportWidth + ) + } + private func focusPaneFromTabBarChrome() -> Bool { guard !isFocused else { return false } withTransaction(Transaction(animation: nil)) { @@ -1031,11 +1070,59 @@ struct TabBarView: View { .fixedSize(horizontal: true, vertical: false) .frame(minWidth: splitButtonsBackdropWidth, alignment: .trailing) .background(SplitButtonLaneWidthReader()) + .background( + GeometryReader { contentGeo in + Color.clear + .onChange( + of: contentGeo.frame(in: .named(splitButtonScrollCoordinateSpaceName)) + ) { _, newFrame in + updateSplitButtonScrollContent(frame: newFrame) + } + .onAppear { + updateSplitButtonScrollContent( + frame: contentGeo.frame(in: .named(splitButtonScrollCoordinateSpaceName)) + ) + } + } + ) } + .coordinateSpace(name: splitButtonScrollCoordinateSpaceName) .frame(width: splitButtonsBackdropWidth, height: tabBarHeight, alignment: .trailing) + .background( + GeometryReader { viewportGeo in + Color.clear + .onChange(of: viewportGeo.size.width) { _, newWidth in + splitButtonViewportWidth = newWidth + } + .onAppear { + splitButtonViewportWidth = viewportGeo.size.width + } + } + ) + .mask(splitButtonScrollMask) .clipped() } + @ViewBuilder + private var splitButtonScrollMask: some View { + let affordances = splitButtonScrollAffordances + let fadeWidth = TabBarStyling.splitButtonScrollFadeWidth + HStack(spacing: 0) { + LinearGradient(colors: [.clear, .black], startPoint: .leading, endPoint: .trailing) + .frame(width: affordances.left ? fadeWidth : 0, height: tabBarHeight) + Rectangle().fill(Color.black) + .frame(height: tabBarHeight) + LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing) + .frame(width: affordances.right ? fadeWidth : 0, height: tabBarHeight) + } + .frame(height: tabBarHeight) + } + + private func updateSplitButtonScrollContent(frame: CGRect) { + splitButtonScrollOffset = max(0, -frame.minX) + splitButtonContentWidth = frame.width + } + @ViewBuilder private var splitButtonRow: some View { let tooltips = controller.configuration.appearance.splitButtonTooltips diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 89b724a4..4c43e427 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -437,6 +437,57 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(layout.trailingTabContentInset, 160) } + func testSplitButtonBackdropSolidSurfaceCoversVisibleActionLane() { + XCTAssertEqual( + TabBarStyling.splitButtonBackdropSolidSurfaceWidth( + effectSolidWidth: 2, + visibleLaneWidth: 60 + ), + 60 + ) + XCTAssertEqual( + TabBarStyling.splitButtonBackdropSolidSurfaceWidth( + effectSolidWidth: 96, + visibleLaneWidth: 60 + ), + 96 + ) + } + + func testSplitButtonScrollAffordancesTrackHiddenButtons() { + var affordances = TabBarStyling.splitButtonScrollAffordances( + scrollOffset: 0, + contentWidth: 320, + viewportWidth: 60 + ) + XCTAssertFalse(affordances.left) + XCTAssertTrue(affordances.right) + + affordances = TabBarStyling.splitButtonScrollAffordances( + scrollOffset: 120, + contentWidth: 320, + viewportWidth: 60 + ) + XCTAssertTrue(affordances.left) + XCTAssertTrue(affordances.right) + + affordances = TabBarStyling.splitButtonScrollAffordances( + scrollOffset: 260, + contentWidth: 320, + viewportWidth: 60 + ) + XCTAssertTrue(affordances.left) + XCTAssertFalse(affordances.right) + + affordances = TabBarStyling.splitButtonScrollAffordances( + scrollOffset: 0, + contentWidth: 60, + viewportWidth: 60 + ) + XCTAssertFalse(affordances.left) + XCTAssertFalse(affordances.right) + } + func testTabBarLayoutDoesNotHardClipSelectedChromeAtSplitButtonLane() { let layout = TabBarLayout( tabBarHeight: 28, @@ -1572,6 +1623,16 @@ final class BonsplitTests: XCTestCase { XCTAssertLessThan(delta, 0.08) } + @MainActor + func testSplitButtonBackdropSolidSurfaceCoversVisibleLane() { + guard let brightness = renderedSplitButtonLaneSolidBackdropBrightness() else { + XCTFail("Expected rendered split button lane backdrop color") + return + } + + XCTAssertGreaterThan(brightness, 0.5) + } + @MainActor func testSelectedTabIndicatorFadesIntoSplitButtonLane() { guard let brightnesses = renderedSelectedIndicatorFadeBrightnesses() else { @@ -2276,6 +2337,44 @@ final class BonsplitTests: XCTestCase { } } + @MainActor + private func renderedSplitButtonLaneSolidBackdropBrightness() -> CGFloat? { + let buttonCount = BonsplitConfiguration.SplitActionButton.defaults.count + let size = NSSize(width: 240, height: 28) + let splitButtonLaneWidth = visibleSplitButtonLaneWidth(size: size, buttonCount: buttonCount) + let appearance = BonsplitConfiguration.Appearance( + tabBarHeight: size.height, + splitButtonBackdropEffect: .default, + chromeColors: .init( + backgroundHex: "#000000", + tabBarBackgroundHex: "#000000", + splitButtonBackdropHex: "#FFFFFF", + borderHex: "#00000000" + ) + ) + + return renderedTabBarValue( + isFocused: true, + appearance: appearance, + showSplitButtons: true, + size: size, + configurePane: { pane in + let selected = TabItem(title: "", icon: nil) + pane.tabs = [selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + let laneStartX = size.width - splitButtonLaneWidth + guard let color = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: laneStartX + 2, y: size.height / 2) + )?.usingColorSpace(.sRGB) else { + return nil + } + return brightness(of: color) + } + } + @MainActor private func renderedSelectedIndicatorFadeBrightnesses() -> (leading: CGFloat, trailing: CGFloat)? { let buttonCount = BonsplitConfiguration.SplitActionButton.defaults.count From 4730ebb3c39564ea9a961318165d462d46c51f7b Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 30 Apr 2026 17:14:49 -0700 Subject: [PATCH 10/29] Extend solid action backdrop over content fade --- .../Bonsplit/Internal/Views/TabBarView.swift | 9 ++- Tests/BonsplitTests/BonsplitTests.swift | 60 ++++++++++++++++++- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 51d1a2c1..7add3554 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -214,9 +214,11 @@ enum TabBarStyling { static func splitButtonBackdropSolidSurfaceWidth( effectSolidWidth: CGFloat, - visibleLaneWidth: CGFloat + visibleLaneWidth: CGFloat, + contentFadeWidth: CGFloat ) -> CGFloat { - max(max(0, effectSolidWidth), max(0, visibleLaneWidth)) + let controlSurfaceWidth = max(0, visibleLaneWidth) + max(0, contentFadeWidth) + return max(max(0, effectSolidWidth), controlSurfaceWidth) } static func splitButtonScrollAffordances( @@ -549,7 +551,8 @@ struct TabBarView: View { private var splitButtonBackdropSolidWidth: CGFloat { TabBarStyling.splitButtonBackdropSolidSurfaceWidth( effectSolidWidth: splitButtonBackdropEffect.solidWidth, - visibleLaneWidth: splitButtonsBackdropWidth + visibleLaneWidth: splitButtonsBackdropWidth, + contentFadeWidth: splitButtonContentFadeWidth ) } diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 4c43e427..29e88c91 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -441,14 +441,16 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual( TabBarStyling.splitButtonBackdropSolidSurfaceWidth( effectSolidWidth: 2, - visibleLaneWidth: 60 + visibleLaneWidth: 60, + contentFadeWidth: 42 ), - 60 + 102 ) XCTAssertEqual( TabBarStyling.splitButtonBackdropSolidSurfaceWidth( effectSolidWidth: 96, - visibleLaneWidth: 60 + visibleLaneWidth: 60, + contentFadeWidth: 24 ), 96 ) @@ -1633,6 +1635,16 @@ final class BonsplitTests: XCTestCase { XCTAssertGreaterThan(brightness, 0.5) } + @MainActor + func testSplitButtonBackdropSolidSurfaceCoversTabContentFadeStart() { + guard let brightness = renderedSplitButtonContentFadeStartBackdropBrightness() else { + XCTFail("Expected rendered split button content fade backdrop color") + return + } + + XCTAssertGreaterThan(brightness, 0.5) + } + @MainActor func testSelectedTabIndicatorFadesIntoSplitButtonLane() { guard let brightnesses = renderedSelectedIndicatorFadeBrightnesses() else { @@ -2375,6 +2387,48 @@ final class BonsplitTests: XCTestCase { } } + @MainActor + private func renderedSplitButtonContentFadeStartBackdropBrightness() -> CGFloat? { + let buttonCount = BonsplitConfiguration.SplitActionButton.defaults.count + let size = NSSize(width: 240, height: 28) + let splitButtonLaneWidth = visibleSplitButtonLaneWidth(size: size, buttonCount: buttonCount) + let contentFadeWidth = BonsplitConfiguration.Appearance.SplitButtonBackdropEffect.default.contentFadeWidth + let appearance = BonsplitConfiguration.Appearance( + tabBarHeight: size.height, + splitButtonBackdropEffect: .default, + chromeColors: .init( + backgroundHex: "#000000", + tabBarBackgroundHex: "#000000", + splitButtonBackdropHex: "#FFFFFF", + borderHex: "#00000000" + ) + ) + + return renderedTabBarValue( + isFocused: true, + appearance: appearance, + showSplitButtons: true, + size: size, + configurePane: { pane in + let selected = TabItem( + title: "selected tab title that reaches under the controls", + icon: nil + ) + pane.tabs = [selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + let laneStartX = size.width - splitButtonLaneWidth + guard let color = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: laneStartX - contentFadeWidth + 2, y: size.height / 2) + )?.usingColorSpace(.sRGB) else { + return nil + } + return brightness(of: color) + } + } + @MainActor private func renderedSelectedIndicatorFadeBrightnesses() -> (leading: CGFloat, trailing: CGFloat)? { let buttonCount = BonsplitConfiguration.SplitActionButton.defaults.count From e48b4aefab11f50ff0034212ff644b8a983a8afe Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 30 Apr 2026 17:37:44 -0700 Subject: [PATCH 11/29] Move split action backdrop over tab chrome --- .../Bonsplit/Internal/Views/TabBarView.swift | 114 ++++++++++-------- Tests/BonsplitTests/BonsplitTests.swift | 64 +++++++++- 2 files changed, 124 insertions(+), 54 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 7add3554..63a755d5 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -739,18 +739,6 @@ struct TabBarView: View { } .frame(height: tabBarHeight) .mask(combinedMask) - // Split buttons sit on top of the tab strip. Their backing surface is - // painted by `tabBarSurface` so translucent colors are composited once, - // while `combinedMask` fades overflowing tab content out below them. - .overlay(alignment: .trailing) { - if shouldRenderSplitButtons { - splitButtons - .saturation(tabBarSaturation) - .opacity(shouldShowSplitButtons ? 1 : 0) - .allowsHitTesting(shouldShowSplitButtons) - .animation(.easeInOut(duration: 0.14), value: shouldShowSplitButtons) - } - } } } .frame(maxWidth: .infinity, alignment: .leading) @@ -758,6 +746,9 @@ struct TabBarView: View { .coordinateSpace(name: "tabBar") .background(tabBarSurface) .overlay(maskedTabBarChrome) + .overlay(alignment: .trailing) { + splitButtonChrome + } .background(TabBarDragAndHoverView( isMinimalMode: isMinimalMode, onDoubleClick: { @@ -1066,6 +1057,66 @@ struct TabBarView: View { // MARK: - Split Buttons + @ViewBuilder + private var splitButtonChrome: some View { + if shouldRenderSplitButtons { + ZStack(alignment: .trailing) { + splitButtonBackdropSurface + .opacity(shouldShowSplitButtons ? 1 : 0) + .allowsHitTesting(false) + + splitButtons + .saturation(tabBarSaturation) + .opacity(shouldShowSplitButtons ? 1 : 0) + .allowsHitTesting(shouldShowSplitButtons) + } + .frame(height: tabBarHeight, alignment: .trailing) + .animation(.easeInOut(duration: 0.14), value: shouldShowSplitButtons) + } + } + + @ViewBuilder + private var splitButtonBackdropSurface: some View { + if shouldPaintSplitButtonBackdrop { + let baseBarColor = TabBarColors.nsColorBarBackground(for: appearance) + let barColor = appearance.usesSharedBackdrop || isFocused + ? baseBarColor + : baseBarColor.withAlphaComponent(baseBarColor.alphaComponent * 0.95) + let effect = splitButtonBackdropEffect + let targetColor = Self.buttonBackdropColor( + for: appearance, + focused: isFocused, + style: effect.style + ) + let colors = Self.splitButtonBackdropColors( + from: barColor, + to: targetColor, + leadingOpacity: effect.leadingOpacity, + trailingOpacity: effect.trailingOpacity, + usesSharedBackdrop: appearance.usesSharedBackdrop + ) + + HStack(spacing: 0) { + if splitButtonBackdropFadeWidth > 0 { + let rampStart = splitButtonBackdropFadeRampStartFraction + LinearGradient( + stops: [ + .init(color: Color(nsColor: colors.leading), location: 0), + .init(color: Color(nsColor: colors.leading), location: rampStart), + .init(color: Color(nsColor: colors.trailing), location: 1) + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: splitButtonBackdropFadeWidth, height: tabBarHeight) + } + TabBarLayerBackedColor(color: colors.trailing) + .frame(width: splitButtonBackdropSolidWidth, height: tabBarHeight) + } + .frame(height: tabBarHeight) + } + } + @ViewBuilder private var splitButtons: some View { ScrollView(.horizontal, showsIndicators: false) { @@ -1392,42 +1443,9 @@ struct TabBarView: View { let barColor = appearance.usesSharedBackdrop || isFocused ? baseBarColor : baseBarColor.withAlphaComponent(baseBarColor.alphaComponent * 0.95) - HStack(spacing: 0) { - TabBarLayerBackedColor(color: barColor) - .frame(maxWidth: .infinity) - .frame(height: tabBarHeight) - if shouldPaintSplitButtonBackdrop { - let effect = splitButtonBackdropEffect - let targetColor = Self.buttonBackdropColor( - for: appearance, - focused: isFocused, - style: effect.style - ) - let colors = Self.splitButtonBackdropColors( - from: barColor, - to: targetColor, - leadingOpacity: effect.leadingOpacity, - trailingOpacity: effect.trailingOpacity, - usesSharedBackdrop: appearance.usesSharedBackdrop - ) - if splitButtonBackdropFadeWidth > 0 { - let rampStart = splitButtonBackdropFadeRampStartFraction - LinearGradient( - stops: [ - .init(color: Color(nsColor: colors.leading), location: 0), - .init(color: Color(nsColor: colors.leading), location: rampStart), - .init(color: Color(nsColor: colors.trailing), location: 1) - ], - startPoint: .leading, - endPoint: .trailing - ) - .frame(width: splitButtonBackdropFadeWidth, height: tabBarHeight) - } - TabBarLayerBackedColor(color: colors.trailing) - .frame(width: splitButtonBackdropSolidWidth, height: tabBarHeight) - } - } - .frame(height: tabBarHeight) + TabBarLayerBackedColor(color: barColor) + .frame(maxWidth: .infinity) + .frame(height: tabBarHeight) } @ViewBuilder diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 29e88c91..da9c92fe 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -1646,14 +1646,24 @@ final class BonsplitTests: XCTestCase { } @MainActor - func testSelectedTabIndicatorFadesIntoSplitButtonLane() { - guard let brightnesses = renderedSelectedIndicatorFadeBrightnesses() else { - XCTFail("Expected rendered selected indicator fade colors") + func testSplitButtonBackdropOccludesTabChromeAtContentFadeStart() { + guard let saturation = renderedSplitButtonContentFadeStartSaturation() else { + XCTFail("Expected rendered split button content fade colors") return } - XCTAssertGreaterThan(brightnesses.leading, 0.15) - XCTAssertLessThan(brightnesses.trailing, brightnesses.leading - 0.08) + XCTAssertLessThan(saturation, 0.2) + } + + @MainActor + func testSelectedTabIndicatorDoesNotBleedUnderSplitButtonBackdrop() { + guard let brightnesses = renderedSelectedIndicatorBackdropBrightnesses() else { + XCTFail("Expected rendered selected indicator backdrop colors") + return + } + + XCTAssertLessThan(brightnesses.leading, 0.08) + XCTAssertLessThan(brightnesses.trailing, 0.08) } func testTabBarSeparatorSegmentsClampGapIntoBounds() { @@ -2430,7 +2440,49 @@ final class BonsplitTests: XCTestCase { } @MainActor - private func renderedSelectedIndicatorFadeBrightnesses() -> (leading: CGFloat, trailing: CGFloat)? { + private func renderedSplitButtonContentFadeStartSaturation() -> CGFloat? { + let buttonCount = BonsplitConfiguration.SplitActionButton.defaults.count + let size = NSSize(width: 240, height: 28) + let splitButtonLaneWidth = visibleSplitButtonLaneWidth(size: size, buttonCount: buttonCount) + let contentFadeWidth = BonsplitConfiguration.Appearance.SplitButtonBackdropEffect.default.contentFadeWidth + let appearance = BonsplitConfiguration.Appearance( + tabBarHeight: size.height, + splitButtonBackdropEffect: .default, + chromeColors: .init( + backgroundHex: "#000000", + tabBarBackgroundHex: "#000000", + splitButtonBackdropHex: "#000000", + borderHex: "#00000000" + ) + ) + + return renderedTabBarValue( + isFocused: true, + appearance: appearance, + showSplitButtons: true, + size: size, + configurePane: { pane in + let selected = TabItem( + title: "selected tab title that reaches under the controls", + icon: nil + ) + pane.tabs = [selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + let laneStartX = size.width - splitButtonLaneWidth + let sampleRect = NSRect( + x: laneStartX - contentFadeWidth + 2, + y: 0, + width: 8, + height: 4 + ) + return maximumSaturation(in: hostingView, sampleRect: sampleRect) + } + } + + @MainActor + private func renderedSelectedIndicatorBackdropBrightnesses() -> (leading: CGFloat, trailing: CGFloat)? { let buttonCount = BonsplitConfiguration.SplitActionButton.defaults.count let size = NSSize(width: 240, height: 28) let splitButtonLaneWidth = visibleSplitButtonLaneWidth(size: size, buttonCount: buttonCount) From 9fffbddd4c4947e7bb076224af72c4646a6614ef Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 30 Apr 2026 17:48:01 -0700 Subject: [PATCH 12/29] Add shared backdrop action lane regression test --- Tests/BonsplitTests/BonsplitTests.swift | 75 +++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index da9c92fe..c7fabec8 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -1666,6 +1666,16 @@ final class BonsplitTests: XCTestCase { XCTAssertLessThan(brightnesses.trailing, 0.08) } + @MainActor + func testSharedBackdropManySplitButtonsPaintsActionLaneSurface() { + guard let alpha = renderedSharedBackdropActionLaneSurfaceAlpha() else { + XCTFail("Expected rendered shared backdrop action lane color") + return + } + + XCTAssertGreaterThan(alpha, 0.2) + } + func testTabBarSeparatorSegmentsClampGapIntoBounds() { var segments = TabBarStyling.separatorSegments(totalWidth: 100, gap: -20...40) XCTAssertEqual(segments.left, 0, accuracy: 0.0001) @@ -2531,6 +2541,71 @@ final class BonsplitTests: XCTestCase { } } + @MainActor + private func renderedSharedBackdropActionLaneSurfaceAlpha() -> CGFloat? { + let buttonCount = 28 + let size = NSSize(width: 360, height: 28) + let splitButtonLaneWidth = visibleSplitButtonLaneWidth(size: size, buttonCount: buttonCount) + let appearance = BonsplitConfiguration.Appearance( + tabBarHeight: size.height, + splitButtons: manySplitActionButtons(count: buttonCount), + splitButtonBackdropEffect: .init( + style: .translucentChrome, + fadeWidth: 99.75, + contentFadeWidth: 28.875, + solidWidth: 23.875, + fadeRampStartFraction: 0.60, + leadingOpacity: 0, + trailingOpacity: 0.8625, + contentOcclusionFraction: 0.6875, + masksTabContent: true + ), + chromeColors: .init( + backgroundHex: "#242424B8", + tabBarBackgroundHex: "#00000000", + splitButtonBackdropHex: "#00000000", + paneBackgroundHex: "#00000000", + borderHex: "#66666680" + ), + usesSharedBackdrop: true + ) + + return renderedTabBarValue( + isFocused: true, + appearance: appearance, + showSplitButtons: true, + size: size, + configurePane: { pane in + let selected = TabItem( + title: "selected tab title that reaches under the full action button lane", + icon: nil + ) + pane.tabs = [selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + let laneStartX = size.width - splitButtonLaneWidth + guard let color = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: laneStartX + 6, y: 6) + )?.usingColorSpace(.sRGB) else { + return nil + } + return color.alphaComponent + } + } + + private func manySplitActionButtons(count: Int) -> [BonsplitConfiguration.SplitActionButton] { + (0.. CGFloat { TabBarLayout( tabBarHeight: size.height, From d212b31ac6ad4bd22953926f7b71b869c791c133 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 30 Apr 2026 17:50:40 -0700 Subject: [PATCH 13/29] Route tab bar action chrome through snapshot --- .../Internal/Styling/TabBarColors.swift | 6 + .../Bonsplit/Internal/Views/TabBarView.swift | 399 +++++++++--------- 2 files changed, 217 insertions(+), 188 deletions(-) diff --git a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift index 08f4b3ba..f20d2c08 100644 --- a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift +++ b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift @@ -137,6 +137,12 @@ enum TabBarColors { splitButtonBackdropColor(for: appearance) ?? nsColorBarBackground(for: appearance) } + static func nsColorSplitButtonBackdropOccludingSurface(for appearance: BonsplitConfiguration.Appearance) -> NSColor { + nonClearColor(splitButtonBackdropColor(for: appearance)) + ?? semanticTabBarBackgroundColor(for: appearance) + ?? nsColorBarBackground(for: appearance) + } + static func nsColorSplitButtonBackdrop( for appearance: BonsplitConfiguration.Appearance, focused: Bool = true diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 63a755d5..2b1eeca0 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -405,6 +405,189 @@ struct TabBarLayout: Equatable { } } +struct TabBarChromeSnapshot { + let layout: TabBarLayout + let barColor: NSColor + let actionLaneWidth: CGFloat + let paintsActionLaneSurface: Bool + let masksTabContentUnderActionLane: Bool + let contentFadeWidth: CGFloat + let contentOcclusionWidth: CGFloat + let backdropFadeWidth: CGFloat + let backdropSolidWidth: CGFloat + let backdropFadeRampStartFraction: CGFloat + let backdropLeadingColor: NSColor + let backdropTrailingColor: NSColor + + init( + appearance: BonsplitConfiguration.Appearance, + layout: TabBarLayout, + isFocused: Bool, + shouldShowSplitButtons: Bool, + fadeColorStyle: Int + ) { + self.layout = layout + self.actionLaneWidth = layout.visibleSplitButtonLaneWidth + + let baseBarColor = TabBarColors.nsColorBarBackground(for: appearance) + self.barColor = appearance.usesSharedBackdrop || isFocused + ? baseBarColor + : baseBarColor.withAlphaComponent(baseBarColor.alphaComponent * 0.95) + + let effect = Self.splitButtonBackdropEffect( + for: appearance, + fadeColorStyle: fadeColorStyle + ) + let targetColor = Self.buttonBackdropColor( + for: appearance, + focused: isFocused, + style: effect.style + ) + let colors = Self.splitButtonBackdropColors( + from: barColor, + to: targetColor, + leadingOpacity: effect.leadingOpacity, + trailingOpacity: effect.trailingOpacity, + usesSharedBackdrop: appearance.usesSharedBackdrop + ) + + self.paintsActionLaneSurface = shouldShowSplitButtons + && effect.style != .hidden + && TabBarColors.shouldPaintSplitButtonBackdrop(for: appearance) + self.masksTabContentUnderActionLane = paintsActionLaneSurface && effect.masksTabContent + self.contentFadeWidth = masksTabContentUnderActionLane ? effect.contentFadeWidth : 0 + self.contentOcclusionWidth = masksTabContentUnderActionLane ? actionLaneWidth : 0 + self.backdropFadeWidth = max(0, effect.fadeWidth) + self.backdropSolidWidth = TabBarStyling.splitButtonBackdropSolidSurfaceWidth( + effectSolidWidth: effect.solidWidth, + visibleLaneWidth: actionLaneWidth, + contentFadeWidth: contentFadeWidth + ) + self.backdropFadeRampStartFraction = min(max(0, effect.fadeRampStartFraction), 0.95) + self.backdropLeadingColor = colors.leading + self.backdropTrailingColor = colors.trailing + } + + private static func splitButtonBackdropEffect( + for appearance: BonsplitConfiguration.Appearance, + fadeColorStyle: Int + ) -> BonsplitConfiguration.Appearance.SplitButtonBackdropEffect { + if let effect = appearance.splitButtonBackdropEffect { + return effect + } + if let style = appearance.splitButtonBackdropStyle { + return .init(style: style) + } + if let debugStyle = BonsplitConfiguration.Appearance.SplitButtonBackdropStyle(rawValue: fadeColorStyle) { + return .init( + style: debugStyle, + fadeWidth: 136, + solidWidth: 2, + fadeRampStartFraction: 0.80, + leadingOpacity: 0, + trailingOpacity: 0.80, + masksTabContent: false + ) + } + return .default + } + + private static func buttonBackdropColor( + for appearance: BonsplitConfiguration.Appearance, + focused: Bool, + style: BonsplitConfiguration.Appearance.SplitButtonBackdropStyle + ) -> NSColor { + if appearance.usesSharedBackdrop { + return TabBarColors.nsColorSplitButtonBackdropOccludingSurface(for: appearance) + } + + switch style { + case .opaquePaneBackground: + return TabBarColors.nsColorPaneBackground(for: appearance).withAlphaComponent(1.0) + case .opaqueBarBackground: + return TabBarColors.nsColorBarBackground(for: appearance).withAlphaComponent(1.0) + case .windowBackground: + return NSColor.windowBackgroundColor.withAlphaComponent(1.0) + case .controlBackground: + return NSColor.controlBackgroundColor.withAlphaComponent(1.0) + case .precompositedBarBackground: + let chrome = TabBarColors.nsColorBarBackground(for: appearance) + let winBg = NSColor.windowBackgroundColor + guard let fg = chrome.usingColorSpace(.sRGB), + let bk = winBg.usingColorSpace(.sRGB) else { + return chrome.withAlphaComponent(1.0) + } + let a: CGFloat = focused ? fg.alphaComponent : fg.alphaComponent * 0.95 + let oneMinusA = 1.0 - a + let r = fg.redComponent * a + bk.redComponent * oneMinusA + let g = fg.greenComponent * a + bk.greenComponent * oneMinusA + let b = fg.blueComponent * a + bk.blueComponent * oneMinusA + return NSColor(red: r, green: g, blue: b, alpha: 1.0) + case .translucentChrome: + let backdrop = TabBarColors.nsColorSplitButtonBackdropSurface(for: appearance) + let alpha = focused ? backdrop.alphaComponent : backdrop.alphaComponent * 0.95 + return backdrop.withAlphaComponent(alpha) + case .hidden: + return .clear + case .precompositedPaneBackground: + return TabBarColors.nsColorSplitButtonBackdrop(for: appearance, focused: focused) + } + } + + private static func blendedSurfaceColor( + from base: NSColor, + to target: NSColor, + amount: CGFloat + ) -> NSColor { + let clampedAmount = min(max(amount, 0), 1) + let source = base.usingColorSpace(.sRGB) ?? base + let destination = target.usingColorSpace(.sRGB) ?? target + let inverse = 1 - clampedAmount + return NSColor( + red: source.redComponent * inverse + destination.redComponent * clampedAmount, + green: source.greenComponent * inverse + destination.greenComponent * clampedAmount, + blue: source.blueComponent * inverse + destination.blueComponent * clampedAmount, + alpha: source.alphaComponent * inverse + destination.alphaComponent * clampedAmount + ) + } + + private static func splitButtonBackdropColors( + from base: NSColor, + to target: NSColor, + leadingOpacity: CGFloat, + trailingOpacity: CGFloat, + usesSharedBackdrop: Bool + ) -> (leading: NSColor, trailing: NSColor) { + if usesSharedBackdrop { + return ( + alphaOnlySurfaceColor(target, opacity: leadingOpacity), + alphaOnlySurfaceColor(target, opacity: trailingOpacity) + ) + } + + return ( + blendedSurfaceColor(from: base, to: target, amount: leadingOpacity), + blendedSurfaceColor(from: base, to: target, amount: trailingOpacity) + ) + } + + private static func alphaOnlySurfaceColor( + _ color: NSColor, + opacity: CGFloat + ) -> NSColor { + let clampedOpacity = min(max(opacity, 0), 1) + guard let source = color.usingColorSpace(.sRGB) else { + return color.withAlphaComponent(color.alphaComponent * clampedOpacity) + } + return NSColor( + red: source.redComponent, + green: source.greenComponent, + blue: source.blueComponent, + alpha: source.alphaComponent * clampedOpacity + ) + } +} + struct TabContextMenuState { let isPinned: Bool let isUnread: Bool @@ -496,6 +679,16 @@ struct TabBarView: View { ) } + private var chromeSnapshot: TabBarChromeSnapshot { + TabBarChromeSnapshot( + appearance: appearance, + layout: tabBarLayout, + isFocused: isFocused, + shouldShowSplitButtons: shouldShowSplitButtons, + fadeColorStyle: fadeColorStyle + ) + } + private var visibleSplitButtons: [BonsplitConfiguration.SplitActionButton] { guard showSplitButtons else { return [] } return appearance.splitButtons @@ -509,63 +702,8 @@ struct TabBarView: View { shouldRenderSplitButtons && (!isMinimalMode || isHoveringTabBar) } - private var splitButtonBackdropEffect: BonsplitConfiguration.Appearance.SplitButtonBackdropEffect { - if let effect = appearance.splitButtonBackdropEffect { - return effect - } - if let style = appearance.splitButtonBackdropStyle { - return .init(style: style) - } - if let debugStyle = BonsplitConfiguration.Appearance.SplitButtonBackdropStyle(rawValue: fadeColorStyle) { - return .init( - style: debugStyle, - fadeWidth: 136, - solidWidth: 2, - fadeRampStartFraction: 0.80, - leadingOpacity: 0, - trailingOpacity: 0.80, - masksTabContent: false - ) - } - return .default - } - - private var shouldPaintSplitButtonBackdrop: Bool { - shouldShowSplitButtons - && splitButtonBackdropEffect.style != .hidden - && TabBarColors.shouldPaintSplitButtonBackdrop(for: appearance) - } - - private var shouldMaskTabsUnderSplitButtonBackdrop: Bool { - shouldPaintSplitButtonBackdrop && splitButtonBackdropEffect.masksTabContent - } - private var splitButtonsBackdropWidth: CGFloat { - tabBarLayout.visibleSplitButtonLaneWidth - } - - private var splitButtonBackdropFadeWidth: CGFloat { - max(0, splitButtonBackdropEffect.fadeWidth) - } - - private var splitButtonBackdropSolidWidth: CGFloat { - TabBarStyling.splitButtonBackdropSolidSurfaceWidth( - effectSolidWidth: splitButtonBackdropEffect.solidWidth, - visibleLaneWidth: splitButtonsBackdropWidth, - contentFadeWidth: splitButtonContentFadeWidth - ) - } - - private var splitButtonBackdropFadeRampStartFraction: CGFloat { - min(max(0, splitButtonBackdropEffect.fadeRampStartFraction), 0.95) - } - - private var splitButtonContentFadeWidth: CGFloat { - max(0, splitButtonBackdropEffect.contentFadeWidth) - } - - private var splitButtonContentOcclusionWidth: CGFloat { - splitButtonsBackdropWidth * min(max(0, splitButtonBackdropEffect.contentOcclusionFraction), 1) + chromeSnapshot.actionLaneWidth } private var showsControlShortcutHints: Bool { @@ -1077,41 +1215,24 @@ struct TabBarView: View { @ViewBuilder private var splitButtonBackdropSurface: some View { - if shouldPaintSplitButtonBackdrop { - let baseBarColor = TabBarColors.nsColorBarBackground(for: appearance) - let barColor = appearance.usesSharedBackdrop || isFocused - ? baseBarColor - : baseBarColor.withAlphaComponent(baseBarColor.alphaComponent * 0.95) - let effect = splitButtonBackdropEffect - let targetColor = Self.buttonBackdropColor( - for: appearance, - focused: isFocused, - style: effect.style - ) - let colors = Self.splitButtonBackdropColors( - from: barColor, - to: targetColor, - leadingOpacity: effect.leadingOpacity, - trailingOpacity: effect.trailingOpacity, - usesSharedBackdrop: appearance.usesSharedBackdrop - ) - + let snapshot = chromeSnapshot + if snapshot.paintsActionLaneSurface { HStack(spacing: 0) { - if splitButtonBackdropFadeWidth > 0 { - let rampStart = splitButtonBackdropFadeRampStartFraction + if snapshot.backdropFadeWidth > 0 { + let rampStart = snapshot.backdropFadeRampStartFraction LinearGradient( stops: [ - .init(color: Color(nsColor: colors.leading), location: 0), - .init(color: Color(nsColor: colors.leading), location: rampStart), - .init(color: Color(nsColor: colors.trailing), location: 1) + .init(color: Color(nsColor: snapshot.backdropLeadingColor), location: 0), + .init(color: Color(nsColor: snapshot.backdropLeadingColor), location: rampStart), + .init(color: Color(nsColor: snapshot.backdropTrailingColor), location: 1) ], startPoint: .leading, endPoint: .trailing ) - .frame(width: splitButtonBackdropFadeWidth, height: tabBarHeight) + .frame(width: snapshot.backdropFadeWidth, height: tabBarHeight) } - TabBarLayerBackedColor(color: colors.trailing) - .frame(width: splitButtonBackdropSolidWidth, height: tabBarHeight) + TabBarLayerBackedColor(color: snapshot.backdropTrailingColor) + .frame(width: snapshot.backdropSolidWidth, height: tabBarHeight) } .frame(height: tabBarHeight) } @@ -1306,101 +1427,6 @@ struct TabBarView: View { } - private static func buttonBackdropColor( - for appearance: BonsplitConfiguration.Appearance, - focused: Bool, - style: BonsplitConfiguration.Appearance.SplitButtonBackdropStyle - ) -> NSColor { - if appearance.usesSharedBackdrop { - return TabBarColors.nsColorSplitButtonBackdropSurface(for: appearance) - } - - switch style { - case .opaquePaneBackground: - return TabBarColors.nsColorPaneBackground(for: appearance).withAlphaComponent(1.0) - case .opaqueBarBackground: - return TabBarColors.nsColorBarBackground(for: appearance).withAlphaComponent(1.0) - case .windowBackground: - return NSColor.windowBackgroundColor.withAlphaComponent(1.0) - case .controlBackground: - return NSColor.controlBackgroundColor.withAlphaComponent(1.0) - case .precompositedBarBackground: - let chrome = TabBarColors.nsColorBarBackground(for: appearance) - let winBg = NSColor.windowBackgroundColor - guard let fg = chrome.usingColorSpace(.sRGB), - let bk = winBg.usingColorSpace(.sRGB) else { - return chrome.withAlphaComponent(1.0) - } - let a: CGFloat = focused ? fg.alphaComponent : fg.alphaComponent * 0.95 - let oneMinusA = 1.0 - a - let r = fg.redComponent * a + bk.redComponent * oneMinusA - let g = fg.greenComponent * a + bk.greenComponent * oneMinusA - let b = fg.blueComponent * a + bk.blueComponent * oneMinusA - return NSColor(red: r, green: g, blue: b, alpha: 1.0) - case .translucentChrome: - let backdrop = TabBarColors.nsColorSplitButtonBackdropSurface(for: appearance) - let alpha = focused ? backdrop.alphaComponent : backdrop.alphaComponent * 0.95 - return backdrop.withAlphaComponent(alpha) - case .hidden: - return .clear - case .precompositedPaneBackground: - return TabBarColors.nsColorSplitButtonBackdrop(for: appearance, focused: focused) - } - } - - private static func blendedSurfaceColor( - from base: NSColor, - to target: NSColor, - amount: CGFloat - ) -> NSColor { - let clampedAmount = min(max(amount, 0), 1) - let source = base.usingColorSpace(.sRGB) ?? base - let destination = target.usingColorSpace(.sRGB) ?? target - let inverse = 1 - clampedAmount - return NSColor( - red: source.redComponent * inverse + destination.redComponent * clampedAmount, - green: source.greenComponent * inverse + destination.greenComponent * clampedAmount, - blue: source.blueComponent * inverse + destination.blueComponent * clampedAmount, - alpha: source.alphaComponent * inverse + destination.alphaComponent * clampedAmount - ) - } - - private static func splitButtonBackdropColors( - from base: NSColor, - to target: NSColor, - leadingOpacity: CGFloat, - trailingOpacity: CGFloat, - usesSharedBackdrop: Bool - ) -> (leading: NSColor, trailing: NSColor) { - if usesSharedBackdrop { - return ( - alphaOnlySurfaceColor(target, opacity: leadingOpacity), - alphaOnlySurfaceColor(target, opacity: trailingOpacity) - ) - } - - return ( - blendedSurfaceColor(from: base, to: target, amount: leadingOpacity), - blendedSurfaceColor(from: base, to: target, amount: trailingOpacity) - ) - } - - private static func alphaOnlySurfaceColor( - _ color: NSColor, - opacity: CGFloat - ) -> NSColor { - let clampedOpacity = min(max(opacity, 0), 1) - guard let source = color.usingColorSpace(.sRGB) else { - return color.withAlphaComponent(color.alphaComponent * clampedOpacity) - } - return NSColor( - red: source.redComponent, - green: source.greenComponent, - blue: source.blueComponent, - alpha: source.alphaComponent * clampedOpacity - ) - } - // MARK: - Combined Mask (scroll fades + button area) // // The split-button backdrop is responsible for occluding content under the controls. @@ -1410,6 +1436,7 @@ struct TabBarView: View { @ViewBuilder private var combinedMask: some View { let fadeWidth: CGFloat = 24 + let snapshot = chromeSnapshot HStack(spacing: 0) { // Left scroll fade LinearGradient(colors: [.clear, .black], startPoint: .leading, endPoint: .trailing) @@ -1419,13 +1446,13 @@ struct TabBarView: View { Rectangle().fill(Color.black) .frame(height: tabBarHeight) - if shouldMaskTabsUnderSplitButtonBackdrop { + if snapshot.masksTabContentUnderActionLane { LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing) - .frame(width: splitButtonContentFadeWidth, height: tabBarHeight) - // Content is already fully faded before the backdrop ramp starts. This keeps the - // beginning of a transparent backdrop fade from blending over bright tab text. + .frame(width: snapshot.contentFadeWidth, height: tabBarHeight) + // Action-lane content is always fully removed from the tab layer. + // Backdrop softness belongs to the foreground chrome snapshot, not the mask width. Color.clear - .frame(width: splitButtonContentOcclusionWidth, height: tabBarHeight) + .frame(width: snapshot.contentOcclusionWidth, height: tabBarHeight) } else { // Right scroll fade only when scroll content actually overflows. LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing) @@ -1439,11 +1466,7 @@ struct TabBarView: View { @ViewBuilder private var tabBarSurface: some View { - let baseBarColor = TabBarColors.nsColorBarBackground(for: appearance) - let barColor = appearance.usesSharedBackdrop || isFocused - ? baseBarColor - : baseBarColor.withAlphaComponent(baseBarColor.alphaComponent * 0.95) - TabBarLayerBackedColor(color: barColor) + TabBarLayerBackedColor(color: chromeSnapshot.barColor) .frame(maxWidth: .infinity) .frame(height: tabBarHeight) } From a9ea9bdd1f11c0e97d560ff2dbf1fd241a46a9a3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 00:46:27 -0700 Subject: [PATCH 14/29] Respect transparent shared backdrop action lanes --- .../Internal/Styling/TabBarColors.swift | 7 ++----- .../Bonsplit/Internal/Views/TabBarView.swift | 6 +++--- Tests/BonsplitTests/BonsplitTests.swift | 21 ++++++++++++++++--- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift index f20d2c08..854d9a83 100644 --- a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift +++ b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift @@ -139,8 +139,7 @@ enum TabBarColors { static func nsColorSplitButtonBackdropOccludingSurface(for appearance: BonsplitConfiguration.Appearance) -> NSColor { nonClearColor(splitButtonBackdropColor(for: appearance)) - ?? semanticTabBarBackgroundColor(for: appearance) - ?? nsColorBarBackground(for: appearance) + ?? .clear } static func nsColorSplitButtonBackdrop( @@ -151,9 +150,7 @@ enum TabBarColors { } static func shouldPaintSplitButtonBackdrop(for appearance: BonsplitConfiguration.Appearance) -> Bool { - splitButtonBackdropColor(for: appearance) != nil - || tabBarBackgroundColor(for: appearance) != nil - || chromeBackgroundColor(for: appearance) != nil + nonClearColor(splitButtonBackdropColor(for: appearance)) != nil } static var barMaterial: Material { diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 2b1eeca0..cf3f7896 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -451,10 +451,10 @@ struct TabBarChromeSnapshot { usesSharedBackdrop: appearance.usesSharedBackdrop ) - self.paintsActionLaneSurface = shouldShowSplitButtons - && effect.style != .hidden + let canUseActionLaneChrome = shouldShowSplitButtons && effect.style != .hidden + self.paintsActionLaneSurface = canUseActionLaneChrome && TabBarColors.shouldPaintSplitButtonBackdrop(for: appearance) - self.masksTabContentUnderActionLane = paintsActionLaneSurface && effect.masksTabContent + self.masksTabContentUnderActionLane = canUseActionLaneChrome && effect.masksTabContent self.contentFadeWidth = masksTabContentUnderActionLane ? effect.contentFadeWidth : 0 self.contentOcclusionWidth = masksTabContentUnderActionLane ? actionLaneWidth : 0 self.backdropFadeWidth = max(0, effect.fadeWidth) diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index c7fabec8..21bd9cdc 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -1667,13 +1667,28 @@ final class BonsplitTests: XCTestCase { } @MainActor - func testSharedBackdropManySplitButtonsPaintsActionLaneSurface() { + func testSharedBackdropTransparentActionLaneDoesNotPaintSyntheticSurface() { + let appearance = BonsplitConfiguration.Appearance( + chromeColors: .init( + backgroundHex: "#272822B8", + tabBarBackgroundHex: "#00000000", + splitButtonBackdropHex: "#00000000", + paneBackgroundHex: "#00000000" + ), + usesSharedBackdrop: true + ) + + XCTAssertFalse(TabBarColors.shouldPaintSplitButtonBackdrop(for: appearance)) + } + + @MainActor + func testSharedBackdropManySplitButtonsMaskTabContentWithoutSyntheticSurface() { guard let alpha = renderedSharedBackdropActionLaneSurfaceAlpha() else { XCTFail("Expected rendered shared backdrop action lane color") return } - XCTAssertGreaterThan(alpha, 0.2) + XCTAssertLessThan(alpha, 0.05) } func testTabBarSeparatorSegmentsClampGapIntoBounds() { @@ -2587,7 +2602,7 @@ final class BonsplitTests: XCTestCase { let laneStartX = size.width - splitButtonLaneWidth guard let color = renderedColorInViewCoordinates( in: hostingView, - at: NSPoint(x: laneStartX + 6, y: 6) + at: NSPoint(x: laneStartX + 2, y: 2) )?.usingColorSpace(.sRGB) else { return nil } From 5dd5b928fed7d785a61013d60149f4f0d0aa23cd Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 01:18:38 -0700 Subject: [PATCH 15/29] Honor split action content occlusion fraction --- .../Bonsplit/Internal/Views/TabBarView.swift | 21 +++++++++++++++---- Tests/BonsplitTests/BonsplitTests.swift | 21 +++++++++++++++++-- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index cf3f7896..3d96634f 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -214,13 +214,20 @@ enum TabBarStyling { static func splitButtonBackdropSolidSurfaceWidth( effectSolidWidth: CGFloat, - visibleLaneWidth: CGFloat, + contentOcclusionWidth: CGFloat, contentFadeWidth: CGFloat ) -> CGFloat { - let controlSurfaceWidth = max(0, visibleLaneWidth) + max(0, contentFadeWidth) + let controlSurfaceWidth = max(0, contentOcclusionWidth) + max(0, contentFadeWidth) return max(max(0, effectSolidWidth), controlSurfaceWidth) } + static func splitButtonContentOcclusionWidth( + visibleLaneWidth: CGFloat, + contentOcclusionFraction: CGFloat + ) -> CGFloat { + max(0, visibleLaneWidth) * min(max(0, contentOcclusionFraction), 1) + } + static func splitButtonScrollAffordances( scrollOffset: CGFloat, contentWidth: CGFloat, @@ -456,11 +463,17 @@ struct TabBarChromeSnapshot { && TabBarColors.shouldPaintSplitButtonBackdrop(for: appearance) self.masksTabContentUnderActionLane = canUseActionLaneChrome && effect.masksTabContent self.contentFadeWidth = masksTabContentUnderActionLane ? effect.contentFadeWidth : 0 - self.contentOcclusionWidth = masksTabContentUnderActionLane ? actionLaneWidth : 0 + let contentOcclusionWidth = masksTabContentUnderActionLane + ? TabBarStyling.splitButtonContentOcclusionWidth( + visibleLaneWidth: actionLaneWidth, + contentOcclusionFraction: effect.contentOcclusionFraction + ) + : 0 + self.contentOcclusionWidth = contentOcclusionWidth self.backdropFadeWidth = max(0, effect.fadeWidth) self.backdropSolidWidth = TabBarStyling.splitButtonBackdropSolidSurfaceWidth( effectSolidWidth: effect.solidWidth, - visibleLaneWidth: actionLaneWidth, + contentOcclusionWidth: contentOcclusionWidth, contentFadeWidth: contentFadeWidth ) self.backdropFadeRampStartFraction = min(max(0, effect.fadeRampStartFraction), 0.95) diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 21bd9cdc..e35a97ea 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -441,7 +441,7 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual( TabBarStyling.splitButtonBackdropSolidSurfaceWidth( effectSolidWidth: 2, - visibleLaneWidth: 60, + contentOcclusionWidth: 60, contentFadeWidth: 42 ), 102 @@ -449,13 +449,30 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual( TabBarStyling.splitButtonBackdropSolidSurfaceWidth( effectSolidWidth: 96, - visibleLaneWidth: 60, + contentOcclusionWidth: 60, contentFadeWidth: 24 ), 96 ) } + func testSplitButtonContentOcclusionFractionLimitsSolidSurface() { + let occlusion = TabBarStyling.splitButtonContentOcclusionWidth( + visibleLaneWidth: 200, + contentOcclusionFraction: 0.25 + ) + + XCTAssertEqual(occlusion, 50) + XCTAssertEqual( + TabBarStyling.splitButtonBackdropSolidSurfaceWidth( + effectSolidWidth: 2, + contentOcclusionWidth: occlusion, + contentFadeWidth: 24 + ), + 74 + ) + } + func testSplitButtonScrollAffordancesTrackHiddenButtons() { var affordances = TabBarStyling.splitButtonScrollAffordances( scrollOffset: 0, From eaae2e94b94b9c596b3b318481441cea87782d3b Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 02:08:19 -0700 Subject: [PATCH 16/29] Add action lane separator regression test --- Tests/BonsplitTests/BonsplitTests.swift | 119 +++++++++++++++++++----- 1 file changed, 98 insertions(+), 21 deletions(-) diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index a3aed09d..94a49150 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -1755,6 +1755,17 @@ final class BonsplitTests: XCTestCase { XCTAssertLessThan(alpha, 0.05) } + @MainActor + func testSharedBackdropActionLaneBottomSeparatorCoversSolidAreaAndFadesOut() { + guard let alphas = renderedSharedBackdropActionLaneBottomSeparatorAlphas() else { + XCTFail("Expected rendered shared backdrop action lane separator colors") + return + } + + XCTAssertGreaterThan(alphas.solid, 0.25) + XCTAssertLessThan(alphas.fadeStart, alphas.solid * 0.5) + } + func testTabBarSeparatorSegmentsClampGapIntoBounds() { var segments = TabBarStyling.separatorSegments(totalWidth: 100, gap: -20...40) XCTAssertEqual(segments.left, 0, accuracy: 0.0001) @@ -2625,28 +2636,9 @@ final class BonsplitTests: XCTestCase { let buttonCount = 28 let size = NSSize(width: 360, height: 28) let splitButtonLaneWidth = visibleSplitButtonLaneWidth(size: size, buttonCount: buttonCount) - let appearance = BonsplitConfiguration.Appearance( + let appearance = sharedBackdropManyActionAppearance( tabBarHeight: size.height, - splitButtons: manySplitActionButtons(count: buttonCount), - splitButtonBackdropEffect: .init( - style: .translucentChrome, - fadeWidth: 99.75, - contentFadeWidth: 28.875, - solidWidth: 23.875, - fadeRampStartFraction: 0.60, - leadingOpacity: 0, - trailingOpacity: 0.8625, - contentOcclusionFraction: 0.6875, - masksTabContent: true - ), - chromeColors: .init( - backgroundHex: "#242424B8", - tabBarBackgroundHex: "#00000000", - splitButtonBackdropHex: "#00000000", - paneBackgroundHex: "#00000000", - borderHex: "#66666680" - ), - usesSharedBackdrop: true + buttonCount: buttonCount ) return renderedTabBarValue( @@ -2674,6 +2666,91 @@ final class BonsplitTests: XCTestCase { } } + @MainActor + private func renderedSharedBackdropActionLaneBottomSeparatorAlphas() -> ( + fadeStart: CGFloat, + solid: CGFloat + )? { + let buttonCount = 28 + let size = NSSize(width: 360, height: 28) + let splitButtonLaneWidth = visibleSplitButtonLaneWidth(size: size, buttonCount: buttonCount) + let contentFadeWidth: CGFloat = 28.875 + let contentOcclusionWidth = TabBarStyling.splitButtonContentOcclusionWidth( + visibleLaneWidth: splitButtonLaneWidth, + contentOcclusionFraction: 0.6875 + ) + let solidWidth = TabBarStyling.splitButtonBackdropSolidSurfaceWidth( + effectSolidWidth: 23.875, + contentOcclusionWidth: contentOcclusionWidth, + contentFadeWidth: contentFadeWidth + ) + let fadeWidth: CGFloat = 99.75 + let appearance = sharedBackdropManyActionAppearance( + tabBarHeight: size.height, + buttonCount: buttonCount, + borderHex: "#FFFFFF80" + ) + + return renderedTabBarValue( + isFocused: true, + appearance: appearance, + showSplitButtons: true, + size: size, + configurePane: { pane in + let selected = TabItem( + title: "selected tab title that reaches under the full action button lane", + icon: nil + ) + pane.tabs = [selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + let separatorY = size.height - 0.5 + let fadeStartX = size.width - solidWidth - fadeWidth + 2 + guard let fadeStart = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: fadeStartX, y: separatorY) + )?.usingColorSpace(.sRGB)?.alphaComponent, + let solid = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: size.width - 6, y: separatorY) + )?.usingColorSpace(.sRGB)?.alphaComponent else { + return nil + } + return (fadeStart: fadeStart, solid: solid) + } + } + + private func sharedBackdropManyActionAppearance( + tabBarHeight: CGFloat, + buttonCount: Int, + borderHex: String = "#66666680" + ) -> BonsplitConfiguration.Appearance { + BonsplitConfiguration.Appearance( + tabBarHeight: tabBarHeight, + splitButtons: manySplitActionButtons(count: buttonCount), + splitButtonBackdropEffect: .init( + style: .translucentChrome, + fadeWidth: 99.75, + contentFadeWidth: 28.875, + solidWidth: 23.875, + fadeRampStartFraction: 0.60, + leadingOpacity: 0, + trailingOpacity: 0.8625, + contentOcclusionFraction: 0.6875, + masksTabContent: true + ), + chromeColors: .init( + backgroundHex: "#242424B8", + tabBarBackgroundHex: "#00000000", + splitButtonBackdropHex: "#00000000", + paneBackgroundHex: "#00000000", + borderHex: borderHex + ), + usesSharedBackdrop: true + ) + } + private func manySplitActionButtons(count: Int) -> [BonsplitConfiguration.SplitActionButton] { (0.. Date: Fri, 1 May 2026 02:08:57 -0700 Subject: [PATCH 17/29] Draw action lane bottom separator --- .../Bonsplit/Internal/Views/TabBarView.swift | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 3d96634f..09db0a3c 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -426,6 +426,10 @@ struct TabBarChromeSnapshot { let backdropLeadingColor: NSColor let backdropTrailingColor: NSColor + var drawsActionLaneSeparator: Bool { + paintsActionLaneSurface || masksTabContentUnderActionLane + } + init( appearance: BonsplitConfiguration.Appearance, layout: TabBarLayout, @@ -1216,6 +1220,10 @@ struct TabBarView: View { .opacity(shouldShowSplitButtons ? 1 : 0) .allowsHitTesting(false) + splitButtonBackdropSeparator + .opacity(shouldShowSplitButtons ? 1 : 0) + .allowsHitTesting(false) + splitButtons .saturation(tabBarSaturation) .opacity(shouldShowSplitButtons ? 1 : 0) @@ -1251,6 +1259,36 @@ struct TabBarView: View { } } + @ViewBuilder + private var splitButtonBackdropSeparator: some View { + let snapshot = chromeSnapshot + if snapshot.drawsActionLaneSeparator { + VStack(spacing: 0) { + Spacer(minLength: 0) + HStack(spacing: 0) { + let separator = TabBarColors.separator(for: appearance) + if snapshot.backdropFadeWidth > 0 { + let rampStart = snapshot.backdropFadeRampStartFraction + LinearGradient( + stops: [ + .init(color: separator.opacity(0), location: 0), + .init(color: separator.opacity(0), location: rampStart), + .init(color: separator, location: 1) + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: snapshot.backdropFadeWidth, height: 1) + } + Rectangle() + .fill(separator) + .frame(width: snapshot.backdropSolidWidth, height: 1) + } + } + .frame(height: tabBarHeight) + } + } + @ViewBuilder private var splitButtons: some View { ScrollView(.horizontal, showsIndicators: false) { From b68e2c459e7a767c7c1e88643e485efb5776a0b2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 02:21:53 -0700 Subject: [PATCH 18/29] Share action lane backdrop segment frames --- .../Bonsplit/Internal/Views/TabBarView.swift | 92 ++++++++++--------- 1 file changed, 47 insertions(+), 45 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 09db0a3c..30cb0483 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -1216,11 +1216,7 @@ struct TabBarView: View { private var splitButtonChrome: some View { if shouldRenderSplitButtons { ZStack(alignment: .trailing) { - splitButtonBackdropSurface - .opacity(shouldShowSplitButtons ? 1 : 0) - .allowsHitTesting(false) - - splitButtonBackdropSeparator + splitButtonBackdropChrome .opacity(shouldShowSplitButtons ? 1 : 0) .allowsHitTesting(false) @@ -1235,58 +1231,64 @@ struct TabBarView: View { } @ViewBuilder - private var splitButtonBackdropSurface: some View { + private var splitButtonBackdropChrome: some View { let snapshot = chromeSnapshot - if snapshot.paintsActionLaneSurface { + if snapshot.drawsActionLaneSeparator { HStack(spacing: 0) { if snapshot.backdropFadeWidth > 0 { - let rampStart = snapshot.backdropFadeRampStartFraction - LinearGradient( - stops: [ - .init(color: Color(nsColor: snapshot.backdropLeadingColor), location: 0), - .init(color: Color(nsColor: snapshot.backdropLeadingColor), location: rampStart), - .init(color: Color(nsColor: snapshot.backdropTrailingColor), location: 1) - ], - startPoint: .leading, - endPoint: .trailing - ) - .frame(width: snapshot.backdropFadeWidth, height: tabBarHeight) + splitButtonBackdropFadeSegment(snapshot: snapshot) } - TabBarLayerBackedColor(color: snapshot.backdropTrailingColor) - .frame(width: snapshot.backdropSolidWidth, height: tabBarHeight) + splitButtonBackdropSolidSegment(snapshot: snapshot) } .frame(height: tabBarHeight) } } @ViewBuilder - private var splitButtonBackdropSeparator: some View { - let snapshot = chromeSnapshot - if snapshot.drawsActionLaneSeparator { - VStack(spacing: 0) { - Spacer(minLength: 0) - HStack(spacing: 0) { - let separator = TabBarColors.separator(for: appearance) - if snapshot.backdropFadeWidth > 0 { - let rampStart = snapshot.backdropFadeRampStartFraction - LinearGradient( - stops: [ - .init(color: separator.opacity(0), location: 0), - .init(color: separator.opacity(0), location: rampStart), - .init(color: separator, location: 1) - ], - startPoint: .leading, - endPoint: .trailing - ) - .frame(width: snapshot.backdropFadeWidth, height: 1) - } - Rectangle() - .fill(separator) - .frame(width: snapshot.backdropSolidWidth, height: 1) - } + private func splitButtonBackdropFadeSegment(snapshot: TabBarChromeSnapshot) -> some View { + let rampStart = snapshot.backdropFadeRampStartFraction + ZStack(alignment: .bottom) { + if snapshot.paintsActionLaneSurface { + LinearGradient( + stops: [ + .init(color: Color(nsColor: snapshot.backdropLeadingColor), location: 0), + .init(color: Color(nsColor: snapshot.backdropLeadingColor), location: rampStart), + .init(color: Color(nsColor: snapshot.backdropTrailingColor), location: 1) + ], + startPoint: .leading, + endPoint: .trailing + ) + } + if snapshot.drawsActionLaneSeparator { + let separator = TabBarColors.separator(for: appearance) + LinearGradient( + stops: [ + .init(color: separator.opacity(0), location: 0), + .init(color: separator.opacity(0), location: rampStart), + .init(color: separator, location: 1) + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(height: 1) + } + } + .frame(width: snapshot.backdropFadeWidth, height: tabBarHeight, alignment: .bottom) + } + + @ViewBuilder + private func splitButtonBackdropSolidSegment(snapshot: TabBarChromeSnapshot) -> some View { + ZStack(alignment: .bottom) { + if snapshot.paintsActionLaneSurface { + TabBarLayerBackedColor(color: snapshot.backdropTrailingColor) + } + if snapshot.drawsActionLaneSeparator { + Rectangle() + .fill(TabBarColors.separator(for: appearance)) + .frame(height: 1) } - .frame(height: tabBarHeight) } + .frame(width: snapshot.backdropSolidWidth, height: tabBarHeight, alignment: .bottom) } @ViewBuilder From c5b24494c4f8773f991a65641d6904e894fce851 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 02:26:03 -0700 Subject: [PATCH 19/29] Narrow action lane separator fade ramp --- Sources/Bonsplit/Internal/Views/TabBarView.swift | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 30cb0483..5af8d2cf 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -430,6 +430,10 @@ struct TabBarChromeSnapshot { paintsActionLaneSurface || masksTabContentUnderActionLane } + var backdropVisibleFadeWidth: CGFloat { + backdropFadeWidth * (1 - backdropFadeRampStartFraction) + } + init( appearance: BonsplitConfiguration.Appearance, layout: TabBarLayout, @@ -1247,7 +1251,7 @@ struct TabBarView: View { @ViewBuilder private func splitButtonBackdropFadeSegment(snapshot: TabBarChromeSnapshot) -> some View { let rampStart = snapshot.backdropFadeRampStartFraction - ZStack(alignment: .bottom) { + ZStack(alignment: .bottomTrailing) { if snapshot.paintsActionLaneSurface { LinearGradient( stops: [ @@ -1262,15 +1266,14 @@ struct TabBarView: View { if snapshot.drawsActionLaneSeparator { let separator = TabBarColors.separator(for: appearance) LinearGradient( - stops: [ - .init(color: separator.opacity(0), location: 0), - .init(color: separator.opacity(0), location: rampStart), - .init(color: separator, location: 1) + colors: [ + separator.opacity(0), + separator ], startPoint: .leading, endPoint: .trailing ) - .frame(height: 1) + .frame(width: snapshot.backdropVisibleFadeWidth, height: 1) } } .frame(width: snapshot.backdropFadeWidth, height: tabBarHeight, alignment: .bottom) From 125625ab1e40fbbb6524c5a336dc3702b8379c55 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 02:33:30 -0700 Subject: [PATCH 20/29] Match action lane separator to tab mask --- .../Bonsplit/Internal/Views/TabBarView.swift | 91 ++++++++++--------- Tests/BonsplitTests/BonsplitTests.swift | 83 ++++++++++++++--- 2 files changed, 117 insertions(+), 57 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 5af8d2cf..59eb43ee 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -434,6 +434,14 @@ struct TabBarChromeSnapshot { backdropFadeWidth * (1 - backdropFadeRampStartFraction) } + var actionLaneSeparatorFadeWidth: CGFloat { + masksTabContentUnderActionLane ? contentFadeWidth : backdropVisibleFadeWidth + } + + var actionLaneSeparatorSolidWidth: CGFloat { + masksTabContentUnderActionLane ? contentOcclusionWidth : backdropSolidWidth + } + init( appearance: BonsplitConfiguration.Appearance, layout: TabBarLayout, @@ -1238,11 +1246,27 @@ struct TabBarView: View { private var splitButtonBackdropChrome: some View { let snapshot = chromeSnapshot if snapshot.drawsActionLaneSeparator { - HStack(spacing: 0) { - if snapshot.backdropFadeWidth > 0 { - splitButtonBackdropFadeSegment(snapshot: snapshot) + ZStack(alignment: .trailing) { + if snapshot.paintsActionLaneSurface { + HStack(spacing: 0) { + if snapshot.backdropFadeWidth > 0 { + splitButtonBackdropFadeSegment(snapshot: snapshot) + } + TabBarLayerBackedColor(color: snapshot.backdropTrailingColor) + .frame(width: snapshot.backdropSolidWidth, height: tabBarHeight) + } + .frame(height: tabBarHeight) + } + + HStack(spacing: 0) { + if snapshot.actionLaneSeparatorFadeWidth > 0 { + splitButtonSeparatorFadeSegment(snapshot: snapshot) + } + Rectangle() + .fill(TabBarColors.separator(for: appearance)) + .frame(width: snapshot.actionLaneSeparatorSolidWidth, height: 1) } - splitButtonBackdropSolidSegment(snapshot: snapshot) + .frame(height: tabBarHeight, alignment: .bottom) } .frame(height: tabBarHeight) } @@ -1251,47 +1275,30 @@ struct TabBarView: View { @ViewBuilder private func splitButtonBackdropFadeSegment(snapshot: TabBarChromeSnapshot) -> some View { let rampStart = snapshot.backdropFadeRampStartFraction - ZStack(alignment: .bottomTrailing) { - if snapshot.paintsActionLaneSurface { - LinearGradient( - stops: [ - .init(color: Color(nsColor: snapshot.backdropLeadingColor), location: 0), - .init(color: Color(nsColor: snapshot.backdropLeadingColor), location: rampStart), - .init(color: Color(nsColor: snapshot.backdropTrailingColor), location: 1) - ], - startPoint: .leading, - endPoint: .trailing - ) - } - if snapshot.drawsActionLaneSeparator { - let separator = TabBarColors.separator(for: appearance) - LinearGradient( - colors: [ - separator.opacity(0), - separator - ], - startPoint: .leading, - endPoint: .trailing - ) - .frame(width: snapshot.backdropVisibleFadeWidth, height: 1) - } - } - .frame(width: snapshot.backdropFadeWidth, height: tabBarHeight, alignment: .bottom) + LinearGradient( + stops: [ + .init(color: Color(nsColor: snapshot.backdropLeadingColor), location: 0), + .init(color: Color(nsColor: snapshot.backdropLeadingColor), location: rampStart), + .init(color: Color(nsColor: snapshot.backdropTrailingColor), location: 1) + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: snapshot.backdropFadeWidth, height: tabBarHeight) } @ViewBuilder - private func splitButtonBackdropSolidSegment(snapshot: TabBarChromeSnapshot) -> some View { - ZStack(alignment: .bottom) { - if snapshot.paintsActionLaneSurface { - TabBarLayerBackedColor(color: snapshot.backdropTrailingColor) - } - if snapshot.drawsActionLaneSeparator { - Rectangle() - .fill(TabBarColors.separator(for: appearance)) - .frame(height: 1) - } - } - .frame(width: snapshot.backdropSolidWidth, height: tabBarHeight, alignment: .bottom) + private func splitButtonSeparatorFadeSegment(snapshot: TabBarChromeSnapshot) -> some View { + let separator = TabBarColors.separator(for: appearance) + LinearGradient( + colors: [ + separator.opacity(0), + separator + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: snapshot.actionLaneSeparatorFadeWidth, height: 1) } @ViewBuilder diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 94a49150..8d3a83b1 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -1763,7 +1763,40 @@ final class BonsplitTests: XCTestCase { } XCTAssertGreaterThan(alphas.solid, 0.25) - XCTAssertLessThan(alphas.fadeStart, alphas.solid * 0.5) + XCTAssertGreaterThan(alphas.fadeEnd, alphas.solid * 0.55) + XCTAssertEqual(alphas.fadeEnd, alphas.solidStart, accuracy: 0.15) + } + + func testSharedBackdropActionLaneSeparatorMatchesMaskedTabContentGeometry() { + let buttonCount = 28 + let size = NSSize(width: 360, height: 28) + let layout = TabBarLayout( + tabBarHeight: size.height, + availableWidth: size.width, + splitButtonCount: buttonCount, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: TabBarStyling.splitButtonsBackdropWidth(buttonCount: buttonCount) + ) + let snapshot = TabBarChromeSnapshot( + appearance: sharedBackdropManyActionAppearance( + tabBarHeight: size.height, + buttonCount: buttonCount + ), + layout: layout, + isFocused: true, + shouldShowSplitButtons: true, + fadeColorStyle: 0 + ) + + XCTAssertEqual(snapshot.actionLaneSeparatorFadeWidth, snapshot.contentFadeWidth, accuracy: 0.0001) + XCTAssertEqual(snapshot.actionLaneSeparatorSolidWidth, snapshot.contentOcclusionWidth, accuracy: 0.0001) + XCTAssertLessThan(snapshot.actionLaneSeparatorSolidWidth, snapshot.backdropSolidWidth) + XCTAssertEqual( + snapshot.actionLaneSeparatorFadeWidth + snapshot.actionLaneSeparatorSolidWidth, + snapshot.contentFadeWidth + snapshot.contentOcclusionWidth, + accuracy: 0.0001 + ) } func testTabBarSeparatorSegmentsClampGapIntoBounds() { @@ -2647,11 +2680,12 @@ final class BonsplitTests: XCTestCase { showSplitButtons: true, size: size, configurePane: { pane in + let leading = TabItem(title: "", icon: nil) let selected = TabItem( title: "selected tab title that reaches under the full action button lane", icon: nil ) - pane.tabs = [selected] + pane.tabs = [leading, selected] pane.selectedTabId = selected.id } ) { hostingView in @@ -2669,6 +2703,9 @@ final class BonsplitTests: XCTestCase { @MainActor private func renderedSharedBackdropActionLaneBottomSeparatorAlphas() -> ( fadeStart: CGFloat, + fadeMid: CGFloat, + fadeEnd: CGFloat, + solidStart: CGFloat, solid: CGFloat )? { let buttonCount = 28 @@ -2679,16 +2716,11 @@ final class BonsplitTests: XCTestCase { visibleLaneWidth: splitButtonLaneWidth, contentOcclusionFraction: 0.6875 ) - let solidWidth = TabBarStyling.splitButtonBackdropSolidSurfaceWidth( - effectSolidWidth: 23.875, - contentOcclusionWidth: contentOcclusionWidth, - contentFadeWidth: contentFadeWidth - ) - let fadeWidth: CGFloat = 99.75 let appearance = sharedBackdropManyActionAppearance( tabBarHeight: size.height, buttonCount: buttonCount, - borderHex: "#FFFFFF80" + borderHex: "#FFFFFF80", + tabMaxWidth: size.width ) return renderedTabBarValue( @@ -2697,37 +2729,59 @@ final class BonsplitTests: XCTestCase { showSplitButtons: true, size: size, configurePane: { pane in + let leading = TabItem(title: "", icon: nil) let selected = TabItem( title: "selected tab title that reaches under the full action button lane", icon: nil ) - pane.tabs = [selected] + pane.tabs = [leading, selected] pane.selectedTabId = selected.id } ) { hostingView in let separatorY = size.height - 0.5 - let fadeStartX = size.width - solidWidth - fadeWidth + 2 + let fadeStartX = size.width - contentOcclusionWidth - contentFadeWidth + let solidStartX = size.width - contentOcclusionWidth guard let fadeStart = renderedColorInViewCoordinates( in: hostingView, - at: NSPoint(x: fadeStartX, y: separatorY) + at: NSPoint(x: fadeStartX + 2, y: separatorY) )?.usingColorSpace(.sRGB)?.alphaComponent, + let fadeMid = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: fadeStartX + (contentFadeWidth / 2), y: separatorY) + )?.usingColorSpace(.sRGB)?.alphaComponent, + let fadeEnd = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: solidStartX - 2, y: separatorY) + )?.usingColorSpace(.sRGB)?.alphaComponent, + let solidStart = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: solidStartX + 2, y: separatorY) + )?.usingColorSpace(.sRGB)?.alphaComponent, let solid = renderedColorInViewCoordinates( in: hostingView, at: NSPoint(x: size.width - 6, y: separatorY) )?.usingColorSpace(.sRGB)?.alphaComponent else { return nil } - return (fadeStart: fadeStart, solid: solid) + return ( + fadeStart: fadeStart, + fadeMid: fadeMid, + fadeEnd: fadeEnd, + solidStart: solidStart, + solid: solid + ) } } private func sharedBackdropManyActionAppearance( tabBarHeight: CGFloat, buttonCount: Int, - borderHex: String = "#66666680" + borderHex: String = "#66666680", + tabMaxWidth: CGFloat = 220 ) -> BonsplitConfiguration.Appearance { BonsplitConfiguration.Appearance( tabBarHeight: tabBarHeight, + tabMaxWidth: tabMaxWidth, splitButtons: manySplitActionButtons(count: buttonCount), splitButtonBackdropEffect: .init( style: .translucentChrome, @@ -2824,7 +2878,6 @@ final class BonsplitTests: XCTestCase { y < bitmap.pixelsHigh else { return nil } return bitmap.colorAt(x: x, y: y) } - @MainActor private func renderedTabBarValue( isFocused: Bool, From 7c4439cc5fda346ea91918b67145296d4bc78c61 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 02:47:12 -0700 Subject: [PATCH 21/29] Widen action lane solid chrome --- .../Bonsplit/Internal/Views/TabBarView.swift | 9 +++++++-- Tests/BonsplitTests/BonsplitTests.swift | 18 +++++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 59eb43ee..7f1cfa33 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -214,10 +214,12 @@ enum TabBarStyling { static func splitButtonBackdropSolidSurfaceWidth( effectSolidWidth: CGFloat, + visibleLaneWidth: CGFloat, contentOcclusionWidth: CGFloat, contentFadeWidth: CGFloat ) -> CGFloat { - let controlSurfaceWidth = max(0, contentOcclusionWidth) + max(0, contentFadeWidth) + let controlSurfaceWidth = max(max(0, visibleLaneWidth), max(0, contentOcclusionWidth)) + + max(0, contentFadeWidth) return max(max(0, effectSolidWidth), controlSurfaceWidth) } @@ -439,7 +441,9 @@ struct TabBarChromeSnapshot { } var actionLaneSeparatorSolidWidth: CGFloat { - masksTabContentUnderActionLane ? contentOcclusionWidth : backdropSolidWidth + masksTabContentUnderActionLane + ? max(actionLaneWidth, contentOcclusionWidth) + : backdropSolidWidth } init( @@ -489,6 +493,7 @@ struct TabBarChromeSnapshot { self.backdropFadeWidth = max(0, effect.fadeWidth) self.backdropSolidWidth = TabBarStyling.splitButtonBackdropSolidSurfaceWidth( effectSolidWidth: effect.solidWidth, + visibleLaneWidth: actionLaneWidth, contentOcclusionWidth: contentOcclusionWidth, contentFadeWidth: contentFadeWidth ) diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 8d3a83b1..0c8b831f 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -441,14 +441,16 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual( TabBarStyling.splitButtonBackdropSolidSurfaceWidth( effectSolidWidth: 2, + visibleLaneWidth: 90, contentOcclusionWidth: 60, contentFadeWidth: 42 ), - 102 + 132 ) XCTAssertEqual( TabBarStyling.splitButtonBackdropSolidSurfaceWidth( effectSolidWidth: 96, + visibleLaneWidth: 72, contentOcclusionWidth: 60, contentFadeWidth: 24 ), @@ -466,10 +468,11 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual( TabBarStyling.splitButtonBackdropSolidSurfaceWidth( effectSolidWidth: 2, + visibleLaneWidth: 200, contentOcclusionWidth: occlusion, contentFadeWidth: 24 ), - 74 + 224 ) } @@ -1790,11 +1793,11 @@ final class BonsplitTests: XCTestCase { ) XCTAssertEqual(snapshot.actionLaneSeparatorFadeWidth, snapshot.contentFadeWidth, accuracy: 0.0001) - XCTAssertEqual(snapshot.actionLaneSeparatorSolidWidth, snapshot.contentOcclusionWidth, accuracy: 0.0001) - XCTAssertLessThan(snapshot.actionLaneSeparatorSolidWidth, snapshot.backdropSolidWidth) + XCTAssertEqual(snapshot.actionLaneSeparatorSolidWidth, snapshot.actionLaneWidth, accuracy: 0.0001) + XCTAssertEqual(snapshot.backdropSolidWidth, snapshot.actionLaneWidth + snapshot.contentFadeWidth, accuracy: 0.0001) XCTAssertEqual( snapshot.actionLaneSeparatorFadeWidth + snapshot.actionLaneSeparatorSolidWidth, - snapshot.contentFadeWidth + snapshot.contentOcclusionWidth, + snapshot.contentFadeWidth + snapshot.actionLaneWidth, accuracy: 0.0001 ) } @@ -2716,6 +2719,7 @@ final class BonsplitTests: XCTestCase { visibleLaneWidth: splitButtonLaneWidth, contentOcclusionFraction: 0.6875 ) + let solidWidth = max(splitButtonLaneWidth, contentOcclusionWidth) let appearance = sharedBackdropManyActionAppearance( tabBarHeight: size.height, buttonCount: buttonCount, @@ -2739,8 +2743,8 @@ final class BonsplitTests: XCTestCase { } ) { hostingView in let separatorY = size.height - 0.5 - let fadeStartX = size.width - contentOcclusionWidth - contentFadeWidth - let solidStartX = size.width - contentOcclusionWidth + let fadeStartX = size.width - solidWidth - contentFadeWidth + let solidStartX = size.width - solidWidth guard let fadeStart = renderedColorInViewCoordinates( in: hostingView, at: NSPoint(x: fadeStartX + 2, y: separatorY) From 8106a1acf68652b4d2581a42944ba64914007b59 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 03:49:47 -0700 Subject: [PATCH 22/29] Layer action lane border below tab separator --- .../Bonsplit/Internal/Views/TabBarView.swift | 52 ++++++++++--------- .../Public/BonsplitConfiguration.swift | 3 ++ Tests/BonsplitTests/BonsplitTests.swift | 36 ++++++++++++- 3 files changed, 66 insertions(+), 25 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 7f1cfa33..6724f98a 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -422,6 +422,7 @@ struct TabBarChromeSnapshot { let masksTabContentUnderActionLane: Bool let contentFadeWidth: CGFloat let contentOcclusionWidth: CGFloat + let actionLaneSeparatorFadeWidth: CGFloat let backdropFadeWidth: CGFloat let backdropSolidWidth: CGFloat let backdropFadeRampStartFraction: CGFloat @@ -436,10 +437,6 @@ struct TabBarChromeSnapshot { backdropFadeWidth * (1 - backdropFadeRampStartFraction) } - var actionLaneSeparatorFadeWidth: CGFloat { - masksTabContentUnderActionLane ? contentFadeWidth : backdropVisibleFadeWidth - } - var actionLaneSeparatorSolidWidth: CGFloat { masksTabContentUnderActionLane ? max(actionLaneWidth, contentOcclusionWidth) @@ -498,6 +495,13 @@ struct TabBarChromeSnapshot { contentFadeWidth: contentFadeWidth ) self.backdropFadeRampStartFraction = min(max(0, effect.fadeRampStartFraction), 0.95) + let defaultSeparatorFadeWidth = masksTabContentUnderActionLane + ? contentFadeWidth + : self.backdropFadeWidth * (1 - self.backdropFadeRampStartFraction) + self.actionLaneSeparatorFadeWidth = min( + defaultSeparatorFadeWidth, + effect.separatorFadeWidth ?? defaultSeparatorFadeWidth + ) self.backdropLeadingColor = colors.leading self.backdropTrailingColor = colors.trailing } @@ -917,7 +921,14 @@ struct TabBarView: View { .frame(height: tabBarHeight) .coordinateSpace(name: "tabBar") .background(tabBarSurface) - .overlay(maskedTabBarChrome) + .overlay(maskedSelectedTabIndicatorChrome) + .overlay(alignment: .trailing) { + splitButtonBackdropChrome + .opacity(shouldShowSplitButtons ? 1 : 0) + .allowsHitTesting(false) + .animation(.easeInOut(duration: 0.14), value: shouldShowSplitButtons) + } + .overlay(maskedTabBarBottomSeparatorChrome) .overlay(alignment: .trailing) { splitButtonChrome } @@ -1232,16 +1243,10 @@ struct TabBarView: View { @ViewBuilder private var splitButtonChrome: some View { if shouldRenderSplitButtons { - ZStack(alignment: .trailing) { - splitButtonBackdropChrome - .opacity(shouldShowSplitButtons ? 1 : 0) - .allowsHitTesting(false) - - splitButtons - .saturation(tabBarSaturation) - .opacity(shouldShowSplitButtons ? 1 : 0) - .allowsHitTesting(shouldShowSplitButtons) - } + splitButtons + .saturation(tabBarSaturation) + .opacity(shouldShowSplitButtons ? 1 : 0) + .allowsHitTesting(shouldShowSplitButtons) .frame(height: tabBarHeight, alignment: .trailing) .animation(.easeInOut(duration: 0.14), value: shouldShowSplitButtons) } @@ -1540,23 +1545,22 @@ struct TabBarView: View { } @ViewBuilder - private var maskedTabBarChrome: some View { + private var maskedSelectedTabIndicatorChrome: some View { GeometryReader { geometry in - tabBarChrome(totalWidth: geometry.size.width) + selectedTabIndicator(totalWidth: geometry.size.width) + .frame(width: geometry.size.width, height: tabBarHeight, alignment: .topLeading) .mask(combinedMask) } .allowsHitTesting(false) } @ViewBuilder - private func tabBarChrome(totalWidth: CGFloat) -> some View { - // Bar-edge chrome uses the same mask as scroll content, so selected - // indicators, separators, and tab titles fade together under controls. - ZStack(alignment: .topLeading) { - selectedTabIndicator(totalWidth: totalWidth) - tabBarBottomSeparator(totalWidth: totalWidth) + private var maskedTabBarBottomSeparatorChrome: some View { + GeometryReader { geometry in + tabBarBottomSeparator(totalWidth: geometry.size.width) + .mask(combinedMask) } - .frame(height: tabBarHeight) + .allowsHitTesting(false) } @ViewBuilder diff --git a/Sources/Bonsplit/Public/BonsplitConfiguration.swift b/Sources/Bonsplit/Public/BonsplitConfiguration.swift index 5f875191..d0d87630 100644 --- a/Sources/Bonsplit/Public/BonsplitConfiguration.swift +++ b/Sources/Bonsplit/Public/BonsplitConfiguration.swift @@ -351,6 +351,7 @@ extension BonsplitConfiguration { public var fadeWidth: CGFloat public var contentFadeWidth: CGFloat public var solidWidth: CGFloat + public var separatorFadeWidth: CGFloat? public var fadeRampStartFraction: CGFloat public var leadingOpacity: CGFloat public var trailingOpacity: CGFloat @@ -362,6 +363,7 @@ extension BonsplitConfiguration { fadeWidth: CGFloat = 136, contentFadeWidth: CGFloat = 42, solidWidth: CGFloat = 2, + separatorFadeWidth: CGFloat? = nil, fadeRampStartFraction: CGFloat = 0.80, leadingOpacity: CGFloat = 0, trailingOpacity: CGFloat = 0.80, @@ -372,6 +374,7 @@ extension BonsplitConfiguration { self.fadeWidth = max(0, fadeWidth) self.contentFadeWidth = max(0, contentFadeWidth) self.solidWidth = max(0, solidWidth) + self.separatorFadeWidth = separatorFadeWidth.map { max(0, $0) } self.fadeRampStartFraction = min(max(0, fadeRampStartFraction), 0.95) self.leadingOpacity = min(max(0, leadingOpacity), 1) self.trailingOpacity = min(max(0, trailingOpacity), 1) diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 0c8b831f..236b5ad0 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -881,13 +881,16 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(effect.fadeWidth, 80) XCTAssertEqual(effect.contentFadeWidth, 42) XCTAssertEqual(effect.solidWidth, 32) + XCTAssertNil(effect.separatorFadeWidth) XCTAssertEqual(effect.fadeRampStartFraction, 0.58) XCTAssertEqual(effect.contentOcclusionFraction, 0.25) let clamped = BonsplitConfiguration.Appearance.SplitButtonBackdropEffect( + separatorFadeWidth: -4, fadeRampStartFraction: 1.4, contentOcclusionFraction: 2.2 ) + XCTAssertEqual(clamped.separatorFadeWidth, 0) XCTAssertEqual(clamped.fadeRampStartFraction, 0.95) XCTAssertEqual(clamped.contentOcclusionFraction, 1.0) } @@ -1802,6 +1805,35 @@ final class BonsplitTests: XCTestCase { ) } + func testSharedBackdropActionLaneSeparatorCanBeNarrowerThanContentFade() { + let buttonCount = 28 + let size = NSSize(width: 360, height: 28) + let layout = TabBarLayout( + tabBarHeight: size.height, + availableWidth: size.width, + splitButtonCount: buttonCount, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: TabBarStyling.splitButtonsBackdropWidth(buttonCount: buttonCount) + ) + let appearance = sharedBackdropManyActionAppearance( + tabBarHeight: size.height, + buttonCount: buttonCount, + separatorFadeWidth: 12 + ) + let snapshot = TabBarChromeSnapshot( + appearance: appearance, + layout: layout, + isFocused: true, + shouldShowSplitButtons: true, + fadeColorStyle: 0 + ) + + XCTAssertEqual(snapshot.contentFadeWidth, 28.875, accuracy: 0.0001) + XCTAssertEqual(snapshot.actionLaneSeparatorFadeWidth, 12, accuracy: 0.0001) + XCTAssertLessThan(snapshot.actionLaneSeparatorFadeWidth, snapshot.contentFadeWidth) + } + func testTabBarSeparatorSegmentsClampGapIntoBounds() { var segments = TabBarStyling.separatorSegments(totalWidth: 100, gap: -20...40) XCTAssertEqual(segments.left, 0, accuracy: 0.0001) @@ -2781,7 +2813,8 @@ final class BonsplitTests: XCTestCase { tabBarHeight: CGFloat, buttonCount: Int, borderHex: String = "#66666680", - tabMaxWidth: CGFloat = 220 + tabMaxWidth: CGFloat = 220, + separatorFadeWidth: CGFloat? = nil ) -> BonsplitConfiguration.Appearance { BonsplitConfiguration.Appearance( tabBarHeight: tabBarHeight, @@ -2792,6 +2825,7 @@ final class BonsplitTests: XCTestCase { fadeWidth: 99.75, contentFadeWidth: 28.875, solidWidth: 23.875, + separatorFadeWidth: separatorFadeWidth, fadeRampStartFraction: 0.60, leadingOpacity: 0, trailingOpacity: 0.8625, From 6dac95f50f502ed3e8039bae345d991f0c1f6b3b Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 04:22:14 -0700 Subject: [PATCH 23/29] Unify action lane geometry --- .../Bonsplit/Internal/Views/TabBarView.swift | 228 ++++++++++++++---- .../Public/BonsplitConfiguration.swift | 5 + Tests/BonsplitTests/BonsplitTests.swift | 82 ++++++- 3 files changed, 251 insertions(+), 64 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 6724f98a..6f1e3272 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -215,12 +215,10 @@ enum TabBarStyling { static func splitButtonBackdropSolidSurfaceWidth( effectSolidWidth: CGFloat, visibleLaneWidth: CGFloat, - contentOcclusionWidth: CGFloat, - contentFadeWidth: CGFloat + solidSurfaceWidthAdjustment: CGFloat ) -> CGFloat { - let controlSurfaceWidth = max(max(0, visibleLaneWidth), max(0, contentOcclusionWidth)) - + max(0, contentFadeWidth) - return max(max(0, effectSolidWidth), controlSurfaceWidth) + let adjustedLaneWidth = max(0, visibleLaneWidth + solidSurfaceWidthAdjustment) + return max(max(0, effectSolidWidth), adjustedLaneWidth) } static func splitButtonContentOcclusionWidth( @@ -414,8 +412,106 @@ struct TabBarLayout: Equatable { } } +struct TabBarActionLaneGeometry: Equatable { + let buttonViewportWidth: CGFloat + let contentFadeWidth: CGFloat + let contentOcclusionWidth: CGFloat + let backgroundFadeWidth: CGFloat + let backgroundSolidWidth: CGFloat + let separatorFadeWidth: CGFloat + let backgroundFadeRampStartFraction: CGFloat + + init( + layout: TabBarLayout, + effect: BonsplitConfiguration.Appearance.SplitButtonBackdropEffect, + masksTabContent: Bool + ) { + self.buttonViewportWidth = layout.visibleSplitButtonLaneWidth + self.contentFadeWidth = masksTabContent ? effect.contentFadeWidth : 0 + self.contentOcclusionWidth = masksTabContent + ? TabBarStyling.splitButtonContentOcclusionWidth( + visibleLaneWidth: layout.visibleSplitButtonLaneWidth, + contentOcclusionFraction: effect.contentOcclusionFraction + ) + : 0 + self.backgroundFadeWidth = max(0, effect.fadeWidth) + self.backgroundSolidWidth = TabBarStyling.splitButtonBackdropSolidSurfaceWidth( + effectSolidWidth: effect.solidWidth, + visibleLaneWidth: layout.visibleSplitButtonLaneWidth, + solidSurfaceWidthAdjustment: effect.solidSurfaceWidthAdjustment + ) + let rampStart = min(max(0, effect.fadeRampStartFraction), 0.95) + self.backgroundFadeRampStartFraction = rampStart + let defaultSeparatorFadeWidth = masksTabContent + ? self.contentFadeWidth + : self.backgroundFadeWidth * (1 - rampStart) + self.separatorFadeWidth = min( + defaultSeparatorFadeWidth, + effect.separatorFadeWidth ?? defaultSeparatorFadeWidth + ) + } + + var separatorTotalWidth: CGFloat { + separatorFadeWidth + backgroundSolidWidth + } + + func backgroundFadeFrame(totalWidth: CGFloat, height: CGFloat) -> CGRect { + let width = max(0, backgroundFadeWidth) + return CGRect( + x: totalWidth - backgroundSolidWidth - width, + y: 0, + width: width, + height: height + ) + } + + func backgroundSolidFrame(totalWidth: CGFloat, height: CGFloat) -> CGRect { + let width = max(0, backgroundSolidWidth) + return CGRect( + x: totalWidth - width, + y: 0, + width: width, + height: height + ) + } + + func separatorFadeFrame(totalWidth: CGFloat, height: CGFloat) -> CGRect { + let width = max(0, separatorFadeWidth) + return CGRect( + x: totalWidth - backgroundSolidWidth - width, + y: height - 1, + width: width, + height: 1 + ) + } + + func separatorSolidFrame(totalWidth: CGFloat, height: CGFloat) -> CGRect { + let solid = backgroundSolidFrame(totalWidth: totalWidth, height: height) + return CGRect(x: solid.minX, y: height - 1, width: solid.width, height: 1) + } + + func separatorCoverageFrame(totalWidth: CGFloat, height: CGFloat) -> CGRect { + let width = separatorTotalWidth + return CGRect(x: totalWidth - width, y: height - 1, width: width, height: 1) + } + + func fallbackSeparatorMaskFrame( + totalWidth: CGFloat, + height: CGFloat, + selectedSeparatorGap: ClosedRange? + ) -> CGRect? { + guard let selectedSeparatorGap else { return nil } + let coverage = separatorCoverageFrame(totalWidth: totalWidth, height: height) + let start = max(coverage.minX, selectedSeparatorGap.lowerBound) + let end = min(coverage.maxX, selectedSeparatorGap.upperBound) + guard end > start else { return nil } + return CGRect(x: start, y: height - 1, width: end - start, height: 1) + } +} + struct TabBarChromeSnapshot { let layout: TabBarLayout + let actionLaneGeometry: TabBarActionLaneGeometry let barColor: NSColor let actionLaneWidth: CGFloat let paintsActionLaneSurface: Bool @@ -438,9 +534,7 @@ struct TabBarChromeSnapshot { } var actionLaneSeparatorSolidWidth: CGFloat { - masksTabContentUnderActionLane - ? max(actionLaneWidth, contentOcclusionWidth) - : backdropSolidWidth + actionLaneGeometry.backgroundSolidWidth } init( @@ -451,7 +545,6 @@ struct TabBarChromeSnapshot { fadeColorStyle: Int ) { self.layout = layout - self.actionLaneWidth = layout.visibleSplitButtonLaneWidth let baseBarColor = TabBarColors.nsColorBarBackground(for: appearance) self.barColor = appearance.usesSharedBackdrop || isFocused @@ -479,29 +572,19 @@ struct TabBarChromeSnapshot { self.paintsActionLaneSurface = canUseActionLaneChrome && TabBarColors.shouldPaintSplitButtonBackdrop(for: appearance) self.masksTabContentUnderActionLane = canUseActionLaneChrome && effect.masksTabContent - self.contentFadeWidth = masksTabContentUnderActionLane ? effect.contentFadeWidth : 0 - let contentOcclusionWidth = masksTabContentUnderActionLane - ? TabBarStyling.splitButtonContentOcclusionWidth( - visibleLaneWidth: actionLaneWidth, - contentOcclusionFraction: effect.contentOcclusionFraction - ) - : 0 - self.contentOcclusionWidth = contentOcclusionWidth - self.backdropFadeWidth = max(0, effect.fadeWidth) - self.backdropSolidWidth = TabBarStyling.splitButtonBackdropSolidSurfaceWidth( - effectSolidWidth: effect.solidWidth, - visibleLaneWidth: actionLaneWidth, - contentOcclusionWidth: contentOcclusionWidth, - contentFadeWidth: contentFadeWidth + let geometry = TabBarActionLaneGeometry( + layout: layout, + effect: effect, + masksTabContent: masksTabContentUnderActionLane ) + self.actionLaneGeometry = geometry + self.actionLaneWidth = geometry.buttonViewportWidth + self.contentFadeWidth = geometry.contentFadeWidth + self.contentOcclusionWidth = geometry.contentOcclusionWidth + self.backdropFadeWidth = geometry.backgroundFadeWidth + self.backdropSolidWidth = geometry.backgroundSolidWidth self.backdropFadeRampStartFraction = min(max(0, effect.fadeRampStartFraction), 0.95) - let defaultSeparatorFadeWidth = masksTabContentUnderActionLane - ? contentFadeWidth - : self.backdropFadeWidth * (1 - self.backdropFadeRampStartFraction) - self.actionLaneSeparatorFadeWidth = min( - defaultSeparatorFadeWidth, - effect.separatorFadeWidth ?? defaultSeparatorFadeWidth - ) + self.actionLaneSeparatorFadeWidth = geometry.separatorFadeWidth self.backdropLeadingColor = colors.leading self.backdropTrailingColor = colors.trailing } @@ -1256,32 +1339,40 @@ struct TabBarView: View { private var splitButtonBackdropChrome: some View { let snapshot = chromeSnapshot if snapshot.drawsActionLaneSeparator { - ZStack(alignment: .trailing) { - if snapshot.paintsActionLaneSurface { - HStack(spacing: 0) { - if snapshot.backdropFadeWidth > 0 { - splitButtonBackdropFadeSegment(snapshot: snapshot) - } - TabBarLayerBackedColor(color: snapshot.backdropTrailingColor) - .frame(width: snapshot.backdropSolidWidth, height: tabBarHeight) + GeometryReader { geometry in + ZStack(alignment: .topLeading) { + if snapshot.paintsActionLaneSurface { + splitButtonBackdropSurface(snapshot: snapshot, totalWidth: geometry.size.width) } - .frame(height: tabBarHeight) - } - HStack(spacing: 0) { - if snapshot.actionLaneSeparatorFadeWidth > 0 { - splitButtonSeparatorFadeSegment(snapshot: snapshot) - } - Rectangle() - .fill(TabBarColors.separator(for: appearance)) - .frame(width: snapshot.actionLaneSeparatorSolidWidth, height: 1) + splitButtonFallbackSeparator(snapshot: snapshot, totalWidth: geometry.size.width) } - .frame(height: tabBarHeight, alignment: .bottom) + .frame(width: geometry.size.width, height: tabBarHeight, alignment: .topLeading) } .frame(height: tabBarHeight) } } + @ViewBuilder + private func splitButtonBackdropSurface(snapshot: TabBarChromeSnapshot, totalWidth: CGFloat) -> some View { + let geometry = snapshot.actionLaneGeometry + let fadeFrame = geometry.backgroundFadeFrame(totalWidth: totalWidth, height: tabBarHeight) + let solidFrame = geometry.backgroundSolidFrame(totalWidth: totalWidth, height: tabBarHeight) + ZStack(alignment: .topLeading) { + if fadeFrame.width > 0 { + splitButtonBackdropFadeSegment(snapshot: snapshot) + .frame(width: fadeFrame.width, height: fadeFrame.height) + .offset(x: fadeFrame.minX, y: fadeFrame.minY) + } + if solidFrame.width > 0 { + TabBarLayerBackedColor(color: snapshot.backdropTrailingColor) + .frame(width: solidFrame.width, height: solidFrame.height) + .offset(x: solidFrame.minX, y: solidFrame.minY) + } + } + .frame(width: totalWidth, height: tabBarHeight, alignment: .topLeading) + } + @ViewBuilder private func splitButtonBackdropFadeSegment(snapshot: TabBarChromeSnapshot) -> some View { let rampStart = snapshot.backdropFadeRampStartFraction @@ -1294,11 +1385,46 @@ struct TabBarView: View { startPoint: .leading, endPoint: .trailing ) - .frame(width: snapshot.backdropFadeWidth, height: tabBarHeight) } @ViewBuilder - private func splitButtonSeparatorFadeSegment(snapshot: TabBarChromeSnapshot) -> some View { + private func splitButtonFallbackSeparator(snapshot: TabBarChromeSnapshot, totalWidth: CGFloat) -> some View { + let geometry = snapshot.actionLaneGeometry + let selectedGap = tabBarLayout.selectedSeparatorGap( + selectedTabFrame: selectedTabFrameInBar, + totalWidth: totalWidth + ) + if let maskFrame = geometry.fallbackSeparatorMaskFrame( + totalWidth: totalWidth, + height: tabBarHeight, + selectedSeparatorGap: selectedGap + ) { + ZStack(alignment: .topLeading) { + let fadeFrame = geometry.separatorFadeFrame(totalWidth: totalWidth, height: tabBarHeight) + if fadeFrame.width > 0 { + splitButtonSeparatorFadeSegment(snapshot: snapshot) + .frame(width: fadeFrame.width, height: fadeFrame.height) + .offset(x: fadeFrame.minX, y: fadeFrame.minY) + } + let solidFrame = geometry.separatorSolidFrame(totalWidth: totalWidth, height: tabBarHeight) + if solidFrame.width > 0 { + Rectangle() + .fill(TabBarColors.separator(for: appearance)) + .frame(width: solidFrame.width, height: solidFrame.height) + .offset(x: solidFrame.minX, y: solidFrame.minY) + } + } + .frame(width: totalWidth, height: tabBarHeight, alignment: .topLeading) + .mask(alignment: .topLeading) { + Rectangle() + .frame(width: maskFrame.width, height: maskFrame.height) + .offset(x: maskFrame.minX, y: maskFrame.minY) + } + } + } + + @ViewBuilder + private func splitButtonSeparatorFadeSegment(snapshot _: TabBarChromeSnapshot) -> some View { let separator = TabBarColors.separator(for: appearance) LinearGradient( colors: [ @@ -1308,7 +1434,6 @@ struct TabBarView: View { startPoint: .leading, endPoint: .trailing ) - .frame(width: snapshot.actionLaneSeparatorFadeWidth, height: 1) } @ViewBuilder @@ -1558,7 +1683,6 @@ struct TabBarView: View { private var maskedTabBarBottomSeparatorChrome: some View { GeometryReader { geometry in tabBarBottomSeparator(totalWidth: geometry.size.width) - .mask(combinedMask) } .allowsHitTesting(false) } diff --git a/Sources/Bonsplit/Public/BonsplitConfiguration.swift b/Sources/Bonsplit/Public/BonsplitConfiguration.swift index d0d87630..632ba937 100644 --- a/Sources/Bonsplit/Public/BonsplitConfiguration.swift +++ b/Sources/Bonsplit/Public/BonsplitConfiguration.swift @@ -351,6 +351,7 @@ extension BonsplitConfiguration { public var fadeWidth: CGFloat public var contentFadeWidth: CGFloat public var solidWidth: CGFloat + public var solidSurfaceWidthAdjustment: CGFloat public var separatorFadeWidth: CGFloat? public var fadeRampStartFraction: CGFloat public var leadingOpacity: CGFloat @@ -363,6 +364,7 @@ extension BonsplitConfiguration { fadeWidth: CGFloat = 136, contentFadeWidth: CGFloat = 42, solidWidth: CGFloat = 2, + solidSurfaceWidthAdjustment: CGFloat = 0, separatorFadeWidth: CGFloat? = nil, fadeRampStartFraction: CGFloat = 0.80, leadingOpacity: CGFloat = 0, @@ -374,6 +376,9 @@ extension BonsplitConfiguration { self.fadeWidth = max(0, fadeWidth) self.contentFadeWidth = max(0, contentFadeWidth) self.solidWidth = max(0, solidWidth) + self.solidSurfaceWidthAdjustment = solidSurfaceWidthAdjustment.isFinite + ? solidSurfaceWidthAdjustment + : 0 self.separatorFadeWidth = separatorFadeWidth.map { max(0, $0) } self.fadeRampStartFraction = min(max(0, fadeRampStartFraction), 0.95) self.leadingOpacity = min(max(0, leadingOpacity), 1) diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 236b5ad0..b0f77053 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -442,23 +442,21 @@ final class BonsplitTests: XCTestCase { TabBarStyling.splitButtonBackdropSolidSurfaceWidth( effectSolidWidth: 2, visibleLaneWidth: 90, - contentOcclusionWidth: 60, - contentFadeWidth: 42 + solidSurfaceWidthAdjustment: 0 ), - 132 + 90 ) XCTAssertEqual( TabBarStyling.splitButtonBackdropSolidSurfaceWidth( effectSolidWidth: 96, visibleLaneWidth: 72, - contentOcclusionWidth: 60, - contentFadeWidth: 24 + solidSurfaceWidthAdjustment: 0 ), 96 ) } - func testSplitButtonContentOcclusionFractionLimitsSolidSurface() { + func testSplitButtonContentOcclusionFractionDoesNotChangeSolidSurface() { let occlusion = TabBarStyling.splitButtonContentOcclusionWidth( visibleLaneWidth: 200, contentOcclusionFraction: 0.25 @@ -469,10 +467,28 @@ final class BonsplitTests: XCTestCase { TabBarStyling.splitButtonBackdropSolidSurfaceWidth( effectSolidWidth: 2, visibleLaneWidth: 200, - contentOcclusionWidth: occlusion, - contentFadeWidth: 24 + solidSurfaceWidthAdjustment: 0 ), - 224 + 200 + ) + } + + func testSplitButtonBackdropSolidSurfaceWidthCanBeAdjusted() { + XCTAssertEqual( + TabBarStyling.splitButtonBackdropSolidSurfaceWidth( + effectSolidWidth: 2, + visibleLaneWidth: 90, + solidSurfaceWidthAdjustment: 12 + ), + 102 + ) + XCTAssertEqual( + TabBarStyling.splitButtonBackdropSolidSurfaceWidth( + effectSolidWidth: 2, + visibleLaneWidth: 90, + solidSurfaceWidthAdjustment: -12 + ), + 78 ) } @@ -874,6 +890,7 @@ final class BonsplitTests: XCTestCase { fadeWidth: 80, contentFadeWidth: 42, solidWidth: 32, + solidSurfaceWidthAdjustment: 7, fadeRampStartFraction: 0.58, contentOcclusionFraction: 0.25 ) @@ -881,15 +898,18 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(effect.fadeWidth, 80) XCTAssertEqual(effect.contentFadeWidth, 42) XCTAssertEqual(effect.solidWidth, 32) + XCTAssertEqual(effect.solidSurfaceWidthAdjustment, 7) XCTAssertNil(effect.separatorFadeWidth) XCTAssertEqual(effect.fadeRampStartFraction, 0.58) XCTAssertEqual(effect.contentOcclusionFraction, 0.25) let clamped = BonsplitConfiguration.Appearance.SplitButtonBackdropEffect( + solidSurfaceWidthAdjustment: .infinity, separatorFadeWidth: -4, fadeRampStartFraction: 1.4, contentOcclusionFraction: 2.2 ) + XCTAssertEqual(clamped.solidSurfaceWidthAdjustment, 0) XCTAssertEqual(clamped.separatorFadeWidth, 0) XCTAssertEqual(clamped.fadeRampStartFraction, 0.95) XCTAssertEqual(clamped.contentOcclusionFraction, 1.0) @@ -1706,13 +1726,13 @@ final class BonsplitTests: XCTestCase { } @MainActor - func testSplitButtonBackdropSolidSurfaceCoversTabContentFadeStart() { + func testSplitButtonBackdropDoesNotPaintSolidSurfaceAtTabContentFadeStart() { guard let brightness = renderedSplitButtonContentFadeStartBackdropBrightness() else { XCTFail("Expected rendered split button content fade backdrop color") return } - XCTAssertGreaterThan(brightness, 0.5) + XCTAssertLessThan(brightness, 0.1) } @MainActor @@ -1797,7 +1817,7 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(snapshot.actionLaneSeparatorFadeWidth, snapshot.contentFadeWidth, accuracy: 0.0001) XCTAssertEqual(snapshot.actionLaneSeparatorSolidWidth, snapshot.actionLaneWidth, accuracy: 0.0001) - XCTAssertEqual(snapshot.backdropSolidWidth, snapshot.actionLaneWidth + snapshot.contentFadeWidth, accuracy: 0.0001) + XCTAssertEqual(snapshot.backdropSolidWidth, snapshot.actionLaneWidth, accuracy: 0.0001) XCTAssertEqual( snapshot.actionLaneSeparatorFadeWidth + snapshot.actionLaneSeparatorSolidWidth, snapshot.contentFadeWidth + snapshot.actionLaneWidth, @@ -1834,6 +1854,44 @@ final class BonsplitTests: XCTestCase { XCTAssertLessThan(snapshot.actionLaneSeparatorFadeWidth, snapshot.contentFadeWidth) } + func testActionLaneFallbackSeparatorClipsToSelectedSeparatorGap() { + let buttonCount = 28 + let size = NSSize(width: 360, height: 28) + let layout = TabBarLayout( + tabBarHeight: size.height, + availableWidth: size.width, + splitButtonCount: buttonCount, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: TabBarStyling.splitButtonsBackdropWidth(buttonCount: buttonCount) + ) + let snapshot = TabBarChromeSnapshot( + appearance: sharedBackdropManyActionAppearance( + tabBarHeight: size.height, + buttonCount: buttonCount, + separatorFadeWidth: 12 + ), + layout: layout, + isFocused: true, + shouldShowSplitButtons: true, + fadeColorStyle: 0 + ) + let geometry = snapshot.actionLaneGeometry + let mask = geometry.fallbackSeparatorMaskFrame( + totalWidth: size.width, + height: size.height, + selectedSeparatorGap: 300...340 + ) + + XCTAssertEqual(mask?.minX ?? -1, 300, accuracy: 0.0001) + XCTAssertEqual(mask?.maxX ?? -1, 340, accuracy: 0.0001) + XCTAssertNil(geometry.fallbackSeparatorMaskFrame( + totalWidth: size.width, + height: size.height, + selectedSeparatorGap: 0...40 + )) + } + func testTabBarSeparatorSegmentsClampGapIntoBounds() { var segments = TabBarStyling.separatorSegments(totalWidth: 100, gap: -20...40) XCTAssertEqual(segments.left, 0, accuracy: 0.0001) From 819688a433e0196d07d6781f751a63663f523b0a Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 04:39:40 -0700 Subject: [PATCH 24/29] Cover overflowing action lane surface --- .../Bonsplit/Internal/Views/TabBarView.swift | 9 +++- Tests/BonsplitTests/BonsplitTests.swift | 48 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 6f1e3272..8acf0c52 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -369,6 +369,10 @@ struct TabBarLayout: Equatable { min(fullSplitButtonLaneWidth, maximumSplitButtonLaneWidth) } + var splitButtonLaneOverflowsViewport: Bool { + fullSplitButtonLaneWidth > visibleSplitButtonLaneWidth + 1 + } + var trailingTabContentInset: CGFloat { reservesSplitButtonLane ? visibleSplitButtonLaneWidth : 0 } @@ -435,10 +439,13 @@ struct TabBarActionLaneGeometry: Equatable { ) : 0 self.backgroundFadeWidth = max(0, effect.fadeWidth) + let solidSurfaceWidthAdjustment = layout.splitButtonLaneOverflowsViewport + ? max(0, effect.solidSurfaceWidthAdjustment) + : effect.solidSurfaceWidthAdjustment self.backgroundSolidWidth = TabBarStyling.splitButtonBackdropSolidSurfaceWidth( effectSolidWidth: effect.solidWidth, visibleLaneWidth: layout.visibleSplitButtonLaneWidth, - solidSurfaceWidthAdjustment: effect.solidSurfaceWidthAdjustment + solidSurfaceWidthAdjustment: solidSurfaceWidthAdjustment ) let rampStart = min(max(0, effect.fadeRampStartFraction), 0.95) self.backgroundFadeRampStartFraction = rampStart diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index b0f77053..dd2d34a3 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -419,6 +419,7 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(layout.maximumSplitButtonLaneWidth, 60) XCTAssertEqual(layout.visibleSplitButtonLaneWidth, 60) XCTAssertEqual(layout.trailingTabContentInset, 60) + XCTAssertTrue(layout.splitButtonLaneOverflowsViewport) } func testTabBarLayoutKeepsMeasuredLaneWhenItFitsQuarterOfAvailableWidth() { @@ -435,6 +436,53 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(layout.maximumSplitButtonLaneWidth, 200) XCTAssertEqual(layout.visibleSplitButtonLaneWidth, 160) XCTAssertEqual(layout.trailingTabContentInset, 160) + XCTAssertFalse(layout.splitButtonLaneOverflowsViewport) + } + + func testActionLaneSolidSurfaceCoversVisibleViewportWhenButtonsOverflow() { + let layout = TabBarLayout( + tabBarHeight: 28, + availableWidth: 240, + splitButtonCount: 12, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: 400 + ) + let effect = BonsplitConfiguration.Appearance.SplitButtonBackdropEffect( + solidWidth: 23.875, + solidSurfaceWidthAdjustment: -53 + ) + let geometry = TabBarActionLaneGeometry( + layout: layout, + effect: effect, + masksTabContent: true + ) + + XCTAssertEqual(layout.visibleSplitButtonLaneWidth, 60, accuracy: 0.0001) + XCTAssertEqual(geometry.backgroundSolidWidth, 60, accuracy: 0.0001) + } + + func testActionLaneSolidSurfaceAllowsTrimWhenButtonsDoNotOverflow() { + let layout = TabBarLayout( + tabBarHeight: 28, + availableWidth: 800, + splitButtonCount: 4, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: 160 + ) + let effect = BonsplitConfiguration.Appearance.SplitButtonBackdropEffect( + solidWidth: 23.875, + solidSurfaceWidthAdjustment: -53 + ) + let geometry = TabBarActionLaneGeometry( + layout: layout, + effect: effect, + masksTabContent: true + ) + + XCTAssertEqual(layout.visibleSplitButtonLaneWidth, 160, accuracy: 0.0001) + XCTAssertEqual(geometry.backgroundSolidWidth, 107, accuracy: 0.0001) } func testSplitButtonBackdropSolidSurfaceCoversVisibleActionLane() { From 66351688ea3ef5c02285e4b1ce064ccaa8cdc880 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 05:32:15 -0700 Subject: [PATCH 25/29] Add action lane overflow regression coverage --- Tests/BonsplitTests/BonsplitTests.swift | 127 ++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 11 deletions(-) diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index dd2d34a3..678561f9 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -450,7 +450,8 @@ final class BonsplitTests: XCTestCase { ) let effect = BonsplitConfiguration.Appearance.SplitButtonBackdropEffect( solidWidth: 23.875, - solidSurfaceWidthAdjustment: -53 + solidSurfaceWidthAdjustment: -53, + contentOcclusionFraction: 0.6875 ) let geometry = TabBarActionLaneGeometry( layout: layout, @@ -460,6 +461,7 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(layout.visibleSplitButtonLaneWidth, 60, accuracy: 0.0001) XCTAssertEqual(geometry.backgroundSolidWidth, 60, accuracy: 0.0001) + XCTAssertEqual(geometry.contentOcclusionWidth, 60, accuracy: 0.0001) } func testActionLaneSolidSurfaceAllowsTrimWhenButtonsDoNotOverflow() { @@ -473,7 +475,8 @@ final class BonsplitTests: XCTestCase { ) let effect = BonsplitConfiguration.Appearance.SplitButtonBackdropEffect( solidWidth: 23.875, - solidSurfaceWidthAdjustment: -53 + solidSurfaceWidthAdjustment: -53, + contentOcclusionFraction: 0.6875 ) let geometry = TabBarActionLaneGeometry( layout: layout, @@ -483,6 +486,7 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(layout.visibleSplitButtonLaneWidth, 160, accuracy: 0.0001) XCTAssertEqual(geometry.backgroundSolidWidth, 107, accuracy: 0.0001) + XCTAssertEqual(geometry.contentOcclusionWidth, 110, accuracy: 0.0001) } func testSplitButtonBackdropSolidSurfaceCoversVisibleActionLane() { @@ -1829,6 +1833,16 @@ final class BonsplitTests: XCTestCase { XCTAssertLessThan(alpha, 0.05) } + @MainActor + func testOverflowingSplitButtonsClipToActionLane() { + guard let brightness = renderedEscapedSplitButtonBrightnessOutsideActionLane() else { + XCTFail("Expected rendered split button overflow colors") + return + } + + XCTAssertLessThan(brightness, 0.30) + } + @MainActor func testSharedBackdropActionLaneBottomSeparatorCoversSolidAreaAndFadesOut() { guard let alphas = renderedSharedBackdropActionLaneBottomSeparatorAlphas() else { @@ -1837,11 +1851,14 @@ final class BonsplitTests: XCTestCase { } XCTAssertGreaterThan(alphas.solid, 0.25) + XCTAssertLessThan(alphas.fadeStart, alphas.solid * 0.25) + XCTAssertLessThan(alphas.beforeRamp, alphas.solid * 0.25) + XCTAssertGreaterThan(alphas.afterRamp, alphas.solid * 0.25) XCTAssertGreaterThan(alphas.fadeEnd, alphas.solid * 0.55) XCTAssertEqual(alphas.fadeEnd, alphas.solidStart, accuracy: 0.15) } - func testSharedBackdropActionLaneSeparatorMatchesMaskedTabContentGeometry() { + func testSharedBackdropActionLaneSeparatorMatchesBackdropGradientGeometry() { let buttonCount = 28 let size = NSSize(width: 360, height: 28) let layout = TabBarLayout( @@ -1863,12 +1880,12 @@ final class BonsplitTests: XCTestCase { fadeColorStyle: 0 ) - XCTAssertEqual(snapshot.actionLaneSeparatorFadeWidth, snapshot.contentFadeWidth, accuracy: 0.0001) + XCTAssertEqual(snapshot.actionLaneSeparatorFadeWidth, snapshot.backdropFadeWidth, accuracy: 0.0001) XCTAssertEqual(snapshot.actionLaneSeparatorSolidWidth, snapshot.actionLaneWidth, accuracy: 0.0001) XCTAssertEqual(snapshot.backdropSolidWidth, snapshot.actionLaneWidth, accuracy: 0.0001) XCTAssertEqual( snapshot.actionLaneSeparatorFadeWidth + snapshot.actionLaneSeparatorSolidWidth, - snapshot.contentFadeWidth + snapshot.actionLaneWidth, + snapshot.backdropFadeWidth + snapshot.actionLaneWidth, accuracy: 0.0001 ) } @@ -2841,10 +2858,62 @@ final class BonsplitTests: XCTestCase { } } + @MainActor + private func renderedEscapedSplitButtonBrightnessOutsideActionLane() -> CGFloat? { + let buttonCount = 28 + let size = NSSize(width: 360, height: 28) + let splitButtonLaneWidth = visibleSplitButtonLaneWidth(size: size, buttonCount: buttonCount) + let appearance = BonsplitConfiguration.Appearance( + tabBarHeight: size.height, + tabMaxWidth: 40, + splitButtons: manySplitActionButtons(count: buttonCount), + splitButtonBackdropEffect: .init( + style: .translucentChrome, + fadeWidth: 99.75, + contentFadeWidth: 28.875, + solidWidth: 23.875, + fadeRampStartFraction: 0.60, + leadingOpacity: 0, + trailingOpacity: 0.8625, + contentOcclusionFraction: 0.6875, + masksTabContent: true + ), + chromeColors: .init( + backgroundHex: "#000000", + tabBarBackgroundHex: "#000000", + splitButtonBackdropHex: "#000000", + borderHex: "#00000000" + ) + ) + + return renderedTabBarValue( + isFocused: true, + appearance: appearance, + showSplitButtons: true, + size: size, + configurePane: { pane in + let selected = TabItem(title: "", icon: nil) + pane.tabs = [selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + maximumBrightness( + in: hostingView, + sampleRect: NSRect( + x: 100, + y: 5, + width: size.width - splitButtonLaneWidth - 108, + height: size.height - 10 + ) + ) + } + } + @MainActor private func renderedSharedBackdropActionLaneBottomSeparatorAlphas() -> ( fadeStart: CGFloat, - fadeMid: CGFloat, + beforeRamp: CGFloat, + afterRamp: CGFloat, fadeEnd: CGFloat, solidStart: CGFloat, solid: CGFloat @@ -2852,7 +2921,8 @@ final class BonsplitTests: XCTestCase { let buttonCount = 28 let size = NSSize(width: 360, height: 28) let splitButtonLaneWidth = visibleSplitButtonLaneWidth(size: size, buttonCount: buttonCount) - let contentFadeWidth: CGFloat = 28.875 + let separatorFadeWidth: CGFloat = 99.75 + let rampStartFraction: CGFloat = 0.60 let contentOcclusionWidth = TabBarStyling.splitButtonContentOcclusionWidth( visibleLaneWidth: splitButtonLaneWidth, contentOcclusionFraction: 0.6875 @@ -2881,15 +2951,20 @@ final class BonsplitTests: XCTestCase { } ) { hostingView in let separatorY = size.height - 0.5 - let fadeStartX = size.width - solidWidth - contentFadeWidth + let fadeStartX = size.width - solidWidth - separatorFadeWidth + let rampStartX = fadeStartX + separatorFadeWidth * rampStartFraction let solidStartX = size.width - solidWidth guard let fadeStart = renderedColorInViewCoordinates( in: hostingView, at: NSPoint(x: fadeStartX + 2, y: separatorY) )?.usingColorSpace(.sRGB)?.alphaComponent, - let fadeMid = renderedColorInViewCoordinates( + let beforeRamp = renderedColorInViewCoordinates( in: hostingView, - at: NSPoint(x: fadeStartX + (contentFadeWidth / 2), y: separatorY) + at: NSPoint(x: rampStartX - 2, y: separatorY) + )?.usingColorSpace(.sRGB)?.alphaComponent, + let afterRamp = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: rampStartX + 16, y: separatorY) )?.usingColorSpace(.sRGB)?.alphaComponent, let fadeEnd = renderedColorInViewCoordinates( in: hostingView, @@ -2907,7 +2982,8 @@ final class BonsplitTests: XCTestCase { } return ( fadeStart: fadeStart, - fadeMid: fadeMid, + beforeRamp: beforeRamp, + afterRamp: afterRamp, fadeEnd: fadeEnd, solidStart: solidStart, solid: solid @@ -3106,6 +3182,35 @@ final class BonsplitTests: XCTestCase { return maximum } + @MainActor + private func maximumBrightness(in view: NSView, sampleRect: NSRect) -> CGFloat? { + let integralBounds = view.bounds.integral + guard let bitmap = view.bitmapImageRepForCachingDisplay(in: integralBounds) else { return nil } + bitmap.size = integralBounds.size + view.cacheDisplay(in: integralBounds, to: bitmap) + + let scaleX = CGFloat(bitmap.pixelsWide) / max(1, integralBounds.width) + let scaleY = CGFloat(bitmap.pixelsHigh) / max(1, integralBounds.height) + let minX = max(0, Int(floor(sampleRect.minX * scaleX))) + let maxX = min(bitmap.pixelsWide, Int(ceil(sampleRect.maxX * scaleX))) + let minY = max(0, Int(floor(sampleRect.minY * scaleY))) + let maxY = min(bitmap.pixelsHigh, Int(ceil(sampleRect.maxY * scaleY))) + + var maximum: CGFloat = 0 + for y in minY.. 0.05 else { continue } + maximum = max( + maximum, + max(rgb.redComponent, rgb.greenComponent, rgb.blueComponent) + ) + } + } + return maximum + } + private func brightness(of color: NSColor) -> CGFloat { max(color.redComponent, color.greenComponent, color.blueComponent) } From cc0f548608843462b131f5e69856515181e3497b Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 05:32:20 -0700 Subject: [PATCH 26/29] Align action lane masking and separator fade --- .../Bonsplit/Internal/Views/TabBarView.swift | 107 +++++++++++------- 1 file changed, 63 insertions(+), 44 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 8acf0c52..3f842b4f 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -432,12 +432,17 @@ struct TabBarActionLaneGeometry: Equatable { ) { self.buttonViewportWidth = layout.visibleSplitButtonLaneWidth self.contentFadeWidth = masksTabContent ? effect.contentFadeWidth : 0 - self.contentOcclusionWidth = masksTabContent - ? TabBarStyling.splitButtonContentOcclusionWidth( + if masksTabContent { + let fractionalOcclusionWidth = TabBarStyling.splitButtonContentOcclusionWidth( visibleLaneWidth: layout.visibleSplitButtonLaneWidth, contentOcclusionFraction: effect.contentOcclusionFraction ) - : 0 + self.contentOcclusionWidth = layout.splitButtonLaneOverflowsViewport + ? layout.visibleSplitButtonLaneWidth + : fractionalOcclusionWidth + } else { + self.contentOcclusionWidth = 0 + } self.backgroundFadeWidth = max(0, effect.fadeWidth) let solidSurfaceWidthAdjustment = layout.splitButtonLaneOverflowsViewport ? max(0, effect.solidSurfaceWidthAdjustment) @@ -449,9 +454,7 @@ struct TabBarActionLaneGeometry: Equatable { ) let rampStart = min(max(0, effect.fadeRampStartFraction), 0.95) self.backgroundFadeRampStartFraction = rampStart - let defaultSeparatorFadeWidth = masksTabContent - ? self.contentFadeWidth - : self.backgroundFadeWidth * (1 - rampStart) + let defaultSeparatorFadeWidth = self.backgroundFadeWidth self.separatorFadeWidth = min( defaultSeparatorFadeWidth, effect.separatorFadeWidth ?? defaultSeparatorFadeWidth @@ -1021,6 +1024,12 @@ struct TabBarView: View { .overlay(maskedTabBarBottomSeparatorChrome) .overlay(alignment: .trailing) { splitButtonChrome + .frame(width: splitButtonsBackdropWidth, height: tabBarHeight, alignment: .trailing) + .mask { + Rectangle() + .frame(width: splitButtonsBackdropWidth, height: tabBarHeight) + } + .clipped() } .background(TabBarDragAndHoverView( isMinimalMode: isMinimalMode, @@ -1431,12 +1440,14 @@ struct TabBarView: View { } @ViewBuilder - private func splitButtonSeparatorFadeSegment(snapshot _: TabBarChromeSnapshot) -> some View { + private func splitButtonSeparatorFadeSegment(snapshot: TabBarChromeSnapshot) -> some View { let separator = TabBarColors.separator(for: appearance) + let rampStart = snapshot.backdropFadeRampStartFraction LinearGradient( - colors: [ - separator.opacity(0), - separator + stops: [ + .init(color: separator.opacity(0), location: 0), + .init(color: separator.opacity(0), location: rampStart), + .init(color: separator, location: 1) ], startPoint: .leading, endPoint: .trailing @@ -1445,41 +1456,49 @@ struct TabBarView: View { @ViewBuilder private var splitButtons: some View { - ScrollView(.horizontal, showsIndicators: false) { - splitButtonRow - .fixedSize(horizontal: true, vertical: false) - .frame(minWidth: splitButtonsBackdropWidth, alignment: .trailing) - .background(SplitButtonLaneWidthReader()) - .background( - GeometryReader { contentGeo in - Color.clear - .onChange( - of: contentGeo.frame(in: .named(splitButtonScrollCoordinateSpaceName)) - ) { _, newFrame in - updateSplitButtonScrollContent(frame: newFrame) - } - .onAppear { - updateSplitButtonScrollContent( - frame: contentGeo.frame(in: .named(splitButtonScrollCoordinateSpaceName)) - ) - } - } - ) - } - .coordinateSpace(name: splitButtonScrollCoordinateSpaceName) - .frame(width: splitButtonsBackdropWidth, height: tabBarHeight, alignment: .trailing) - .background( - GeometryReader { viewportGeo in - Color.clear - .onChange(of: viewportGeo.size.width) { _, newWidth in - splitButtonViewportWidth = newWidth - } - .onAppear { - splitButtonViewportWidth = viewportGeo.size.width - } + let laneWidth = splitButtonsBackdropWidth + ZStack(alignment: .trailing) { + ScrollView(.horizontal, showsIndicators: false) { + splitButtonRow + .fixedSize(horizontal: true, vertical: false) + .frame(minWidth: laneWidth, alignment: .trailing) + .background(SplitButtonLaneWidthReader()) + .background( + GeometryReader { contentGeo in + Color.clear + .onChange( + of: contentGeo.frame(in: .named(splitButtonScrollCoordinateSpaceName)) + ) { _, newFrame in + updateSplitButtonScrollContent(frame: newFrame) + } + .onAppear { + updateSplitButtonScrollContent( + frame: contentGeo.frame(in: .named(splitButtonScrollCoordinateSpaceName)) + ) + } + } + ) } - ) - .mask(splitButtonScrollMask) + .coordinateSpace(name: splitButtonScrollCoordinateSpaceName) + .frame(width: laneWidth, height: tabBarHeight, alignment: .trailing) + .background( + GeometryReader { viewportGeo in + Color.clear + .onChange(of: viewportGeo.size.width) { _, newWidth in + splitButtonViewportWidth = newWidth + } + .onAppear { + splitButtonViewportWidth = viewportGeo.size.width + } + } + ) + .mask( + splitButtonScrollMask + .frame(width: laneWidth, height: tabBarHeight) + ) + } + .frame(width: laneWidth, height: tabBarHeight, alignment: .trailing) + .contentShape(Rectangle()) .clipped() } From eeb04dd0895857cf75b862685eb02a77f70b4058 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 05:38:18 -0700 Subject: [PATCH 27/29] Make render samplers alpha-aware --- Tests/BonsplitTests/BonsplitTests.swift | 31 +++++++++++++++++++------ 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 678561f9..3e797f2a 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -3169,9 +3169,10 @@ final class BonsplitTests: XCTestCase { guard let color = bitmap.colorAt(x: x, y: y), let rgb = color.usingColorSpace(.sRGB), rgb.alphaComponent > 0.05 else { continue } - let red = rgb.redComponent - let green = rgb.greenComponent - let blue = rgb.blueComponent + let alpha = min(max(rgb.alphaComponent, 0), 1) + let red = rgb.redComponent * alpha + let green = rgb.greenComponent * alpha + let blue = rgb.blueComponent * alpha let high = max(red, green, blue) guard high > 0.01 else { continue } let low = min(red, green, blue) @@ -3202,9 +3203,14 @@ final class BonsplitTests: XCTestCase { guard let color = bitmap.colorAt(x: x, y: y), let rgb = color.usingColorSpace(.sRGB), rgb.alphaComponent > 0.05 else { continue } + let alpha = min(max(rgb.alphaComponent, 0), 1) maximum = max( maximum, - max(rgb.redComponent, rgb.greenComponent, rgb.blueComponent) + max( + rgb.redComponent * alpha, + rgb.greenComponent * alpha, + rgb.blueComponent * alpha + ) ) } } @@ -3212,7 +3218,14 @@ final class BonsplitTests: XCTestCase { } private func brightness(of color: NSColor) -> CGFloat { - max(color.redComponent, color.greenComponent, color.blueComponent) + guard let rgb = color.usingColorSpace(.sRGB) else { return 0 } + let alpha = min(max(rgb.alphaComponent, 0), 1) + guard alpha > 0.01 else { return 0 } + return max( + rgb.redComponent * alpha, + rgb.greenComponent * alpha, + rgb.blueComponent * alpha + ) } @MainActor @@ -3236,9 +3249,13 @@ final class BonsplitTests: XCTestCase { guard let color = bitmap.colorAt(x: x, y: y), let rgb = color.usingColorSpace(.sRGB), rgb.alphaComponent > 0.05 else { continue } - let high = max(rgb.redComponent, rgb.greenComponent, rgb.blueComponent) + let alpha = min(max(rgb.alphaComponent, 0), 1) + let red = rgb.redComponent * alpha + let green = rgb.greenComponent * alpha + let blue = rgb.blueComponent * alpha + let high = max(red, green, blue) guard high > 0.01 else { continue } - let low = min(rgb.redComponent, rgb.greenComponent, rgb.blueComponent) + let low = min(red, green, blue) if (high - low) / high > 0.4 { hasIndicatorPixel = true break From 9d082c0830118179c5f161beede2484ec0a2eac0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 06:19:09 -0700 Subject: [PATCH 28/29] Keep legacy file-drop hover zones valid --- .../Internal/Views/PaneContainerView.swift | 39 +++++++++++++++++-- Tests/BonsplitTests/BonsplitTests.swift | 38 ++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/PaneContainerView.swift b/Sources/Bonsplit/Internal/Views/PaneContainerView.swift index 1a00eccb..a5754ee9 100644 --- a/Sources/Bonsplit/Internal/Views/PaneContainerView.swift +++ b/Sources/Bonsplit/Internal/Views/PaneContainerView.swift @@ -511,9 +511,21 @@ struct UnifiedPaneDropDelegate: DropDelegate { return DropProposal(operation: dropOperation(for: info)) } let zone = effectiveZone(for: info) - activeDropZone = zone + guard let acceptedZone = Self.acceptedDropZone( + zone, + isFileDropOnly: isFileDropOnly(info), + hasExternalFileDropHandler: bonsplitController.onExternalFileDrop != nil, + hasLegacyFileDropHandler: controller.onFileDrop != nil + ) else { + activeDropZone = nil +#if DEBUG + dlog("pane.dropUpdated pane=\(pane.id.id.uuidString.prefix(5)) zone=\(zone) allowed=0") +#endif + return DropProposal(operation: .cancel) + } + activeDropZone = acceptedZone #if DEBUG - dlog("pane.dropUpdated pane=\(pane.id.id.uuidString.prefix(5)) zone=\(zone)") + dlog("pane.dropUpdated pane=\(pane.id.id.uuidString.prefix(5)) zone=\(acceptedZone)") #endif return DropProposal(operation: dropOperation(for: info)) } @@ -576,12 +588,33 @@ struct UnifiedPaneDropDelegate: DropDelegate { return zone == .center } + static func acceptedDropZone( + _ zone: DropZone, + isFileDropOnly: Bool, + hasExternalFileDropHandler: Bool, + hasLegacyFileDropHandler: Bool + ) -> DropZone? { + if isFileDropOnly, + !acceptsFileDrop( + zone: zone, + hasExternalFileDropHandler: hasExternalFileDropHandler, + hasLegacyFileDropHandler: hasLegacyFileDropHandler + ) { + return nil + } + return zone + } + private func dropOperation(for info: DropInfo) -> DropOperation { - info.hasItemsConforming(to: [.fileURL]) && !info.hasItemsConforming(to: [.tabTransfer]) + isFileDropOnly(info) ? .copy : .move } + private func isFileDropOnly(_ info: DropInfo) -> Bool { + info.hasItemsConforming(to: [.fileURL]) && !info.hasItemsConforming(to: [.tabTransfer]) + } + private func destination( for zone: DropZone ) -> BonsplitController.ExternalTabDropRequest.Destination? { diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 3e797f2a..893702c1 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -1682,6 +1682,44 @@ final class BonsplitTests: XCTestCase { ) } + func testLegacyFileDropUpdatedRejectsEdgeZones() { + XCTAssertEqual( + UnifiedPaneDropDelegate.acceptedDropZone( + .center, + isFileDropOnly: true, + hasExternalFileDropHandler: false, + hasLegacyFileDropHandler: true + ), + .center + ) + XCTAssertNil( + UnifiedPaneDropDelegate.acceptedDropZone( + .left, + isFileDropOnly: true, + hasExternalFileDropHandler: false, + hasLegacyFileDropHandler: true + ) + ) + XCTAssertEqual( + UnifiedPaneDropDelegate.acceptedDropZone( + .left, + isFileDropOnly: true, + hasExternalFileDropHandler: true, + hasLegacyFileDropHandler: false + ), + .left + ) + XCTAssertEqual( + UnifiedPaneDropDelegate.acceptedDropZone( + .left, + isFileDropOnly: false, + hasExternalFileDropHandler: false, + hasLegacyFileDropHandler: false + ), + .left + ) + } + func testFileURLPasteboardReaderReturnsFileURLs() throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("bonsplit-file-drop-\(UUID().uuidString)", isDirectory: true) From 90f4981f6a990a2282b2433a67f32edc89306c36 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 1 May 2026 15:03:08 -0700 Subject: [PATCH 29/29] Keep file drops out of stale tab-drag state --- .../Internal/Views/PaneContainerView.swift | 180 ++++++++++-------- Tests/BonsplitTests/BonsplitTests.swift | 42 ++++ 2 files changed, 146 insertions(+), 76 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/PaneContainerView.swift b/Sources/Bonsplit/Internal/Views/PaneContainerView.swift index a5754ee9..6fef8e94 100644 --- a/Sources/Bonsplit/Internal/Views/PaneContainerView.swift +++ b/Sources/Bonsplit/Internal/Views/PaneContainerView.swift @@ -351,6 +351,9 @@ struct UnifiedPaneDropDelegate: DropDelegate { private func effectiveZone(for info: DropInfo) -> DropZone { let defaultZone = zoneForLocation(info.location) + guard !isFileDropOnly(info) else { + return defaultZone + } guard let draggedTab = controller.activeDragTab ?? controller.draggingTab, let sourcePaneId = controller.activeDragSourcePaneId ?? controller.dragSourcePaneId else { return defaultZone @@ -404,32 +407,75 @@ struct UnifiedPaneDropDelegate: DropDelegate { ) #endif - // Read from non-observable drag state — @Observable writes from createItemProvider + let hasTabTransfer = info.hasItemsConforming(to: [.tabTransfer]) + let hasFileURL = info.hasItemsConforming(to: [.fileURL]) + + // Read from non-observable drag state. @Observable writes from createItemProvider // may not have propagated yet when performDrop runs. - guard let draggedTab = controller.activeDragTab ?? controller.draggingTab, - let sourcePaneId = controller.activeDragSourcePaneId ?? controller.dragSourcePaneId else { - if info.hasItemsConforming(to: [.tabTransfer]) { - guard let transfer = decodeTransfer(from: info), - transfer.isFromCurrentProcess, - let destination = destination(for: zone) else { - return false + if Self.shouldUseLocalTabDrag( + hasTabTransfer: hasTabTransfer, + hasFileURL: hasFileURL, + hasLocalTabDrag: controller.activeDragTab != nil || controller.draggingTab != nil + ), + let draggedTab = controller.activeDragTab ?? controller.draggingTab, + let sourcePaneId = controller.activeDragSourcePaneId ?? controller.dragSourcePaneId { + // Clear both observable and non-observable drag state. + dropLifecycle = .idle + activeDropZone = nil + controller.draggingTab = nil + controller.dragSourcePaneId = nil + controller.activeDragTab = nil + controller.activeDragSourcePaneId = nil + + if zone == .center { + if sourcePaneId != pane.id { + withTransaction(Transaction(animation: nil)) { + _ = bonsplitController.moveTab( + TabID(id: draggedTab.id), + toPane: pane.id, + atIndex: nil + ) + } } - - let request = BonsplitController.ExternalTabDropRequest( - tabId: TabID(id: transfer.tab.id), - sourcePaneId: PaneID(id: transfer.sourcePaneId), - destination: destination + } else if let orientation = zone.orientation { +#if DEBUG + dlog( + "pane.drop.splitRequest targetPane=\(pane.id.id.uuidString.prefix(5)) " + + "sourcePane=\(sourcePaneId.id.uuidString.prefix(5)) zone=\(zone) " + + "orientation=\(orientation) insertFirst=\(zone.insertsFirst ? 1 : 0) " + + "draggedTab=\(draggedTab.id.uuidString.prefix(5))" ) - let handled = bonsplitController.onExternalTabDrop?(request) ?? false - if handled { - dropLifecycle = .idle - activeDropZone = nil - } - return handled +#endif + let newPaneId = bonsplitController.splitPane( + pane.id, + orientation: orientation, + movingTab: TabID(id: draggedTab.id), + insertFirst: zone.insertsFirst + ) +#if DEBUG + dlog( + "pane.drop.splitResult targetPane=\(pane.id.id.uuidString.prefix(5)) " + + "newPane=\(newPaneId?.id.uuidString.prefix(5) ?? "nil")" + ) +#endif } - guard info.hasItemsConforming(to: [.fileURL]) else { return false } - let handled = performFileDrop(info: info, zone: zone) + return true + } + + if info.hasItemsConforming(to: [.tabTransfer]) { + guard let transfer = decodeTransfer(from: info), + transfer.isFromCurrentProcess, + let destination = destination(for: zone) else { + return false + } + + let request = BonsplitController.ExternalTabDropRequest( + tabId: TabID(id: transfer.tab.id), + sourcePaneId: PaneID(id: transfer.sourcePaneId), + destination: destination + ) + let handled = bonsplitController.onExternalTabDrop?(request) ?? false if handled { dropLifecycle = .idle activeDropZone = nil @@ -437,48 +483,13 @@ struct UnifiedPaneDropDelegate: DropDelegate { return handled } - // Clear both observable and non-observable drag state. - dropLifecycle = .idle - activeDropZone = nil - controller.draggingTab = nil - controller.dragSourcePaneId = nil - controller.activeDragTab = nil - controller.activeDragSourcePaneId = nil - - if zone == .center { - if sourcePaneId != pane.id { - withTransaction(Transaction(animation: nil)) { - _ = bonsplitController.moveTab( - TabID(id: draggedTab.id), - toPane: pane.id, - atIndex: nil - ) - } - } - } else if let orientation = zone.orientation { -#if DEBUG - dlog( - "pane.drop.splitRequest targetPane=\(pane.id.id.uuidString.prefix(5)) " + - "sourcePane=\(sourcePaneId.id.uuidString.prefix(5)) zone=\(zone) " + - "orientation=\(orientation) insertFirst=\(zone.insertsFirst ? 1 : 0) " + - "draggedTab=\(draggedTab.id.uuidString.prefix(5))" - ) -#endif - let newPaneId = bonsplitController.splitPane( - pane.id, - orientation: orientation, - movingTab: TabID(id: draggedTab.id), - insertFirst: zone.insertsFirst - ) -#if DEBUG - dlog( - "pane.drop.splitResult targetPane=\(pane.id.id.uuidString.prefix(5)) " + - "newPane=\(newPaneId?.id.uuidString.prefix(5) ?? "nil")" - ) -#endif + guard info.hasItemsConforming(to: [.fileURL]) else { return false } + let handled = performFileDrop(info: info, zone: zone) + if handled { + dropLifecycle = .idle + activeDropZone = nil } - - return true + return handled } func dropEntered(info: DropInfo) { @@ -542,23 +553,21 @@ struct UnifiedPaneDropDelegate: DropDelegate { let hasFileURL = info.hasItemsConforming(to: [.fileURL]) guard hasTabTransfer || hasFileURL else { return false } - // Local drags use in-memory state and are always same-process. - if controller.activeDragTab != nil || controller.draggingTab != nil { - return true - } - - if hasTabTransfer { - // External drags (another Bonsplit controller) must include a payload from this process. - guard let transfer = decodeTransfer(from: info), - transfer.isFromCurrentProcess else { - return false - } - } else if hasFileURL { + if Self.isFileDropOnly(hasTabTransfer: hasTabTransfer, hasFileURL: hasFileURL) { guard Self.acceptsFileDrop( zone: effectiveZone(for: info), hasExternalFileDropHandler: bonsplitController.onExternalFileDrop != nil, hasLegacyFileDropHandler: controller.onFileDrop != nil - ) else { + ), Self.hasReadableFileURLs() else { + return false + } + } else if controller.activeDragTab != nil || controller.draggingTab != nil { + // Local tab drags use in-memory state and are always same-process. + return true + } else if hasTabTransfer { + // External drags (another Bonsplit controller) must include a payload from this process. + guard let transfer = decodeTransfer(from: info), + transfer.isFromCurrentProcess else { return false } } @@ -612,7 +621,22 @@ struct UnifiedPaneDropDelegate: DropDelegate { } private func isFileDropOnly(_ info: DropInfo) -> Bool { - info.hasItemsConforming(to: [.fileURL]) && !info.hasItemsConforming(to: [.tabTransfer]) + Self.isFileDropOnly( + hasTabTransfer: info.hasItemsConforming(to: [.tabTransfer]), + hasFileURL: info.hasItemsConforming(to: [.fileURL]) + ) + } + + static func isFileDropOnly(hasTabTransfer: Bool, hasFileURL: Bool) -> Bool { + hasFileURL && !hasTabTransfer + } + + static func shouldUseLocalTabDrag( + hasTabTransfer: Bool, + hasFileURL: Bool, + hasLocalTabDrag: Bool + ) -> Bool { + hasLocalTabDrag && !isFileDropOnly(hasTabTransfer: hasTabTransfer, hasFileURL: hasFileURL) } private func destination( @@ -670,6 +694,10 @@ struct UnifiedPaneDropDelegate: DropDelegate { } } + static func hasReadableFileURLs(from pasteboard: NSPasteboard = NSPasteboard(name: .drag)) -> Bool { + !fileURLs(from: pasteboard).isEmpty + } + private func decodeTransfer(from string: String) -> TabTransferData? { guard let data = string.data(using: .utf8), let transfer = try? JSONDecoder().decode(TabTransferData.self, from: data) else { diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 893702c1..50f2916c 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -1736,6 +1736,48 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(UnifiedPaneDropDelegate.fileURLs(from: pasteboard), [fileURL]) } + func testFileDropValidationRequiresReadablePasteboardURLs() throws { + let pasteboard = NSPasteboard(name: NSPasteboard.Name("bonsplit.file-drop.empty.\(UUID().uuidString)")) + pasteboard.clearContents() + + XCTAssertFalse(UnifiedPaneDropDelegate.hasReadableFileURLs(from: pasteboard)) + + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("bonsplit-file-drop-readable-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let fileURL = directory.appendingPathComponent("sample.txt") + try "sample".write(to: fileURL, atomically: true, encoding: .utf8) + + XCTAssertTrue(pasteboard.writeObjects([fileURL as NSURL])) + XCTAssertTrue(UnifiedPaneDropDelegate.hasReadableFileURLs(from: pasteboard)) + } + + func testFileOnlyDropsDoNotUseStaleLocalTabDragState() { + XCTAssertFalse( + UnifiedPaneDropDelegate.shouldUseLocalTabDrag( + hasTabTransfer: false, + hasFileURL: true, + hasLocalTabDrag: true + ) + ) + XCTAssertTrue( + UnifiedPaneDropDelegate.shouldUseLocalTabDrag( + hasTabTransfer: true, + hasFileURL: false, + hasLocalTabDrag: true + ) + ) + XCTAssertFalse( + UnifiedPaneDropDelegate.shouldUseLocalTabDrag( + hasTabTransfer: true, + hasFileURL: false, + hasLocalTabDrag: false + ) + ) + } + func testSelectedTabNeverShowsHoverBackground() { XCTAssertFalse( TabItemStyling.shouldShowHoverBackground(isHovered: true, isSelected: true)