From ce88069d45083eb092cba435b959e128e9bdfec7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 19:24:14 -0700 Subject: [PATCH 01/10] Route tab-header zoom requests through host ownership cmux needs tab chrome zoom actions to run the same host-owned reconciliation used by shortcuts and context menus, instead of mutating Bonsplit zoom state directly from the SwiftUI tab item. Constraint: Tab header gesture ownership lives in Bonsplit while cmux owns portal visibility and focus reconciliation. Rejected: Toggle splitViewController.zoomedPaneId directly from double-click | it would bypass cmux workspace side effects. Confidence: medium Scope-risk: moderate Directive: Keep tab-chrome zoom requests routed through requestTabZoomToggle when hosts need side-effect ownership. Tested: git diff --check Not-tested: Local XCTest per cmux no-local-tests instruction --- .../Bonsplit/Internal/Views/TabBarView.swift | 2 +- .../Bonsplit/Internal/Views/TabItemView.swift | 14 ++++++-- .../Bonsplit/Public/BonsplitController.swift | 16 +++++++++ Tests/BonsplitTests/BonsplitTests.swift | 36 +++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index e5866935..be2d4799 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -1148,7 +1148,7 @@ struct TabBarView: View { } }, onZoomToggle: { - _ = splitViewController.togglePaneZoom(pane.id) + _ = controller.requestTabZoomToggle(for: TabID(id: tab.id), inPane: pane.id) }, onContextAction: { action in controller.requestTabContextAction(action, for: TabID(id: tab.id), inPane: pane.id) diff --git a/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index dfb85c79..3fed9007 100644 --- a/Sources/Bonsplit/Internal/Views/TabItemView.swift +++ b/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -197,9 +197,7 @@ struct TabItemView: View { onContextAction: onContextAction, onMoveDestination: onMoveDestination )) - .onTapGesture { - onSelect() - } + .gesture(selectionOrZoomGesture) .onHover { hovering in // Keep icon rendering stable while hovering; only accessory/background elements animate. isHovered = hovering @@ -211,6 +209,16 @@ struct TabItemView: View { .safeHelp(tab.title) } + private var selectionOrZoomGesture: some Gesture { + TapGesture(count: 2) + .onEnded { + onZoomToggle() + } + .exclusively(before: TapGesture(count: 1).onEnded { + onSelect() + }) + } + private func glyphSize(for iconName: String) -> CGFloat { // `terminal.fill` reads visually heavier than most symbols at the same point size. // Hardcode sizes to avoid cross-glyph layout shifts. diff --git a/Sources/Bonsplit/Public/BonsplitController.swift b/Sources/Bonsplit/Public/BonsplitController.swift index 0acc2243..1513c28c 100644 --- a/Sources/Bonsplit/Public/BonsplitController.swift +++ b/Sources/Bonsplit/Public/BonsplitController.swift @@ -81,6 +81,10 @@ public final class BonsplitController { /// Internal host-driven closes should not use this hook. @ObservationIgnored public var onTabCloseRequest: ((_ tabId: TabID, _ paneId: PaneID) -> Void)? + /// Called when the user explicitly requests to toggle zoom from tab chrome. + /// When set, the host owns the full toggle and should return whether it succeeded. + @ObservationIgnored public var onTabZoomToggleRequest: ((_ tabId: TabID, _ paneId: PaneID) -> Bool)? + // MARK: - Internal State internal var internalController: SplitViewController @@ -647,6 +651,18 @@ public final class BonsplitController { return internalController.togglePaneZoom(targetPaneId) } + /// Request a tab-chrome zoom toggle for a specific tab. + /// Hosts can intercept this to run their own focus/layout reconciliation. + @discardableResult + public func requestTabZoomToggle(for tabId: TabID, inPane paneId: PaneID) -> Bool { + guard let (pane, _) = findTabInternal(tabId), + pane.id == paneId else { return false } + if let onTabZoomToggleRequest { + return onTabZoomToggleRequest(tabId, paneId) + } + return togglePaneZoom(inPane: paneId) + } + // MARK: - Context Menu Shortcut Hints /// Keyboard shortcuts to display in tab context menus, keyed by context action. diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index b969060e..7076d679 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -1242,6 +1242,42 @@ final class BonsplitTests: XCTestCase { XCTAssertFalse(controller.isSplitZoomed) } + @MainActor + func testRequestTabZoomToggleUsesHostHandler() { + let controller = BonsplitController() + let pane = controller.focusedPaneId! + let tabId = controller.createTab(title: "Zoom target")! + var requestedTabId: TabID? + var requestedPaneId: PaneID? + controller.onTabZoomToggleRequest = { tabId, paneId in + requestedTabId = tabId + requestedPaneId = paneId + return true + } + + XCTAssertTrue(controller.requestTabZoomToggle(for: tabId, inPane: pane)) + + XCTAssertEqual(requestedTabId, tabId) + XCTAssertEqual(requestedPaneId, pane) + XCTAssertNil(controller.zoomedPaneId) + } + + @MainActor + func testRequestTabZoomToggleFallsBackToInternalZoom() { + let controller = BonsplitController() + let pane = controller.focusedPaneId! + let tabId = controller.createTab(title: "Zoom target")! + guard controller.splitPane(pane, orientation: .horizontal) != nil else { + return XCTFail("Expected splitPane to create a new pane") + } + + XCTAssertTrue(controller.requestTabZoomToggle(for: tabId, inPane: pane)) + XCTAssertEqual(controller.zoomedPaneId, pane) + + XCTAssertTrue(controller.requestTabZoomToggle(for: tabId, inPane: pane)) + XCTAssertNil(controller.zoomedPaneId) + } + @MainActor func testSplitClearsExistingPaneZoom() { let controller = BonsplitController() From 2979b6f0151ffcc7f5cc572815654a22cdd7b9e8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 19:30:54 -0700 Subject: [PATCH 02/10] Mark tab zoom hook main actor Tab chrome gestures are UI events and host zoom reconciliation runs on the main actor, so the public callback type now states that isolation explicitly. Constraint: Greptile review requested explicit confirmation of the Bonsplit-side actor annotation. Rejected: Rely only on BonsplitController class isolation | less clear at the stored closure boundary. Confidence: high Scope-risk: narrow Tested: git diff --check Not-tested: Local XCTest per cmux no-local-tests instruction --- Sources/Bonsplit/Public/BonsplitController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Bonsplit/Public/BonsplitController.swift b/Sources/Bonsplit/Public/BonsplitController.swift index 1513c28c..802c655a 100644 --- a/Sources/Bonsplit/Public/BonsplitController.swift +++ b/Sources/Bonsplit/Public/BonsplitController.swift @@ -83,7 +83,7 @@ public final class BonsplitController { /// Called when the user explicitly requests to toggle zoom from tab chrome. /// When set, the host owns the full toggle and should return whether it succeeded. - @ObservationIgnored public var onTabZoomToggleRequest: ((_ tabId: TabID, _ paneId: PaneID) -> Bool)? + @ObservationIgnored public var onTabZoomToggleRequest: (@MainActor (_ tabId: TabID, _ paneId: PaneID) -> Bool)? // MARK: - Internal State From c44cc83f54dcd29c9fd3bdcf988c466a02a25ccc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 13:15:58 -0700 Subject: [PATCH 03/10] Keep tab selection instant with zoom gesture --- .../Bonsplit/Internal/Views/TabItemView.swift | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index 3fed9007..d9f213f0 100644 --- a/Sources/Bonsplit/Internal/Views/TabItemView.swift +++ b/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -197,7 +197,14 @@ struct TabItemView: View { onContextAction: onContextAction, onMoveDestination: onMoveDestination )) - .gesture(selectionOrZoomGesture) + .onTapGesture { + onSelect() + } + .simultaneousGesture( + TapGesture(count: 2).onEnded { + onZoomToggle() + } + ) .onHover { hovering in // Keep icon rendering stable while hovering; only accessory/background elements animate. isHovered = hovering @@ -209,16 +216,6 @@ struct TabItemView: View { .safeHelp(tab.title) } - private var selectionOrZoomGesture: some Gesture { - TapGesture(count: 2) - .onEnded { - onZoomToggle() - } - .exclusively(before: TapGesture(count: 1).onEnded { - onSelect() - }) - } - private func glyphSize(for iconName: String) -> CGFloat { // `terminal.fill` reads visually heavier than most symbols at the same point size. // Hardcode sizes to avoid cross-glyph layout shifts. From 207186b87b220276629efa3f1de3b328e11a899d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 13:40:46 -0700 Subject: [PATCH 04/10] test: cover action lane using trailing whitespace --- .../Bonsplit/Internal/Views/TabBarView.swift | 3 +++ Tests/BonsplitTests/BonsplitTests.swift | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index e5866935..a1a0b726 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -329,6 +329,7 @@ enum TabBarStyling { struct TabBarLayout: Equatable { let barHeight: CGFloat let availableWidth: CGFloat + let tabContentWidthExcludingSplitButtonLane: CGFloat? let splitButtonCount: Int let splitButtonLaneVisible: Bool let reservesSplitButtonLane: Bool @@ -337,6 +338,7 @@ struct TabBarLayout: Equatable { init( tabBarHeight: CGFloat, availableWidth: CGFloat = 0, + tabContentWidthExcludingSplitButtonLane: CGFloat? = nil, splitButtonCount: Int, splitButtonLaneVisible: Bool, reservesSplitButtonLane: Bool, @@ -344,6 +346,7 @@ struct TabBarLayout: Equatable { ) { self.barHeight = max(1, tabBarHeight) self.availableWidth = max(0, availableWidth) + self.tabContentWidthExcludingSplitButtonLane = tabContentWidthExcludingSplitButtonLane.map { max(0, $0) } self.splitButtonCount = max(0, splitButtonCount) self.splitButtonLaneVisible = splitButtonLaneVisible self.reservesSplitButtonLane = reservesSplitButtonLane diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index b969060e..8061c2db 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -422,6 +422,24 @@ final class BonsplitTests: XCTestCase { XCTAssertTrue(layout.splitButtonLaneOverflowsViewport) } + func testTabBarLayoutUsesTrailingWhitespaceBeforeClippingSplitButtons() { + let measuredWidth = TabBarStyling.splitButtonsBackdropWidth(buttonCount: 10) + let layout = TabBarLayout( + tabBarHeight: 28, + availableWidth: 930, + tabContentWidthExcludingSplitButtonLane: 300, + splitButtonCount: 10, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: measuredWidth + ) + + XCTAssertEqual(layout.maximumSplitButtonLaneWidth, 630) + XCTAssertEqual(layout.visibleSplitButtonLaneWidth, measuredWidth) + XCTAssertEqual(layout.trailingTabContentInset, measuredWidth) + XCTAssertFalse(layout.splitButtonLaneOverflowsViewport) + } + func testTabBarLayoutKeepsMeasuredLaneWhenItFitsQuarterOfAvailableWidth() { let layout = TabBarLayout( tabBarHeight: 28, From ff55eda89ffbebcd887de5e37ea8b258fd22191f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 13:41:20 -0700 Subject: [PATCH 05/10] fix: let action lane use empty tab bar space --- .../Bonsplit/Internal/Views/TabBarView.swift | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index a1a0b726..c24a25d9 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -365,7 +365,16 @@ struct TabBarLayout: Equatable { var maximumSplitButtonLaneWidth: CGFloat { guard availableWidth > 0 else { return fullSplitButtonLaneWidth } - return availableWidth * TabBarStyling.maximumSplitButtonLaneWidthFraction + let fractionLimit = availableWidth * TabBarStyling.maximumSplitButtonLaneWidthFraction + return max(fractionLimit, trailingWhitespaceBeforeSplitButtonLane) + } + + var trailingWhitespaceBeforeSplitButtonLane: CGFloat { + guard availableWidth > 0, + let tabContentWidthExcludingSplitButtonLane else { + return 0 + } + return max(0, availableWidth - tabContentWidthExcludingSplitButtonLane) } var visibleSplitButtonLaneWidth: CGFloat { @@ -764,6 +773,7 @@ struct TabBarView: View { @State private var dropLifecycle: TabDropLifecycle = .idle @State private var scrollOffset: CGFloat = 0 @State private var contentWidth: CGFloat = 0 + @State private var tabContentWidthExcludingSplitButtonLane: CGFloat? @State private var containerWidth: CGFloat = 0 @State private var selectedTabFrameInBar: CGRect? @State private var tabFramesInBar: [UUID: CGRect] = [:] @@ -806,6 +816,7 @@ struct TabBarView: View { TabBarLayout( tabBarHeight: appearance.tabBarHeight, availableWidth: containerWidth, + tabContentWidthExcludingSplitButtonLane: tabContentWidthExcludingSplitButtonLane, splitButtonCount: visibleSplitButtons.count, splitButtonLaneVisible: shouldShowSplitButtons, reservesSplitButtonLane: showSplitButtons && !isMinimalMode, @@ -947,13 +958,11 @@ struct TabBarView: View { GeometryReader { contentGeo in Color.clear .onChange(of: contentGeo.frame(in: .named("tabScroll"))) { _, newFrame in - scrollOffset = -newFrame.minX - contentWidth = newFrame.width + updateTabScrollContent(frame: newFrame) } .onAppear { let frame = contentGeo.frame(in: .named("tabScroll")) - scrollOffset = -frame.minX - contentWidth = frame.width + updateTabScrollContent(frame: frame) } } ) @@ -1539,6 +1548,12 @@ struct TabBarView: View { splitButtonContentWidth = frame.width } + private func updateTabScrollContent(frame: CGRect) { + scrollOffset = -frame.minX + contentWidth = frame.width + tabContentWidthExcludingSplitButtonLane = max(0, frame.width - tabBarLayout.trailingTabContentInset) + } + @ViewBuilder private var splitButtonRow: some View { let tooltips = controller.configuration.appearance.splitButtonTooltips From e1d9954a027aa35afe003b0ebdbf81875258e171 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 13:44:56 -0700 Subject: [PATCH 06/10] test: keep overflow fixture crowded --- Tests/BonsplitTests/BonsplitTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 8061c2db..5aa75379 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -3115,9 +3115,9 @@ final class BonsplitTests: XCTestCase { showSplitButtons: true, size: size, configurePane: { pane in - let selected = TabItem(title: "", icon: nil) - pane.tabs = [selected] - pane.selectedTabId = selected.id + let tabs = (0..<8).map { _ in TabItem(title: "", icon: nil) } + pane.tabs = tabs + pane.selectedTabId = tabs.first?.id } ) { hostingView in maximumBrightness( From e1a9e76b1e7655ea669b6f79f8a0eecb3e377860 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 03:08:28 -0700 Subject: [PATCH 07/10] Fix minimal tab drag window movement --- .../Bonsplit/Internal/Views/TabBarView.swift | 73 ++++++++++++++- Tests/BonsplitTests/BonsplitTests.swift | 90 +++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index c24a25d9..8458856a 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -47,6 +47,56 @@ public enum BonsplitTabBarHitRegionRegistry { } } +public protocol BonsplitTabItemHitRegionProviding: AnyObject { + func containsBonsplitTabItemHit(localPoint: NSPoint) -> Bool +} + +public enum BonsplitTabItemHitRegionRegistry { + private static let lock = NSLock() + private static let registeredViews = NSHashTable.weakObjects() + + static func register(_ view: NSView) { + lock.lock() + registeredViews.add(view) + lock.unlock() + } + + static func unregister(_ view: NSView) { + lock.lock() + registeredViews.remove(view) + lock.unlock() + } + + private static func snapshot() -> [NSView] { + lock.lock() + let views = registeredViews.allObjects + lock.unlock() + return views + } + + private static func isVisibleInHierarchy(_ view: NSView) -> Bool { + var current: NSView? = view + while let candidate = current { + guard !candidate.isHidden, candidate.alphaValue > 0 else { return false } + current = candidate.superview + } + return true + } + + public static func containsWindowPoint(_ windowPoint: CGPoint, in window: NSWindow) -> Bool { + for view in snapshot() { + guard view.window === window, + isVisibleInHierarchy(view), + let provider = view as? BonsplitTabItemHitRegionProviding else { continue } + let localPoint = view.convert(windowPoint, from: nil) + if provider.containsBonsplitTabItemHit(localPoint: localPoint) { + return true + } + } + return false + } +} + private struct SelectedTabFramePreferenceKey: PreferenceKey { static let defaultValue: CGRect? = nil @@ -1059,6 +1109,7 @@ struct TabBarView: View { } .background(TabBarDragAndHoverView( isMinimalMode: isMinimalMode, + tabFrames: Array(tabFramesInBar.values), onDoubleClick: { performNewTerminalSplitButtonAction() }, @@ -2264,12 +2315,14 @@ private struct TabBarManualReorderTrackingView: NSViewRepresentable { /// this view only receives hits in truly empty space. private struct TabBarDragAndHoverView: NSViewRepresentable { let isMinimalMode: Bool + let tabFrames: [CGRect] let onDoubleClick: () -> Bool let onHoverChanged: (Bool) -> Void func makeNSView(context: Context) -> TabBarBackgroundNSView { let view = TabBarBackgroundNSView() view.isMinimalMode = isMinimalMode + view.tabFrames = tabFrames view.onDoubleClick = onDoubleClick view.onHoverChanged = onHoverChanged return view @@ -2277,12 +2330,14 @@ private struct TabBarDragAndHoverView: NSViewRepresentable { func updateNSView(_ nsView: TabBarBackgroundNSView, context: Context) { nsView.isMinimalMode = isMinimalMode + nsView.tabFrames = tabFrames nsView.onDoubleClick = onDoubleClick nsView.onHoverChanged = onHoverChanged } - final class TabBarBackgroundNSView: NSView { + final class TabBarBackgroundNSView: NSView, BonsplitTabItemHitRegionProviding { var isMinimalMode = false + nonisolated(unsafe) var tabFrames: [CGRect] = [] var onDoubleClick: (() -> Bool)? var onHoverChanged: ((Bool) -> Void)? private var hoverTrackingArea: NSTrackingArea? @@ -2297,15 +2352,18 @@ private struct TabBarDragAndHoverView: NSViewRepresentable { removeLocalMouseMonitor() removeWindowObservers() BonsplitTabBarHitRegionRegistry.unregister(self) + BonsplitTabItemHitRegionRegistry.unregister(self) } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() BonsplitTabBarHitRegionRegistry.unregister(self) + BonsplitTabItemHitRegionRegistry.unregister(self) removeWindowObservers() if let window { window.acceptsMouseMovedEvents = true BonsplitTabBarHitRegionRegistry.register(self) + BonsplitTabItemHitRegionRegistry.register(self) installWindowObservers() installLocalMouseMonitorIfNeeded() syncHoverStateToCurrentMouseLocation() @@ -2319,9 +2377,15 @@ private struct TabBarDragAndHoverView: NSViewRepresentable { super.viewDidMoveToSuperview() if superview == nil { BonsplitTabBarHitRegionRegistry.unregister(self) + BonsplitTabItemHitRegionRegistry.unregister(self) } } + nonisolated func containsBonsplitTabItemHit(localPoint: NSPoint) -> Bool { + let paddedFrames = tabFrames.map { $0.insetBy(dx: -2, dy: -2) } + return paddedFrames.contains { $0.contains(localPoint) } + } + override func updateTrackingAreas() { super.updateTrackingAreas() if let existing = hoverTrackingArea { @@ -2356,6 +2420,13 @@ private struct TabBarDragAndHoverView: NSViewRepresentable { super.mouseDown(with: event) return } + if containsBonsplitTabItemHit(localPoint: convert(event.locationInWindow, from: nil)) { +#if DEBUG + dlog("tab.bar.bg.mouseDown skipped reason=tabItem") +#endif + super.mouseDown(with: event) + return + } if event.clickCount >= 2 { if isMinimalMode { let action = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)?["AppleActionOnDoubleClick"] as? String diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 5aa75379..106732f0 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -26,6 +26,34 @@ final class BonsplitTests: XCTestCase { } } + @MainActor + private final class FakeTabItemHitRegionView: NSView, BonsplitTabItemHitRegionProviding { + nonisolated(unsafe) var tabFrames: [CGRect] = [] + + deinit { + BonsplitTabItemHitRegionRegistry.unregister(self) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + BonsplitTabItemHitRegionRegistry.unregister(self) + if window != nil { + BonsplitTabItemHitRegionRegistry.register(self) + } + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + if superview == nil { + BonsplitTabItemHitRegionRegistry.unregister(self) + } + } + + nonisolated func containsBonsplitTabItemHit(localPoint: NSPoint) -> Bool { + tabFrames.contains { $0.contains(localPoint) } + } + } + @MainActor private final class LayoutProbeView: NSView { private(set) var sizeChangeCount = 0 @@ -754,6 +782,68 @@ final class BonsplitTests: XCTestCase { ) } + @MainActor + func testTabItemHitRegionRegistryTracksOnlyRealTabFrames() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 200), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let tabBar = FakeTabItemHitRegionView(frame: NSRect(x: 20, y: 132, width: 220, height: 30)) + tabBar.tabFrames = [ + CGRect(x: 8, y: 0, width: 96, height: 30), + CGRect(x: 112, y: 0, width: 80, height: 30) + ] + contentView.addSubview(tabBar) + + let tabPoint = tabBar.convert(NSPoint(x: 32, y: 12), to: nil) + XCTAssertTrue( + BonsplitTabItemHitRegionRegistry.containsWindowPoint(tabPoint, in: window), + "Points inside actual tab frames should suppress implicit AppKit window dragging" + ) + + let emptyChromePoint = tabBar.convert(NSPoint(x: 204, y: 12), to: nil) + XCTAssertFalse( + BonsplitTabItemHitRegionRegistry.containsWindowPoint(emptyChromePoint, in: window), + "Empty tab-bar chrome should stay available for explicit app-window dragging" + ) + } + + @MainActor + func testTabItemHitRegionRegistryIgnoresHiddenProviders() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 200), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let tabBar = FakeTabItemHitRegionView(frame: NSRect(x: 20, y: 132, width: 220, height: 30)) + tabBar.tabFrames = [CGRect(x: 8, y: 0, width: 96, height: 30)] + contentView.addSubview(tabBar) + + let tabPoint = tabBar.convert(NSPoint(x: 32, y: 12), to: nil) + XCTAssertTrue(BonsplitTabItemHitRegionRegistry.containsWindowPoint(tabPoint, in: window)) + + tabBar.isHidden = true + XCTAssertFalse( + BonsplitTabItemHitRegionRegistry.containsWindowPoint(tabPoint, in: window), + "Hidden tab providers must not suppress app-window dragging" + ) + } + @MainActor func testConfigurationAcceptsCustomSplitButtonTooltips() { let customTooltips = BonsplitConfiguration.SplitButtonTooltips( From f8233a412140f86095ceced2a2412d64f8f5f9c9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 03:16:06 -0700 Subject: [PATCH 08/10] Show cursor over minimal tab drag chrome --- .../Bonsplit/Internal/Views/TabBarView.swift | 50 ++++++++++++++++++- Tests/BonsplitTests/BonsplitTests.swift | 35 +++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 8458856a..10541e9f 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -2563,9 +2563,13 @@ struct TabBarDragZoneView: NSViewRepresentable { } final class DragNSView: NSView { - var hitRegion = HitRegion.entireBounds + var hitRegion = HitRegion.entireBounds { + didSet { invalidateWindowDragCursorRects() } + } var hitTestEventTypeOverride: NSEvent.EventType? - var isMinimalMode = false + var isMinimalMode = false { + didSet { invalidateWindowDragCursorRects() } + } var isFocusedPane = false var onSingleClick: (() -> Bool)? var onDoubleClick: (() -> Bool)? @@ -2584,6 +2588,18 @@ struct TabBarDragZoneView: NSViewRepresentable { // SplitNodeView.swift for the same class of bug on pane tab clicks. override var mouseDownCanMoveWindow: Bool { false } + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + invalidateWindowDragCursorRects() + } + + override func resetCursorRects() { + super.resetCursorRects() + for rect in windowDragCursorRectsForCurrentState() { + addCursorRect(rect, cursor: .openHand) + } + } + override func hitTest(_ point: NSPoint) -> NSView? { guard shouldCaptureHit(at: point) else { return nil } return self @@ -2669,6 +2685,36 @@ struct TabBarDragZoneView: NSViewRepresentable { } } + func windowDragCursorRectsForCurrentState() -> [NSRect] { + guard isMinimalMode, !bounds.isEmpty else { return [] } + + switch hitRegion { + case .entireBounds: + return [bounds] + case .trailingEmptyChrome(let tabFrames, let reservedTrailingWidth): + let trailingLimit = bounds.maxX - max(0, reservedTrailingWidth) + guard trailingLimit > bounds.minX else { return [] } + + let paddedFrames = tabFrames.map { $0.insetBy(dx: -2, dy: -2) } + let startX = max(bounds.minX, paddedFrames.map(\.maxX).max() ?? bounds.minX) + guard trailingLimit > startX else { return [] } + + return [ + NSRect( + x: startX, + y: bounds.minY, + width: trailingLimit - startX, + height: bounds.height + ) + ] + } + } + + private func invalidateWindowDragCursorRects() { + guard let window else { return } + window.invalidateCursorRects(for: self) + } + private var isMouseDownOrDragCandidate: Bool { switch hitTestEventTypeOverride ?? NSApp.currentEvent?.type { case .leftMouseDown, .leftMouseDragged, .leftMouseUp: diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 106732f0..7a855dba 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -2649,6 +2649,41 @@ final class BonsplitTests: XCTestCase { XCTAssertIdentical(view.hitTest(NSPoint(x: 140, y: 15)), view) } + @MainActor + func testTabBarDragZoneCursorMarksOnlyMinimalModeWindowDragArea() { + let view = TabBarDragZoneView.DragNSView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + view.hitRegion = .trailingEmptyChrome( + tabFrames: [CGRect(x: 10, y: 0, width: 90, height: 30)], + reservedTrailingWidth: 48 + ) + + XCTAssertEqual( + view.windowDragCursorRectsForCurrentState(), + [], + "Standard mode should not advertise tab-bar window dragging with an open-hand cursor" + ) + + view.isMinimalMode = true + + XCTAssertEqual( + view.windowDragCursorRectsForCurrentState(), + [NSRect(x: 102, y: 0, width: 170, height: 30)], + "Minimal mode should show the open-hand cursor only in empty chrome after the tab frames and before action buttons" + ) + } + + @MainActor + func testTabBarDragZoneCursorCoversEntireExplicitDragZoneInMinimalMode() { + let view = TabBarDragZoneView.DragNSView(frame: NSRect(x: 0, y: 0, width: 160, height: 30)) + view.isMinimalMode = true + + XCTAssertEqual( + view.windowDragCursorRectsForCurrentState(), + [view.bounds], + "Explicit leading and inline empty drag zones should be visibly marked as window-drag chrome in minimal mode" + ) + } + @MainActor func testTabBarDragZoneKeepsFocusedPaneWindowDragInMinimalMode() throws { let view = TabBarDragZoneView.DragNSView(frame: NSRect(x: 0, y: 0, width: 160, height: 30)) From ba8042127a4b0cf1976d8bea9652c526ff4366b7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 16:06:06 -0700 Subject: [PATCH 09/10] Protect tab items from minimal chrome dragging --- .../Bonsplit/Internal/Views/TabBarView.swift | 79 +++++++++++++++++++ Tests/BonsplitTests/BonsplitTests.swift | 35 ++++++++ 2 files changed, 114 insertions(+) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 10541e9f..1a1a100e 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -97,6 +97,72 @@ public enum BonsplitTabItemHitRegionRegistry { } } +private struct TabItemHitRegionView: NSViewRepresentable { + func makeNSView(context: Context) -> RegionNSView { + RegionNSView() + } + + func updateNSView(_ nsView: RegionNSView, context: Context) {} + + final class RegionNSView: NSView, BonsplitTabItemHitRegionProviding { + nonisolated(unsafe) private var hitBounds: NSRect = .zero + + override var mouseDownCanMoveWindow: Bool { false } + + deinit { + BonsplitTabItemHitRegionRegistry.unregister(self) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + syncHitBounds() + BonsplitTabItemHitRegionRegistry.unregister(self) + if window != nil { + BonsplitTabItemHitRegionRegistry.register(self) + } + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + if superview == nil { + BonsplitTabItemHitRegionRegistry.unregister(self) + } + } + + override func layout() { + super.layout() + syncHitBounds() + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + syncHitBounds() + } + + override func setBoundsSize(_ newSize: NSSize) { + super.setBoundsSize(newSize) + syncHitBounds() + } + + override func setBoundsOrigin(_ newOrigin: NSPoint) { + super.setBoundsOrigin(newOrigin) + syncHitBounds() + } + + nonisolated func containsBonsplitTabItemHit(localPoint: NSPoint) -> Bool { + hitBounds.insetBy(dx: -2, dy: -2).contains(localPoint) + } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + private func syncHitBounds() { + hitBounds = bounds + } + } +} + private struct SelectedTabFramePreferenceKey: PreferenceKey { static let defaultValue: CGRect? = nil @@ -1220,6 +1286,9 @@ struct TabBarView: View { controller.requestTabMove(toDestination: destinationId, for: TabID(id: tab.id), inPane: pane.id) } ) + .background( + TabItemHitRegionView() + ) .background( GeometryReader { geometry in let frame = geometry.frame(in: .named("tabBar")) @@ -2671,6 +2740,16 @@ struct TabBarDragZoneView: NSViewRepresentable { private func shouldCaptureHit(at point: NSPoint) -> Bool { guard bounds.contains(point) else { return false } + if let window, + BonsplitTabItemHitRegionRegistry.containsWindowPoint(convert(point, to: nil), in: window) { +#if DEBUG + dlog( + "tab.bar.dragZone.hitTest capture=false reason=registeredTabItem " + + "point=\(point.x.rounded()),\(point.y.rounded())" + ) +#endif + return false + } switch hitRegion { case .entireBounds: return true diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 7a855dba..ae0e7f89 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -2649,6 +2649,41 @@ final class BonsplitTests: XCTestCase { XCTAssertIdentical(view.hitTest(NSPoint(x: 140, y: 15)), view) } + @MainActor + func testTabBarTrailingEmptyChromeDefersToRegisteredTabItemWhenFrameCacheIsEmpty() throws { + let view = TabBarDragZoneView.DragNSView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + view.hitRegion = .trailingEmptyChrome(tabFrames: [], reservedTrailingWidth: 48) + view.hitTestEventTypeOverride = .leftMouseDown + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 60), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + contentView.addSubview(view) + let tabItem = FakeTabItemHitRegionView(frame: NSRect(x: 10, y: 0, width: 90, height: 30)) + tabItem.tabFrames = [tabItem.bounds] + contentView.addSubview(tabItem) + window.makeKeyAndOrderFront(nil) + + XCTAssertNil( + view.hitTest(NSPoint(x: 40, y: 15)), + "A registered pane tab owns its pixels even before the tab-frame preference cache is populated" + ) + XCTAssertIdentical( + view.hitTest(NSPoint(x: 140, y: 15)), + view, + "Empty chrome after the registered tab should still drag the app window" + ) + } + @MainActor func testTabBarDragZoneCursorMarksOnlyMinimalModeWindowDragArea() { let view = TabBarDragZoneView.DragNSView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) From 02db30f579e6308b12bf7521fc5faa7ac430ed16 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 20:11:45 -0700 Subject: [PATCH 10/10] Clarify minimal tab drag hit region --- .../Bonsplit/Internal/Views/TabItemView.swift | 20 ++++- Tests/BonsplitTests/BonsplitTests.swift | 78 ++++++++++++++++++- 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index d9f213f0..1be8085f 100644 --- a/Sources/Bonsplit/Internal/Views/TabItemView.swift +++ b/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -34,6 +34,12 @@ enum TabItemStyling { isHovered && !isSelected } + static func tabWidthRange(for appearance: BonsplitConfiguration.Appearance) -> ClosedRange { + let minimum = max(1, appearance.tabMinWidth) + let maximum = max(minimum, appearance.tabMaxWidth) + return minimum...maximum + } + static func resolvedFaviconImage(existing: NSImage?, incomingData: Data?) -> NSImage? { guard let incomingData else { return nil } if let decoded = NSImage(data: incomingData) { @@ -178,8 +184,8 @@ struct TabItemView: View { } .padding(.horizontal, TabBarMetrics.tabHorizontalPadding) .frame( - minWidth: TabBarMetrics.tabMinWidth, - maxWidth: TabBarMetrics.tabMaxWidth, + minWidth: tabWidthRange.lowerBound, + maxWidth: tabWidthRange.upperBound, minHeight: tabHeight, maxHeight: tabHeight ) @@ -234,6 +240,10 @@ struct TabItemView: View { allowsShortcutHints && (showsControlShortcutHint || alwaysShowShortcutHints) && shortcutHintLabel != nil } + private var tabWidthRange: ClosedRange { + TabItemStyling.tabWidthRange(for: appearance) + } + private var shortcutHintSlotWidth: CGFloat { guard let label = shortcutHintLabel else { return accessorySlotSize @@ -358,8 +368,10 @@ struct TabItemView: View { @ViewBuilder private var tabBackground: some View { ZStack(alignment: .top) { - // Background fill (hover) - if TabItemStyling.shouldShowHoverBackground(isHovered: isHovered, isSelected: isSelected) { + if isSelected { + Rectangle() + .fill(TabBarColors.activeTabBackground(for: appearance)) + } else if TabItemStyling.shouldShowHoverBackground(isHovered: isHovered, isSelected: isSelected) { Rectangle() .fill(TabBarColors.hoveredTabBackground(for: appearance)) } else { diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 3f1f75af..541c7288 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -1598,6 +1598,65 @@ final class BonsplitTests: XCTestCase { XCTAssertNil(spy.requestedPaneId) } + @MainActor + func testShortConfiguredTabOwnsFullConfiguredHitWidth() { + let appearance = BonsplitConfiguration.Appearance( + tabMinWidth: 140, + tabMaxWidth: 220, + splitButtons: [] + ) + let controller = BonsplitController(configuration: BonsplitConfiguration(appearance: appearance)) + let pane = controller.internalController.rootNode.allPanes.first! + let tab = TabItem(title: "~", icon: "terminal.fill") + pane.tabs = [tab] + pane.selectedTabId = tab.id + + let hostingView = NSHostingView( + rootView: TabBarView(pane: pane, isFocused: true, showSplitButtons: true) + .environment(controller) + .environment(controller.internalController) + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 60), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + hostingView.frame = contentView.bounds + hostingView.autoresizingMask = [.width, .height] + contentView.addSubview(hostingView) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + contentView.layoutSubtreeIfNeeded() + + let verticalCenter = appearance.tabBarHeight / 2 + let configuredTabPoint = hostingView.convert( + NSPoint(x: appearance.tabMinWidth - 20, y: verticalCenter), + to: nil + ) + XCTAssertTrue( + BonsplitTabItemHitRegionRegistry.containsWindowPoint(configuredTabPoint, in: window), + "A short-titled tab must own the full configured visible tab width so minimal-mode drags do not become window drags" + ) + + let emptyChromePoint = hostingView.convert( + NSPoint(x: appearance.tabMinWidth + 30, y: verticalCenter), + to: nil + ) + XCTAssertFalse( + BonsplitTabItemHitRegionRegistry.containsWindowPoint(emptyChromePoint, in: window), + "Empty tab-strip chrome after the configured tab remains available for app-window dragging" + ) + } + @MainActor func testTabBarVisibilityDefaultsToAlways() throws { XCTAssertEqual(BonsplitConfiguration().tabBarVisibility, .always) @@ -1949,6 +2008,15 @@ final class BonsplitTests: XCTestCase { ) } + func testTabWidthRangeUsesAppearanceMinimum() { + let range = TabItemStyling.tabWidthRange( + for: BonsplitConfiguration.Appearance(tabMinWidth: 140, tabMaxWidth: 220) + ) + + XCTAssertEqual(range.lowerBound, 140) + XCTAssertEqual(range.upperBound, 220) + } + func testActiveTabIndicatorHeightIsOneAndHalfPixels() { XCTAssertEqual(TabBarMetrics.activeIndicatorHeight, 1.5) } @@ -1960,7 +2028,11 @@ final class BonsplitTests: XCTestCase { return } - XCTAssertEqual(width, TabBarMetrics.tabMinWidth - 1, accuracy: 0.5) + XCTAssertEqual( + width, + BonsplitConfiguration.Appearance.default.tabMinWidth - TabBarMetrics.activeIndicatorTrailingInset, + accuracy: 0.5 + ) } @MainActor @@ -2984,7 +3056,7 @@ final class BonsplitTests: XCTestCase { @MainActor private func renderedTabBarIndicatorWidth(isFocused: Bool) -> CGFloat? { renderedTabBarValue(isFocused: isFocused) { hostingView in - let sampleRect = NSRect(x: 0, y: 0, width: 80, height: 4) + let sampleRect = NSRect(x: 0, y: 0, width: 180, height: 4) return highSaturationWidth(in: hostingView, sampleRect: sampleRect) } } @@ -3485,7 +3557,7 @@ final class BonsplitTests: XCTestCase { pane.selectedTabId = selected.id } ) { hostingView in - let separatorX = TabBarMetrics.tabMinWidth - 0.5 + let separatorX = BonsplitConfiguration.Appearance.default.tabMinWidth - 0.5 guard let top = renderedColorInViewCoordinates(in: hostingView, at: NSPoint(x: separatorX, y: 4))? .usingColorSpace(.sRGB)? .alphaComponent,