diff --git a/Sources/Bonsplit/Internal/Views/BonsplitHostingView.swift b/Sources/Bonsplit/Internal/Views/BonsplitHostingView.swift new file mode 100644 index 00000000..3c41e655 --- /dev/null +++ b/Sources/Bonsplit/Internal/Views/BonsplitHostingView.swift @@ -0,0 +1,53 @@ +import AppKit +import SwiftUI + +final class BonsplitHostingView: NSHostingView { + private let zeroSafeAreaLayoutGuide = NSLayoutGuide() + + override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } + override var safeAreaRect: NSRect { bounds } + override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide } + override var mouseDownCanMoveWindow: Bool { false } + override var intrinsicContentSize: NSSize { + NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric) + } + + required init(rootView: Content) { + super.init(rootView: rootView) + addLayoutGuide(zeroSafeAreaLayoutGuide) + NSLayoutConstraint.activate([ + zeroSafeAreaLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor), + zeroSafeAreaLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor), + zeroSafeAreaLayoutGuide.topAnchor.constraint(equalTo: topAnchor), + zeroSafeAreaLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +final class BonsplitHostingController: NSViewController { + private let hostingView: BonsplitHostingView + + var rootView: Content { + get { hostingView.rootView } + set { hostingView.rootView = newValue } + } + + init(rootView: Content) { + hostingView = BonsplitHostingView(rootView: rootView) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = hostingView + } +} diff --git a/Sources/Bonsplit/Internal/Views/PaneContainerView.swift b/Sources/Bonsplit/Internal/Views/PaneContainerView.swift index 5e0a2577..902b1f94 100644 --- a/Sources/Bonsplit/Internal/Views/PaneContainerView.swift +++ b/Sources/Bonsplit/Internal/Views/PaneContainerView.swift @@ -2,6 +2,159 @@ import SwiftUI import UniformTypeIdentifiers import AppKit +private final class TabBarInteractionContainerView: NSView { + private var eventMonitor: Any? + private weak var monitoredWindow: NSWindow? + private var previousWindowMovableState: Bool? + + override var mouseDownCanMoveWindow: Bool { false } + override func isAccessibilityElement() -> Bool { true } + override func accessibilityRole() -> NSAccessibility.Role? { .group } + + deinit { + removeEventMonitor() + restoreWindowDraggingIfNeeded() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + + if window !== monitoredWindow { + removeEventMonitor() + monitoredWindow = window + installEventMonitor() + } + + if window == nil { + restoreWindowDraggingIfNeeded() + } + } + + private func installEventMonitor() { + guard eventMonitor == nil else { return } + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp]) { [weak self] event in + self?.handleLocalMouseEvent(event) ?? event + } + } + + private func removeEventMonitor() { + guard let eventMonitor else { return } + NSEvent.removeMonitor(eventMonitor) + self.eventMonitor = nil + } + + private func handleLocalMouseEvent(_ event: NSEvent) -> NSEvent? { + guard let window = monitoredWindow else { + return event + } + + if event.window !== window { + if event.type == .leftMouseUp { + restoreWindowDraggingIfNeeded() + } + return event + } + + let point = convert(event.locationInWindow, from: nil) + switch event.type { + case .leftMouseDown: + if bounds.contains(point), !hitTestRoutesToWindowDragRegion(at: point) { + suppressWindowDraggingIfNeeded(window: window) + } + case .leftMouseDragged: + if previousWindowMovableState == nil, bounds.contains(point), !hitTestRoutesToWindowDragRegion(at: point) { + suppressWindowDraggingIfNeeded(window: window) + } + case .leftMouseUp: + restoreWindowDraggingIfNeeded() + default: + break + } + + return event + } + + private func hitTestRoutesToWindowDragRegion(at point: NSPoint) -> Bool { + guard let hitView = super.hitTest(point) else { return false } + var current: NSView? = hitView + while let view = current { + if view is TabBarWindowDragRegionView { + return true + } + current = view.superview + } + return descendantWindowDragRegionContains(point: point, in: self) + } + + private func descendantWindowDragRegionContains(point: NSPoint, in view: NSView) -> Bool { + if let dragRegionView = view as? TabBarWindowDragRegionView { + let pointInDragRegion = dragRegionView.convert(point, from: self) + if dragRegionView.bounds.contains(pointInDragRegion) { + return true + } + } + + for subview in view.subviews { + if descendantWindowDragRegionContains(point: point, in: subview) { + return true + } + } + + return false + } + + private func suppressWindowDraggingIfNeeded(window: NSWindow) { + guard previousWindowMovableState == nil else { return } + previousWindowMovableState = window.isMovable + if window.isMovable { + window.isMovable = false + } + } + + private func restoreWindowDraggingIfNeeded() { + guard let previousWindowMovableState else { return } + monitoredWindow?.isMovable = previousWindowMovableState + self.previousWindowMovableState = nil + } +} + +private struct TabBarHostingWrapper: NSViewRepresentable { + let content: Content + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> NSView { + let containerView = TabBarInteractionContainerView() + containerView.setAccessibilityElement(true) + containerView.setAccessibilityIdentifier("paneTabBar") + + let hostingView = BonsplitHostingView(rootView: content) + hostingView.translatesAutoresizingMaskIntoConstraints = false + hostingView.setAccessibilityElement(false) + containerView.addSubview(hostingView) + + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: containerView.topAnchor), + hostingView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + hostingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + ]) + + context.coordinator.hostingView = hostingView + return containerView + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.hostingView?.rootView = content + } + + final class Coordinator { + var hostingView: BonsplitHostingView? + } +} + /// Drop zone positions for creating splits public enum DropZone: Equatable { case center @@ -170,10 +323,12 @@ struct PaneContainerView: View { var body: some View { VStack(spacing: 0) { // Tab bar - TabBarView( - pane: pane, - isFocused: isFocused, - showSplitButtons: showSplitButtons + TabBarHostingWrapper( + content: TabBarView( + pane: pane, + isFocused: isFocused, + showSplitButtons: showSplitButtons + ) ) // Content area with drop zones diff --git a/Sources/Bonsplit/Internal/Views/SplitContainerView.swift b/Sources/Bonsplit/Internal/Views/SplitContainerView.swift index 01e85b41..cc7273b8 100644 --- a/Sources/Bonsplit/Internal/Views/SplitContainerView.swift +++ b/Sources/Bonsplit/Internal/Views/SplitContainerView.swift @@ -356,14 +356,8 @@ struct SplitContainerView: NSViewRepresentabl // MARK: - Helpers - private func makeHostingController(for node: SplitNode) -> NSHostingController { - let hostingController = NSHostingController(rootView: AnyView(makeView(for: node))) - if #available(macOS 13.0, *) { - // NSSplitView owns pane geometry. Keep NSHostingController from publishing - // intrinsic-size constraints that force a minimum pane width. - hostingController.sizingOptions = [] - } - + private func makeHostingController(for node: SplitNode) -> BonsplitHostingController { + let hostingController = BonsplitHostingController(rootView: AnyView(makeView(for: node))) let hostedView = hostingController.view // NSSplitView lays out arranged subviews by setting frames. Leaving Auto Layout // enabled on these NSHostingViews can allow them to compress to 0 during @@ -379,7 +373,7 @@ struct SplitContainerView: NSViewRepresentabl return hostingController } - private func installHostingController(_ hostingController: NSHostingController, into container: NSView) { + private func installHostingController(_ hostingController: BonsplitHostingController, into container: NSView) { let hostedView = hostingController.view hostedView.frame = container.bounds hostedView.autoresizingMask = [.width, .height] @@ -392,7 +386,7 @@ struct SplitContainerView: NSViewRepresentabl in container: NSView, node: SplitNode, nodeTypeChanged: Bool, - controller: inout NSHostingController? + controller: inout BonsplitHostingController? ) { // Historically we recreated the NSHostingController when the child node type changed // (pane <-> split) to force a full detach/reattach of native AppKit subviews. @@ -469,8 +463,8 @@ struct SplitContainerView: NSViewRepresentabl var firstNodeType: SplitNode.NodeType var secondNodeType: SplitNode.NodeType /// Retain hosting controllers so SwiftUI content stays alive - var firstHostingController: NSHostingController? - var secondHostingController: NSHostingController? + var firstHostingController: BonsplitHostingController? + var secondHostingController: BonsplitHostingController? init( splitState: SplitState, diff --git a/Sources/Bonsplit/Internal/Views/SplitNodeView.swift b/Sources/Bonsplit/Internal/Views/SplitNodeView.swift index ea098c97..668f657b 100644 --- a/Sources/Bonsplit/Internal/Views/SplitNodeView.swift +++ b/Sources/Bonsplit/Internal/Views/SplitNodeView.swift @@ -46,6 +46,7 @@ struct SplitNodeView: View { /// Container NSView for a pane inside SinglePaneWrapper. class PaneDragContainerView: NSView { + override var mouseDownCanMoveWindow: Bool { false } override var isOpaque: Bool { false } } @@ -68,7 +69,7 @@ struct SinglePaneWrapper: NSViewRepresentable showSplitButtons: showSplitButtons, contentViewLifecycle: contentViewLifecycle ) - let hostingController = NSHostingController(rootView: paneView) + let hostingController = BonsplitHostingController(rootView: paneView) hostingController.view.translatesAutoresizingMaskIntoConstraints = false let containerView = PaneDragContainerView() @@ -116,6 +117,6 @@ struct SinglePaneWrapper: NSViewRepresentable } class Coordinator { - var hostingController: NSHostingController>? + var hostingController: BonsplitHostingController>? } } diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 15705d8b..264689d0 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -12,6 +12,113 @@ private struct SelectedTabFramePreferenceKey: PreferenceKey { } } +func tabBarLeadingTrafficLightInset( + trafficLightMaxX: CGFloat, + tabBarMinXInWindow: CGFloat, + trailingPadding: CGFloat = 14 +) -> CGFloat { + max(0, trafficLightMaxX + trailingPadding - tabBarMinXInWindow) +} + +final class TabBarLeadingInsetPassthroughView: NSView { + var onInsetChange: ((CGFloat) -> Void)? + + private weak var observedWindow: NSWindow? + private var observers: [NSObjectProtocol] = [] + private var lastPublishedInset: CGFloat? + + override var mouseDownCanMoveWindow: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window !== observedWindow { + reinstallObservers(for: window) + } + publishInsetIfNeeded() + } + + override func layout() { + super.layout() + publishInsetIfNeeded() + } + + deinit { + removeObservers() + } + + func publishInsetIfNeeded() { + guard let window = self.window ?? observedWindow else { return } + + let buttonTypes: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton] + let trafficLightMaxX = buttonTypes + .compactMap { window.standardWindowButton($0)?.frame.maxX } + .max() ?? 0 + let frameInWindow = convert(bounds, to: nil) + let inset = tabBarLeadingTrafficLightInset( + trafficLightMaxX: trafficLightMaxX, + tabBarMinXInWindow: frameInWindow.minX + ) + guard lastPublishedInset == nil || abs((lastPublishedInset ?? 0) - inset) > 0.5 else { + return + } + lastPublishedInset = inset + onInsetChange?(inset) + } + + private func reinstallObservers(for window: NSWindow?) { + removeObservers() + observedWindow = window + guard let window else { return } + + let center = NotificationCenter.default + let names: [Notification.Name] = [ + NSWindow.didResizeNotification, + NSWindow.didEndLiveResizeNotification, + NSWindow.didBecomeKeyNotification, + NSWindow.didBecomeMainNotification, + ] + observers = names.map { name in + center.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in + self?.publishInsetIfNeeded() + } + } + } + + private func removeObservers() { + let center = NotificationCenter.default + for observer in observers { + center.removeObserver(observer) + } + observers.removeAll() + } +} + +private struct TabBarLeadingInsetReader: NSViewRepresentable { + @Binding var inset: CGFloat + + func makeNSView(context: Context) -> NSView { + let view = TabBarLeadingInsetPassthroughView() + view.setFrameSize(.zero) + view.onInsetChange = { nextInset in + if abs(nextInset - inset) > 0.5 { + inset = nextInset + } + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + guard let view = nsView as? TabBarLeadingInsetPassthroughView else { return } + view.onInsetChange = { nextInset in + if abs(nextInset - inset) > 0.5 { + inset = nextInset + } + } + view.publishInsetIfNeeded() + } +} + enum TabBarStyling { static func separatorSegments( totalWidth: CGFloat, @@ -71,7 +178,11 @@ struct TabBarView: View { @State private var contentWidth: CGFloat = 0 @State private var containerWidth: CGFloat = 0 @State private var selectedTabFrameInBar: CGRect? + @State private var isHoveringTabBar = false + @State private var leadingTrafficLightInset: CGFloat = 78 @StateObject private var controlKeyMonitor = TabControlShortcutKeyMonitor() + @AppStorage("workspacePresentationMode") + private var workspacePresentationMode = "standard" private var canScrollLeft: Bool { scrollOffset > 1 @@ -98,6 +209,24 @@ struct TabBarView: View { isFocused && controlKeyMonitor.isShortcutHintVisible } + private var isMinimalMode: Bool { + workspacePresentationMode == "minimal" + } + + private var shouldShowSplitButtonsNow: Bool { + !isMinimalMode || isHoveringTabBar + } + + private var effectiveLeadingInset: CGFloat { + isMinimalMode ? leadingTrafficLightInset : 0 + } + + private func handleEmptyTabBarDoubleClick() -> Bool { + guard splitViewController.isInteractive, !isMinimalMode else { return false } + controller.requestNewTab(kind: "terminal", inPane: pane.id) + return true + } + var body: some View { HStack(spacing: 0) { // Scrollable tabs with fade overlays @@ -115,7 +244,8 @@ struct TabBarView: View { // supports dropping after the last tab. dropZoneAfterTabs } - .padding(.horizontal, TabBarMetrics.barPadding) + .padding(.leading, TabBarMetrics.barPadding + effectiveLeadingInset) + .padding(.trailing, TabBarMetrics.barPadding) // Keep tab insert/remove/reorder instant without suppressing unrelated // subtree animations (for example, shortcut-hint fades). .animation(nil, value: pane.tabs.map(\.id)) @@ -143,11 +273,7 @@ struct TabBarView: View { .frame(width: trailing, height: TabBarMetrics.tabHeight) .contentShape(Rectangle()) .background( - EmptyTabBarDoubleClickMonitorView { - guard splitViewController.isInteractive else { return false } - controller.requestNewTab(kind: "terminal", inPane: pane.id) - return true - } + TabBarWindowDragRegion(onDoubleClick: handleEmptyTabBarDoubleClick) ) .onDrop(of: [.tabTransfer], delegate: TabDropDelegate( targetIndex: pane.tabs.count, @@ -187,11 +313,15 @@ struct TabBarView: View { if showSplitButtons { splitButtons .saturation(tabBarSaturation) + .opacity(shouldShowSplitButtonsNow ? 1 : 0) + .allowsHitTesting(shouldShowSplitButtonsNow) + .animation(.easeInOut(duration: TabBarMetrics.hoverDuration), value: shouldShowSplitButtonsNow) } } .frame(height: TabBarMetrics.barHeight) .coordinateSpace(name: "tabBar") .contentShape(Rectangle()) + .accessibilityIdentifier("paneTabBar") .background(tabBarBackground) .background( TabBarHostWindowReader { window in @@ -199,6 +329,9 @@ struct TabBarView: View { } .frame(width: 0, height: 0) ) + .background( + TabBarLeadingInsetReader(inset: $leadingTrafficLightInset) + ) // Clear drop state when drag ends elsewhere (cancelled, dropped in another pane, etc.) .onChange(of: splitViewController.draggingTab) { _, newValue in #if DEBUG @@ -222,6 +355,9 @@ struct TabBarView: View { .onDisappear { controlKeyMonitor.stop() } + .onHover { hovering in + isHoveringTabBar = hovering + } } // MARK: - Tab Item @@ -427,11 +563,7 @@ struct TabBarView: View { .frame(width: 30, height: TabBarMetrics.tabHeight) .contentShape(Rectangle()) .background( - EmptyTabBarDoubleClickMonitorView { - guard splitViewController.isInteractive else { return false } - controller.requestNewTab(kind: "terminal", inPane: pane.id) - return true - } + TabBarWindowDragRegion(onDoubleClick: handleEmptyTabBarDoubleClick) ) .onDrop(of: [.tabTransfer], delegate: TabDropDelegate( targetIndex: pane.tabs.count, @@ -457,6 +589,8 @@ struct TabBarView: View { .fill(TabBarColors.dropIndicator(for: appearance)) .frame(width: TabBarMetrics.dropIndicatorWidth, height: TabBarMetrics.dropIndicatorHeight) .offset(x: -1) + .accessibilityElement(children: .ignore) + .accessibilityIdentifier("paneTabBar.dropIndicator") } // MARK: - Split Buttons @@ -472,6 +606,7 @@ struct TabBarView: View { .font(.system(size: 12)) } .buttonStyle(SplitActionButtonStyle(appearance: appearance)) + .accessibilityIdentifier("paneTabBarControl.newTerminal") .safeHelp(tooltips.newTerminal) Button { @@ -481,6 +616,7 @@ struct TabBarView: View { .font(.system(size: 12)) } .buttonStyle(SplitActionButtonStyle(appearance: appearance)) + .accessibilityIdentifier("paneTabBarControl.newBrowser") .safeHelp(tooltips.newBrowser) Button { @@ -491,6 +627,7 @@ struct TabBarView: View { .font(.system(size: 12)) } .buttonStyle(SplitActionButtonStyle(appearance: appearance)) + .accessibilityIdentifier("paneTabBarControl.splitRight") .safeHelp(tooltips.splitRight) Button { @@ -501,6 +638,7 @@ struct TabBarView: View { .font(.system(size: 12)) } .buttonStyle(SplitActionButtonStyle(appearance: appearance)) + .accessibilityIdentifier("paneTabBarControl.splitDown") .safeHelp(tooltips.splitDown) } .padding(.trailing, 8) @@ -588,53 +726,6 @@ private struct SplitActionButtonStyle: ButtonStyle { } } -private struct EmptyTabBarDoubleClickMonitorView: NSViewRepresentable { - let onDoubleClick: () -> Bool - - final class Coordinator { - var onDoubleClick: (() -> Bool)? - weak var view: NSView? - var monitor: Any? - - deinit { - if let monitor { - NSEvent.removeMonitor(monitor) - } - } - } - - func makeCoordinator() -> Coordinator { Coordinator() } - - func makeNSView(context: Context) -> NSView { - let view = NSView(frame: .zero) - view.wantsLayer = true - view.layer?.backgroundColor = NSColor.clear.cgColor - - context.coordinator.view = view - context.coordinator.onDoubleClick = onDoubleClick - - let coordinator = context.coordinator - coordinator.monitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak coordinator] event in - guard event.clickCount >= 2 else { return event } - guard let coordinator, let view = coordinator.view, let window = view.window else { return event } - guard event.window === window else { return event } - - let point = view.convert(event.locationInWindow, from: nil) - guard view.bounds.contains(point) else { return event } - - guard coordinator.onDoubleClick?() == true else { return event } - return nil - } - - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - context.coordinator.view = nsView - context.coordinator.onDoubleClick = onDoubleClick - } -} - enum TabControlShortcutModifier: Equatable { case control case command diff --git a/Sources/Bonsplit/Internal/Views/TabBarWindowDragRegion.swift b/Sources/Bonsplit/Internal/Views/TabBarWindowDragRegion.swift new file mode 100644 index 00000000..bd6f7713 --- /dev/null +++ b/Sources/Bonsplit/Internal/Views/TabBarWindowDragRegion.swift @@ -0,0 +1,142 @@ +import AppKit +import SwiftUI + +private func performTabBarStandardDoubleClick(window: NSWindow?) -> Bool { + guard let window else { return false } + + let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) ?? [:] + if let action = (globalDefaults["AppleActionOnDoubleClick"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() { + switch action { + case "minimize": + window.miniaturize(nil) + return true + case "none": + return false + case "maximize", "zoom": + window.zoom(nil) + return true + default: + break + } + } + + if let miniaturizeOnDoubleClick = globalDefaults["AppleMiniaturizeOnDoubleClick"] as? Bool, + miniaturizeOnDoubleClick { + window.miniaturize(nil) + return true + } + + window.zoom(nil) + return true +} + +struct TabBarWindowDragRegion: NSViewRepresentable { + let onDoubleClick: (() -> Bool)? + + init(onDoubleClick: (() -> Bool)? = nil) { + self.onDoubleClick = onDoubleClick + } + + func makeNSView(context: Context) -> NSView { + let view = TabBarWindowDragRegionView() + view.onDoubleClick = onDoubleClick + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + guard let nsView = nsView as? TabBarWindowDragRegionView else { return } + nsView.onDoubleClick = onDoubleClick + } +} + +final class TabBarWindowDragRegionView: NSView { + var onDoubleClick: (() -> Bool)? + private var eventMonitor: Any? + + override var mouseDownCanMoveWindow: Bool { false } + + deinit { + removeEventMonitor() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + + removeEventMonitor() + if window != nil { + installEventMonitor() + } + } + + override func mouseDown(with event: NSEvent) { + if event.clickCount >= 2 { + if onDoubleClick?() == true { + return + } + if performTabBarStandardDoubleClick(window: window) { + return + } + } + + guard let window else { + super.mouseDown(with: event) + return + } + + let previousMovableState = window.isMovable + if !previousMovableState { + window.isMovable = true + } + defer { + if window.isMovable != previousMovableState { + window.isMovable = previousMovableState + } + } + + window.performDrag(with: event) + } + + private func installEventMonitor() { + guard eventMonitor == nil else { return } + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in + self?.handleLocalMouseDown(event) ?? event + } + } + + private func removeEventMonitor() { + guard let eventMonitor else { return } + NSEvent.removeMonitor(eventMonitor) + self.eventMonitor = nil + } + + private func handleLocalMouseDown(_ event: NSEvent) -> NSEvent? { + guard let window else { return event } + guard event.window === window else { return event } + + let point = convert(event.locationInWindow, from: nil) + guard bounds.contains(point) else { return event } + + if event.clickCount >= 2 { + if onDoubleClick?() == true { + return nil + } + + return performTabBarStandardDoubleClick(window: window) ? nil : event + } + + let previousMovableState = window.isMovable + if !previousMovableState { + window.isMovable = true + } + defer { + if window.isMovable != previousMovableState { + window.isMovable = previousMovableState + } + } + + window.performDrag(with: event) + return nil + } +} diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 08ec881d..ee652ae7 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -4,6 +4,36 @@ import AppKit import SwiftUI final class BonsplitTests: XCTestCase { + func testTabBarLeadingTrafficLightInsetKeepsFullClearanceWhenTabBarStartsAtWindowEdge() { + XCTAssertEqual( + tabBarLeadingTrafficLightInset( + trafficLightMaxX: 64, + tabBarMinXInWindow: 0 + ), + 78 + ) + } + + func testTabBarLeadingTrafficLightInsetDropsToZeroWhenTabBarStartsRightOfTrafficLights() { + XCTAssertEqual( + tabBarLeadingTrafficLightInset( + trafficLightMaxX: 64, + tabBarMinXInWindow: 220 + ), + 0 + ) + } + + func testTabBarLeadingTrafficLightInsetKeepsOnlyRemainingOverlapClearance() { + XCTAssertEqual( + tabBarLeadingTrafficLightInset( + trafficLightMaxX: 64, + tabBarMinXInWindow: 40 + ), + 38 + ) + } + @MainActor private final class LayoutProbeView: NSView { private(set) var sizeChangeCount = 0 @@ -76,6 +106,30 @@ final class BonsplitTests: XCTestCase { } } + private func withWorkspacePresentationMode(_ mode: String, perform: () throws -> T) rethrows -> T { + let defaults = UserDefaults.standard + let key = "workspacePresentationMode" + let previousValue = defaults.object(forKey: key) + defaults.set(mode, forKey: key) + defer { + if let previousValue { + defaults.set(previousValue, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + return try perform() + } + + @MainActor + private final class DragRecordingWindow: NSWindow { + private(set) var didPerformDrag = false + + override func performDrag(with event: NSEvent) { + didPerformDrag = true + } + } + @MainActor func testControllerCreation() { let controller = BonsplitController() @@ -553,6 +607,97 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(spy.requestedPaneId, pane.id) } + @MainActor + func testMinimalModeDoubleClickingEmptyTrailingTabBarSpaceDoesNotRequestNewTerminalTab() { + withWorkspacePresentationMode("minimal") { + let appearance = BonsplitConfiguration.Appearance(showSplitButtons: false) + let configuration = BonsplitConfiguration(appearance: appearance) + let controller = BonsplitController(configuration: configuration) + let pane = controller.internalController.rootNode.allPanes.first! + let spy = NewTabRequestDelegateSpy() + controller.delegate = spy + + let hostingView = NSHostingView( + rootView: TabBarView(pane: pane, isFocused: true, showSplitButtons: false) + .environment(controller) + .environment(controller.internalController) + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, 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 clickPoint = NSPoint(x: hostingView.bounds.maxX - 12, y: hostingView.bounds.midY) + guard let event = try? makeLeftMouseDownEvent(in: hostingView, at: clickPoint, clickCount: 2) else { + XCTFail("Expected mouse event") + return + } + NSApp.sendEvent(event) + + XCTAssertNil(spy.requestedKind) + XCTAssertNil(spy.requestedPaneId) + } + } + + @MainActor + func testMouseDownInEmptyTrailingTabBarSpaceStartsWindowDrag() { + let appearance = BonsplitConfiguration.Appearance(showSplitButtons: false) + let configuration = BonsplitConfiguration(appearance: appearance) + let controller = BonsplitController(configuration: configuration) + let pane = controller.internalController.rootNode.allPanes.first! + + let hostingView = NSHostingView( + rootView: TabBarView(pane: pane, isFocused: true, showSplitButtons: false) + .environment(controller) + .environment(controller.internalController) + ) + let window = DragRecordingWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, 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 clickPoint = NSPoint(x: hostingView.bounds.maxX - 12, y: hostingView.bounds.midY) + guard let event = try? makeLeftMouseDownEvent(in: hostingView, at: clickPoint, clickCount: 1) else { + XCTFail("Expected mouse event") + return + } + NSApp.sendEvent(event) + + XCTAssertTrue(window.didPerformDrag) + } + func testIconSaturationKeepsRasterFaviconInColorWhenInactive() { XCTAssertEqual( TabItemStyling.iconSaturation(hasRasterIcon: true, tabSaturation: 0.0),