diff --git a/Sources/Bonsplit/Internal/Views/BonsplitHostingView.swift b/Sources/Bonsplit/Internal/Views/BonsplitHostingView.swift new file mode 100644 index 00000000..0ba0ff77 --- /dev/null +++ b/Sources/Bonsplit/Internal/Views/BonsplitHostingView.swift @@ -0,0 +1,53 @@ +import AppKit +import SwiftUI + +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 25090abc..614c3b81 100644 --- a/Sources/Bonsplit/Internal/Views/PaneContainerView.swift +++ b/Sources/Bonsplit/Internal/Views/PaneContainerView.swift @@ -2,6 +2,142 @@ 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 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 @@ -174,10 +310,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 faa29891..45a1da86 100644 --- a/Sources/Bonsplit/Internal/Views/SplitContainerView.swift +++ b/Sources/Bonsplit/Internal/Views/SplitContainerView.swift @@ -122,7 +122,7 @@ struct SplitContainerView: NSViewRepresentabl // Keep arranged subviews stable (always 2) to avoid transient "collapse" flashes when // replacing pane<->split content. We swap the hosted content within these containers. - let firstContainer = NSView() + let firstContainer = PaneDragContainerView() firstContainer.wantsLayer = true firstContainer.layer?.backgroundColor = chromeBackgroundColor.cgColor firstContainer.layer?.masksToBounds = true @@ -131,7 +131,7 @@ struct SplitContainerView: NSViewRepresentabl splitView.addArrangedSubview(firstContainer) context.coordinator.firstHostingController = firstController - let secondContainer = NSView() + let secondContainer = PaneDragContainerView() secondContainer.wantsLayer = true secondContainer.layer?.backgroundColor = chromeBackgroundColor.cgColor secondContainer.layer?.masksToBounds = true @@ -355,14 +355,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 @@ -378,7 +372,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] @@ -391,7 +385,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. @@ -468,8 +462,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 fa69e6b1..80106180 100644 --- a/Sources/Bonsplit/Internal/Views/SplitNodeView.swift +++ b/Sources/Bonsplit/Internal/Views/SplitNodeView.swift @@ -45,7 +45,9 @@ struct SplitNodeView: View { } /// Container NSView for a pane inside SinglePaneWrapper. -class PaneDragContainerView: NSView {} +class PaneDragContainerView: NSView { + override var mouseDownCanMoveWindow: Bool { false } +} /// Wrapper that uses NSHostingController for proper AppKit layout constraints struct SinglePaneWrapper: NSViewRepresentable { @@ -66,7 +68,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() @@ -110,6 +112,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 e14b6e96..62893fd7 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -12,6 +12,223 @@ private struct SelectedTabFramePreferenceKey: PreferenceKey { } } +private struct TabDropFrame: Equatable { + let index: Int + let frame: CGRect +} + +private struct TabDropFramesPreferenceKey: PreferenceKey { + static let defaultValue: [TabDropFrame] = [] + + static func reduce(value: inout [TabDropFrame], nextValue: () -> [TabDropFrame]) { + value.append(contentsOf: nextValue()) + } +} + +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() { + DispatchQueue.main.async { [weak self] in + guard let self, let window = self.window ?? self.observedWindow else { return } + + let buttonTypes: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton] + let trafficLightMaxX = buttonTypes + .compactMap { window.standardWindowButton($0)?.frame.maxX } + .max() ?? 0 + let frameInWindow = self.convert(self.bounds, to: nil) + let inset = tabBarLeadingTrafficLightInset( + trafficLightMaxX: trafficLightMaxX, + tabBarMinXInWindow: frameInWindow.minX + ) + guard self.lastPublishedInset == nil || abs((self.lastPublishedInset ?? 0) - inset) > 0.5 else { + return + } + self.lastPublishedInset = inset + self.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() + } +} + +private final class TabBarNativeDropDestinationView: NSView { + var isEnabled = false + var validateDrop: ((NSPasteboard) -> Bool)? + var updateDropTarget: ((CGPoint) -> Void)? + var clearDropState: (() -> Void)? + var performDrop: ((CGPoint, NSPasteboard) -> Bool)? + + override var mouseDownCanMoveWindow: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { + guard isEnabled, bounds.contains(point) else { return nil } + return self + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + registerForDraggedTypes([NSPasteboard.PasteboardType(UTType.tabTransfer.identifier)]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + updateDrag(sender) + } + + override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + updateDrag(sender) + } + + override func draggingExited(_ sender: (any NSDraggingInfo)?) { + clearDropState?() + } + + override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { + isEnabled && (validateDrop?(sender.draggingPasteboard) ?? false) + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + guard isEnabled, (validateDrop?(sender.draggingPasteboard) ?? false) else { + clearDropState?() + return false + } + + let point = convert(sender.draggingLocation, from: nil) + guard bounds.contains(point) else { + clearDropState?() + return false + } + return performDrop?(CGPoint(x: point.x, y: point.y), sender.draggingPasteboard) ?? false + } + + private func updateDrag(_ sender: any NSDraggingInfo) -> NSDragOperation { + guard isEnabled, (validateDrop?(sender.draggingPasteboard) ?? false) else { + clearDropState?() + return [] + } + + let point = convert(sender.draggingLocation, from: nil) + guard bounds.contains(point) else { + clearDropState?() + return [] + } + + updateDropTarget?(CGPoint(x: point.x, y: point.y)) + return .move + } +} + +private struct TabBarNativeDropBridge: NSViewRepresentable { + let isEnabled: Bool + let validateDrop: (NSPasteboard) -> Bool + let updateDropTarget: (CGPoint) -> Void + let clearDropState: () -> Void + let performDrop: (CGPoint, NSPasteboard) -> Bool + + func makeNSView(context: Context) -> NSView { + let view = TabBarNativeDropDestinationView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + guard let view = nsView as? TabBarNativeDropDestinationView else { return } + view.isEnabled = isEnabled + view.validateDrop = validateDrop + view.updateDropTarget = updateDropTarget + view.clearDropState = clearDropState + view.performDrop = performDrop + if !isEnabled { + clearDropState() + } + } +} + enum TabBarStyling { static func separatorSegments( totalWidth: CGFloat, @@ -53,6 +270,11 @@ struct TabContextMenuState { } } +private enum TabBarControlsVisibilityMode: String { + case always + case onHover +} + /// Tab bar view with scrollable tabs, drag/drop support, and split buttons struct TabBarView: View { @Environment(BonsplitController.self) private var controller @@ -68,7 +290,14 @@ struct TabBarView: View { @State private var contentWidth: CGFloat = 0 @State private var containerWidth: CGFloat = 0 @State private var selectedTabFrameInBar: CGRect? + @State private var tabDropFrames: [TabDropFrame] = [] + @State private var isHoveringTabBar = false + @State private var leadingTrafficLightInset: CGFloat = 0 @StateObject private var controlKeyMonitor = TabControlShortcutKeyMonitor() + @AppStorage("paneTabBarControlsVisibilityMode") + private var controlsVisibilityModeRawValue = TabBarControlsVisibilityMode.always.rawValue + @AppStorage("workspaceTitlebarVisible") + private var showWorkspaceTitlebar = true private var canScrollLeft: Bool { scrollOffset > 1 @@ -95,6 +324,31 @@ struct TabBarView: View { isFocused && controlKeyMonitor.isShortcutHintVisible } + private var controlsVisibilityMode: TabBarControlsVisibilityMode { + TabBarControlsVisibilityMode(rawValue: controlsVisibilityModeRawValue) ?? .always + } + + private var shouldShowSplitButtonsNow: Bool { + switch controlsVisibilityMode { + case .always: + return true + case .onHover: + return isHoveringTabBar + } + } + + private var shouldUseTabBarDragRegion: Bool { + !showWorkspaceTitlebar + } + + private var isTabDragActive: Bool { + splitViewController.draggingTab != nil || splitViewController.activeDragTab != nil + } + + private var effectiveLeadingInset: CGFloat { + showWorkspaceTitlebar ? 0 : leadingTrafficLightInset + } + var body: some View { HStack(spacing: 0) { // Scrollable tabs with fade overlays @@ -112,7 +366,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)) @@ -136,17 +391,7 @@ struct TabBarView: View { .overlay(alignment: .trailing) { let trailing = max(0, containerGeo.size.width - contentWidth) if trailing >= 1 { - Color.clear - .frame(width: trailing, height: TabBarMetrics.tabHeight) - .contentShape(Rectangle()) - .onDrop(of: [.tabTransfer], delegate: TabDropDelegate( - targetIndex: pane.tabs.count, - pane: pane, - bonsplitController: controller, - controller: splitViewController, - dropTargetIndex: $dropTargetIndex, - dropLifecycle: $dropLifecycle - )) + trailingInteractionView(width: trailing) } } .coordinateSpace(name: "tabScroll") @@ -175,13 +420,13 @@ struct TabBarView: View { // Split buttons if showSplitButtons { - splitButtons - .saturation(tabBarSaturation) + splitButtonsArea } } .frame(height: TabBarMetrics.barHeight) .coordinateSpace(name: "tabBar") .contentShape(Rectangle()) + .accessibilityIdentifier("paneTabBar") .background(tabBarBackground) .background( TabBarHostWindowReader { window in @@ -189,6 +434,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 @@ -209,9 +457,64 @@ struct TabBarView: View { .onPreferenceChange(SelectedTabFramePreferenceKey.self) { frame in selectedTabFrameInBar = frame } + .onPreferenceChange(TabDropFramesPreferenceKey.self) { frames in + tabDropFrames = frames.sorted { lhs, rhs in + if lhs.index == rhs.index { + return lhs.frame.minX < rhs.frame.minX + } + return lhs.index < rhs.index + } + } .onDisappear { controlKeyMonitor.stop() } + .onHover { hovering in + isHoveringTabBar = hovering + } + .overlay { + TabBarNativeDropBridge( + isEnabled: isTabDragActive, + validateDrop: { pasteboard in + TabDropDelegate.validateDrop(pasteboard: pasteboard, controller: splitViewController) + }, + updateDropTarget: { location in + let targetIndex = TabDropDelegate.resolvedTargetIndex( + for: location, + frames: tabDropFrames, + fallbackCount: pane.tabs.count + ) + TabDropDelegate.updateDropState( + targetIndex: targetIndex, + pane: pane, + controller: splitViewController, + dropTargetIndex: $dropTargetIndex, + dropLifecycle: $dropLifecycle + ) + }, + clearDropState: { + TabDropDelegate.clearDropState( + dropTargetIndex: $dropTargetIndex, + dropLifecycle: $dropLifecycle + ) + }, + performDrop: { location, pasteboard in + let targetIndex = TabDropDelegate.resolvedTargetIndex( + for: location, + frames: tabDropFrames, + fallbackCount: pane.tabs.count + ) + return TabDropDelegate.performDrop( + targetIndex: targetIndex, + pasteboard: pasteboard, + pane: pane, + bonsplitController: controller, + controller: splitViewController, + dropTargetIndex: $dropTargetIndex, + dropLifecycle: $dropLifecycle + ) + } + ) + } } // MARK: - Tab Item @@ -261,12 +564,17 @@ struct TabBarView: View { ) .background( GeometryReader { geometry in + let frame = geometry.frame(in: .named("tabBar")) Color.clear.preference( key: SelectedTabFramePreferenceKey.self, value: pane.selectedTabId == tab.id - ? geometry.frame(in: .named("tabBar")) + ? frame : nil ) + .preference( + key: TabDropFramesPreferenceKey.self, + value: [TabDropFrame(index: index, frame: frame)] + ) } ) .onDrag { @@ -436,10 +744,50 @@ struct TabBarView: View { .fill(TabBarColors.dropIndicator(for: appearance)) .frame(width: TabBarMetrics.dropIndicatorWidth, height: TabBarMetrics.dropIndicatorHeight) .offset(x: -1) + .accessibilityElement(children: .ignore) + .accessibilityIdentifier("paneTabBar.dropIndicator") + } + + @ViewBuilder + private func trailingInteractionView(width: CGFloat) -> some View { + if shouldUseTabBarDragRegion && !isTabDragActive { + TabBarWindowDragRegion() + .frame(width: width, height: TabBarMetrics.tabHeight) + } else { + Color.clear + .frame(width: width, height: TabBarMetrics.tabHeight) + .contentShape(Rectangle()) + .onDrop(of: [.tabTransfer], delegate: TabDropDelegate( + targetIndex: pane.tabs.count, + pane: pane, + bonsplitController: controller, + controller: splitViewController, + dropTargetIndex: $dropTargetIndex, + dropLifecycle: $dropLifecycle + )) + } } // MARK: - Split Buttons + @ViewBuilder + private var splitButtonsArea: some View { + ZStack(alignment: .trailing) { + if !shouldShowSplitButtonsNow && shouldUseTabBarDragRegion && !isTabDragActive { + TabBarWindowDragRegion() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + splitButtons + .saturation(tabBarSaturation) + .opacity(shouldShowSplitButtonsNow ? 1 : 0) + .allowsHitTesting(shouldShowSplitButtonsNow) + .animation(.easeInOut(duration: TabBarMetrics.hoverDuration), value: shouldShowSplitButtonsNow) + } + .contentShape(Rectangle()) + .accessibilityIdentifier("paneTabBarControlsRegion") + } + @ViewBuilder private var splitButtons: some View { let tooltips = controller.configuration.appearance.splitButtonTooltips @@ -451,6 +799,7 @@ struct TabBarView: View { .font(.system(size: 12)) } .buttonStyle(SplitActionButtonStyle(appearance: appearance)) + .accessibilityIdentifier("paneTabBarControl.newTerminal") .safeHelp(tooltips.newTerminal) Button { @@ -460,6 +809,7 @@ struct TabBarView: View { .font(.system(size: 12)) } .buttonStyle(SplitActionButtonStyle(appearance: appearance)) + .accessibilityIdentifier("paneTabBarControl.newBrowser") .safeHelp(tooltips.newBrowser) Button { @@ -470,6 +820,7 @@ struct TabBarView: View { .font(.system(size: 12)) } .buttonStyle(SplitActionButtonStyle(appearance: appearance)) + .accessibilityIdentifier("paneTabBarControl.splitRight") .safeHelp(tooltips.splitRight) Button { @@ -480,6 +831,7 @@ struct TabBarView: View { .font(.system(size: 12)) } .buttonStyle(SplitActionButtonStyle(appearance: appearance)) + .accessibilityIdentifier("paneTabBarControl.splitDown") .safeHelp(tooltips.splitDown) } .padding(.trailing, 8) @@ -844,14 +1196,67 @@ enum TabDropLifecycle { // MARK: - Tab Drop Delegate struct TabDropDelegate: DropDelegate { - let targetIndex: Int let pane: PaneState let bonsplitController: BonsplitController let controller: SplitViewController @Binding var dropTargetIndex: Int? @Binding var dropLifecycle: TabDropLifecycle + let resolveTargetIndex: (CGPoint) -> Int + + init( + targetIndex: Int, + pane: PaneState, + bonsplitController: BonsplitController, + controller: SplitViewController, + dropTargetIndex: Binding, + dropLifecycle: Binding + ) { + self.pane = pane + self.bonsplitController = bonsplitController + self.controller = controller + self._dropTargetIndex = dropTargetIndex + self._dropLifecycle = dropLifecycle + self.resolveTargetIndex = { _ in targetIndex } + } + + init( + pane: PaneState, + bonsplitController: BonsplitController, + controller: SplitViewController, + dropTargetIndex: Binding, + dropLifecycle: Binding, + resolveTargetIndex: @escaping (CGPoint) -> Int + ) { + self.pane = pane + self.bonsplitController = bonsplitController + self.controller = controller + self._dropTargetIndex = dropTargetIndex + self._dropLifecycle = dropLifecycle + self.resolveTargetIndex = resolveTargetIndex + } func performDrop(info: DropInfo) -> Bool { + let targetIndex = resolveTargetIndex(info.location) + return Self.performDrop( + targetIndex: targetIndex, + pasteboard: NSPasteboard(name: .drag), + pane: pane, + bonsplitController: bonsplitController, + controller: controller, + dropTargetIndex: $dropTargetIndex, + dropLifecycle: $dropLifecycle + ) + } + + fileprivate static func performDrop( + targetIndex: Int, + pasteboard: NSPasteboard, + pane: PaneState, + bonsplitController: BonsplitController, + controller: SplitViewController, + dropTargetIndex: Binding, + dropLifecycle: Binding + ) -> Bool { #if DEBUG NSLog("[Bonsplit Drag] performDrop called, targetIndex: \(targetIndex)") #endif @@ -863,7 +1268,15 @@ struct TabDropDelegate: DropDelegate { // callbacks off-main, and SplitViewController is @MainActor. if !Thread.isMainThread { return DispatchQueue.main.sync { - performDrop(info: info) + performDrop( + targetIndex: targetIndex, + pasteboard: pasteboard, + pane: pane, + bonsplitController: bonsplitController, + controller: controller, + dropTargetIndex: dropTargetIndex, + dropLifecycle: dropLifecycle + ) } } @@ -871,7 +1284,7 @@ struct TabDropDelegate: DropDelegate { // may not have propagated yet when performDrop runs. guard let draggedTab = controller.activeDragTab ?? controller.draggingTab, let sourcePaneId = controller.activeDragSourcePaneId ?? controller.dragSourcePaneId else { - guard let transfer = decodeTransfer(from: info), + guard let transfer = decodeTransfer(from: pasteboard), transfer.isFromCurrentProcess else { return false } @@ -882,8 +1295,7 @@ struct TabDropDelegate: DropDelegate { ) let handled = bonsplitController.onExternalTabDrop?(request) ?? false if handled { - dropLifecycle = .idle - dropTargetIndex = nil + clearDropState(dropTargetIndex: dropTargetIndex, dropLifecycle: dropLifecycle) } return handled } @@ -914,8 +1326,7 @@ struct TabDropDelegate: DropDelegate { // Clear visual state immediately to prevent lingering indicators. // Must happen synchronously before returning, not in async callback. // Setting dropLifecycle to idle prevents dropUpdated from re-setting dropTargetIndex. - dropLifecycle = .idle - dropTargetIndex = nil + clearDropState(dropTargetIndex: dropTargetIndex, dropLifecycle: dropLifecycle) controller.draggingTab = nil controller.dragSourcePaneId = nil controller.activeDragTab = nil @@ -925,6 +1336,7 @@ struct TabDropDelegate: DropDelegate { } func dropEntered(info: DropInfo) { + let targetIndex = resolveTargetIndex(info.location) #if DEBUG NSLog("[Bonsplit Drag] dropEntered at index: \(targetIndex)") dlog( @@ -933,26 +1345,26 @@ struct TabDropDelegate: DropDelegate { "hasActive=\(controller.activeDragTab != nil ? 1 : 0)" ) #endif - dropLifecycle = .hovering - if shouldSuppressIndicatorForNoopSamePaneDrop() { - dropTargetIndex = nil - } else { - dropTargetIndex = targetIndex - } + Self.updateDropState( + targetIndex: targetIndex, + pane: pane, + controller: controller, + dropTargetIndex: $dropTargetIndex, + dropLifecycle: $dropLifecycle + ) } func dropExited(info: DropInfo) { + let targetIndex = resolveTargetIndex(info.location) #if DEBUG NSLog("[Bonsplit Drag] dropExited from index: \(targetIndex)") dlog("tab.dropExited pane=\(pane.id.id.uuidString.prefix(5)) targetIndex=\(targetIndex)") #endif - dropLifecycle = .idle - if dropTargetIndex == targetIndex { - dropTargetIndex = nil - } + Self.clearDropState(dropTargetIndex: $dropTargetIndex, dropLifecycle: $dropLifecycle) } func dropUpdated(info: DropInfo) -> DropProposal? { + let targetIndex = resolveTargetIndex(info.location) // Guard against dropUpdated firing after performDrop/dropExited // This is the key fix for the lingering indicator bug guard dropLifecycle == .hovering else { @@ -961,14 +1373,13 @@ struct TabDropDelegate: DropDelegate { #endif return DropProposal(operation: .move) } - // Only update if this is the active target, and suppress same-pane no-op indicators. - if shouldSuppressIndicatorForNoopSamePaneDrop() { - if dropTargetIndex == targetIndex { - dropTargetIndex = nil - } - } else if dropTargetIndex != targetIndex { - dropTargetIndex = targetIndex - } + Self.updateDropState( + targetIndex: targetIndex, + pane: pane, + controller: controller, + dropTargetIndex: $dropTargetIndex, + dropLifecycle: $dropLifecycle + ) #if DEBUG dlog( "tab.dropUpdated pane=\(pane.id.id.uuidString.prefix(5)) targetIndex=\(targetIndex) " + @@ -979,41 +1390,62 @@ struct TabDropDelegate: DropDelegate { } func validateDrop(info: DropInfo) -> Bool { - // Reject drops on inactive workspaces whose views are kept alive in a ZStack. - guard controller.isInteractive else { -#if DEBUG - dlog("tab.validateDrop pane=\(pane.id.id.uuidString.prefix(5)) allowed=0 reason=inactive") -#endif - return false - } - // The custom UTType alone is sufficient — only Bonsplit tab drags produce it. - // Do NOT gate on draggingTab != nil: @Observable changes from createItemProvider - // may not have propagated to the drop delegate yet, causing false rejections. - let hasType = info.hasItemsConforming(to: [.tabTransfer]) - guard hasType else { return false } + Self.validateDrop(pasteboard: NSPasteboard(name: .drag), controller: controller) + } + + fileprivate static func validateDrop( + pasteboard: NSPasteboard, + controller: SplitViewController + ) -> Bool { + guard controller.isInteractive else { return false } + + let type = NSPasteboard.PasteboardType(UTType.tabTransfer.identifier) + guard pasteboard.availableType(from: [type]) != nil else { return false } - // Local drags use in-memory state and are always same-process. if controller.activeDragTab != nil || controller.draggingTab != nil { return true } - // External drags (another Bonsplit controller) must include a payload from this process. - guard let transfer = decodeTransfer(from: info), + guard let transfer = decodeTransfer(from: pasteboard), transfer.isFromCurrentProcess else { return false } -#if DEBUG - let hasDrag = controller.draggingTab != nil - let hasActive = controller.activeDragTab != nil - dlog( - "tab.validateDrop pane=\(pane.id.id.uuidString.prefix(5)) " + - "allowed=\(hasType ? 1 : 0) hasDrag=\(hasDrag ? 1 : 0) hasActive=\(hasActive ? 1 : 0)" - ) -#endif + return true } - private func shouldSuppressIndicatorForNoopSamePaneDrop() -> Bool { + fileprivate static func clearDropState( + dropTargetIndex: Binding, + dropLifecycle: Binding + ) { + dropLifecycle.wrappedValue = .idle + dropTargetIndex.wrappedValue = nil + } + + fileprivate static func updateDropState( + targetIndex: Int, + pane: PaneState, + controller: SplitViewController, + dropTargetIndex: Binding, + dropLifecycle: Binding + ) { + dropLifecycle.wrappedValue = .hovering + if shouldSuppressIndicatorForNoopSamePaneDrop( + targetIndex: targetIndex, + pane: pane, + controller: controller + ) { + dropTargetIndex.wrappedValue = nil + } else if dropTargetIndex.wrappedValue != targetIndex { + dropTargetIndex.wrappedValue = targetIndex + } + } + + fileprivate static func shouldSuppressIndicatorForNoopSamePaneDrop( + targetIndex: Int, + pane: PaneState, + controller: SplitViewController + ) -> Bool { guard let draggedTab = controller.draggingTab, controller.dragSourcePaneId == pane.id, let sourceIndex = pane.tabs.firstIndex(where: { $0.id == draggedTab.id }) else { @@ -1024,7 +1456,7 @@ struct TabDropDelegate: DropDelegate { return targetIndex == sourceIndex || targetIndex == sourceIndex + 1 } - private func decodeTransfer(from string: String) -> TabTransferData? { + private static func decodeTransfer(from string: String) -> TabTransferData? { guard let data = string.data(using: .utf8), let transfer = try? JSONDecoder().decode(TabTransferData.self, from: data) else { return nil @@ -1032,8 +1464,7 @@ struct TabDropDelegate: DropDelegate { return transfer } - private func decodeTransfer(from info: DropInfo) -> TabTransferData? { - let pasteboard = NSPasteboard(name: .drag) + private static func decodeTransfer(from pasteboard: NSPasteboard) -> TabTransferData? { let type = NSPasteboard.PasteboardType(UTType.tabTransfer.identifier) if let data = pasteboard.data(forType: type), let transfer = try? JSONDecoder().decode(TabTransferData.self, from: data) { @@ -1044,4 +1475,20 @@ struct TabDropDelegate: DropDelegate { } return nil } + + fileprivate static func resolvedTargetIndex( + for location: CGPoint, + frames: [TabDropFrame], + fallbackCount: Int + ) -> Int { + guard !frames.isEmpty else { return fallbackCount } + + for frame in frames { + if location.x < frame.frame.midX { + return frame.index + } + } + + return fallbackCount + } } diff --git a/Sources/Bonsplit/Internal/Views/TabBarWindowDragRegion.swift b/Sources/Bonsplit/Internal/Views/TabBarWindowDragRegion.swift new file mode 100644 index 00000000..2912eaff --- /dev/null +++ b/Sources/Bonsplit/Internal/Views/TabBarWindowDragRegion.swift @@ -0,0 +1,68 @@ +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 { + func makeNSView(context: Context) -> NSView { + TabBarWindowDragRegionView() + } + + func updateNSView(_ nsView: NSView, context: Context) {} +} + +final class TabBarWindowDragRegionView: NSView { + override var mouseDownCanMoveWindow: Bool { false } + + override func mouseDown(with event: NSEvent) { + if event.clickCount >= 2, 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) + } +} diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 8f017571..cbca418c 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