diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index e5866935..17f010d4 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -47,6 +47,122 @@ 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 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 @@ -329,6 +445,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 +454,7 @@ struct TabBarLayout: Equatable { init( tabBarHeight: CGFloat, availableWidth: CGFloat = 0, + tabContentWidthExcludingSplitButtonLane: CGFloat? = nil, splitButtonCount: Int, splitButtonLaneVisible: Bool, reservesSplitButtonLane: Bool, @@ -344,6 +462,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 @@ -362,7 +481,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 { @@ -761,6 +889,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] = [:] @@ -803,6 +932,7 @@ struct TabBarView: View { TabBarLayout( tabBarHeight: appearance.tabBarHeight, availableWidth: containerWidth, + tabContentWidthExcludingSplitButtonLane: tabContentWidthExcludingSplitButtonLane, splitButtonCount: visibleSplitButtons.count, splitButtonLaneVisible: shouldShowSplitButtons, reservesSplitButtonLane: showSplitButtons && !isMinimalMode, @@ -944,13 +1074,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) } } ) @@ -1047,6 +1175,7 @@ struct TabBarView: View { } .background(TabBarDragAndHoverView( isMinimalMode: isMinimalMode, + tabFrames: Array(tabFramesInBar.values), onDoubleClick: { performNewTerminalSplitButtonAction() }, @@ -1148,7 +1277,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) @@ -1157,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")) @@ -1536,6 +1668,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 @@ -2246,12 +2384,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 @@ -2259,12 +2399,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? @@ -2279,15 +2421,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() @@ -2301,9 +2446,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 { @@ -2338,6 +2489,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 @@ -2474,9 +2632,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)? @@ -2495,6 +2657,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 @@ -2566,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 @@ -2580,6 +2764,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/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index dfb85c79..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 ) @@ -200,6 +206,11 @@ struct TabItemView: View { .onTapGesture { onSelect() } + .simultaneousGesture( + TapGesture(count: 2).onEnded { + onZoomToggle() + } + ) .onHover { hovering in // Keep icon rendering stable while hovering; only accessory/background elements animate. isHovered = hovering @@ -229,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 @@ -353,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/Sources/Bonsplit/Public/BonsplitController.swift b/Sources/Bonsplit/Public/BonsplitController.swift index 0acc2243..802c655a 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: (@MainActor (_ 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..541c7288 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 @@ -422,6 +450,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, @@ -736,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( @@ -1242,6 +1350,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() @@ -1454,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) @@ -1805,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) } @@ -1816,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 @@ -2541,6 +2757,76 @@ 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)) + 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)) @@ -2770,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) } } @@ -3097,9 +3383,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( @@ -3271,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,