diff --git a/Sources/Bonsplit/Internal/Views/SplitContainerView.swift b/Sources/Bonsplit/Internal/Views/SplitContainerView.swift index 1de6338e..b7a78fc7 100644 --- a/Sources/Bonsplit/Internal/Views/SplitContainerView.swift +++ b/Sources/Bonsplit/Internal/Views/SplitContainerView.swift @@ -118,7 +118,10 @@ 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() + // The containers use `SplitArrangedContainerView` (rather than bare NSView) so they + // override `mouseDownCanMoveWindow=false` — see `NonDraggableHostingView` in + // SplitNodeView.swift for the regression this guards against. + let firstContainer = SplitArrangedContainerView() firstContainer.wantsLayer = true firstContainer.layer?.backgroundColor = NSColor.clear.cgColor firstContainer.layer?.isOpaque = false @@ -128,7 +131,7 @@ struct SplitContainerView: NSViewRepresentabl splitView.addArrangedSubview(firstContainer) context.coordinator.firstHostingController = firstController - let secondContainer = NSView() + let secondContainer = SplitArrangedContainerView() secondContainer.wantsLayer = true secondContainer.layer?.backgroundColor = NSColor.clear.cgColor secondContainer.layer?.isOpaque = false @@ -356,8 +359,8 @@ struct SplitContainerView: NSViewRepresentabl // MARK: - Helpers - private func makeHostingController(for node: SplitNode) -> NSHostingController { - let hostingController = NSHostingController(rootView: AnyView(makeView(for: node))) + private func makeHostingController(for node: SplitNode) -> NonDraggableHostingController { + let hostingController = NonDraggableHostingController(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. @@ -379,7 +382,7 @@ struct SplitContainerView: NSViewRepresentabl return hostingController } - private func installHostingController(_ hostingController: NSHostingController, into container: NSView) { + private func installHostingController(_ hostingController: NonDraggableHostingController, into container: NSView) { let hostedView = hostingController.view hostedView.frame = container.bounds hostedView.autoresizingMask = [.width, .height] @@ -392,7 +395,7 @@ struct SplitContainerView: NSViewRepresentabl in container: NSView, node: SplitNode, nodeTypeChanged: Bool, - controller: inout NSHostingController? + controller: inout NonDraggableHostingController? ) { // 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 +472,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: NonDraggableHostingController? + var secondHostingController: NonDraggableHostingController? init( splitState: SplitState, diff --git a/Sources/Bonsplit/Internal/Views/SplitNodeView.swift b/Sources/Bonsplit/Internal/Views/SplitNodeView.swift index ea098c97..091e9d10 100644 --- a/Sources/Bonsplit/Internal/Views/SplitNodeView.swift +++ b/Sources/Bonsplit/Internal/Views/SplitNodeView.swift @@ -44,9 +44,46 @@ struct SplitNodeView: View { } } +/// NSHostingView subclass that refuses to act as a window-drag handle. +/// +/// Bonsplit nests SwiftUI panes inside their own `NSHostingController` instances +/// (via `SinglePaneWrapper` and `SplitContainerView.makeHostingController`). +/// The default `NSHostingView` returned by `NSHostingController.view` inherits the +/// AppKit default of `mouseDownCanMoveWindow == true` when the view appears opaque. +/// In `presentationMode == "minimal"` (where the window has no titlebar drag region) +/// AppKit was treating clicks on pane tab bars as window-drag intents and stealing the +/// mouseUp before the SwiftUI tap gesture could fire — making split pane tabs +/// completely unclickable. Routing clicks through this subclass keeps the entire pane +/// hosting chain non-draggable so SwiftUI gesture recognizers receive every click. +final class NonDraggableHostingView: NSHostingView { + override var mouseDownCanMoveWindow: Bool { false } +} + +/// NSHostingController whose view is a `NonDraggableHostingView`. See the comment on +/// `NonDraggableHostingView` for the rationale — this exists so call sites can keep using +/// the controller-based lifecycle (root-view swapping, sizing options, etc.) without +/// having to construct and reparent the hosting view manually. +final class NonDraggableHostingController: NSHostingController { + override func loadView() { + view = NonDraggableHostingView(rootView: rootView) + } +} + /// Container NSView for a pane inside SinglePaneWrapper. class PaneDragContainerView: NSView { override var isOpaque: Bool { false } + // Mirror the override on `NonDraggableHostingView` so AppKit cannot grab a window + // drag from this container either, even before the click reaches the inner hosting + // view. See `NonDraggableHostingView` for the full rationale. + override var mouseDownCanMoveWindow: Bool { false } +} + +/// Bare container used by `SplitContainerView` to back NSSplitView arranged subviews. +/// Like `PaneDragContainerView`, this exists purely to suppress AppKit window-drag +/// intent so split-pane tab clicks are not consumed by drag detection in minimal mode. +final class SplitArrangedContainerView: NSView { + override var isOpaque: Bool { false } + override var mouseDownCanMoveWindow: Bool { false } } /// Wrapper that uses NSHostingController for proper AppKit layout constraints @@ -68,7 +105,7 @@ struct SinglePaneWrapper: NSViewRepresentable showSplitButtons: showSplitButtons, contentViewLifecycle: contentViewLifecycle ) - let hostingController = NSHostingController(rootView: paneView) + let hostingController = NonDraggableHostingController(rootView: paneView) hostingController.view.translatesAutoresizingMaskIntoConstraints = false let containerView = PaneDragContainerView() @@ -116,6 +153,6 @@ struct SinglePaneWrapper: NSViewRepresentable } class Coordinator { - var hostingController: NSHostingController>? + var hostingController: NonDraggableHostingController>? } } diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 8d083c90..aa18d0a1 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -183,18 +183,40 @@ struct TabBarView: View { } .frame(height: TabBarMetrics.barHeight) .mask(combinedMask) - // Buttons float on top. No backdrop color needed because - // the mask hides scroll content and the tab bar's own - // background shows through naturally. + // Split buttons sit on top of the tab strip in their own opaque backdrop. + // The backdrop visually obscures any tabs that scroll under the buttons, + // and (critically) does not break hit testing on tabs outside the backdrop — + // unlike the prior approach of using a `Color.clear` region in `combinedMask`, + // which silently blocked SwiftUI hit tests in the masked-out area and let + // tab clicks fall through to `TabBarDragAndHoverView` (which performs a + // window drag in minimal mode). .overlay(alignment: .trailing) { if showSplitButtons { let shouldShow = presentationMode != "minimal" || isHoveringTabBar - splitButtons - .saturation(tabBarSaturation) - .padding(.bottom, 1) - .opacity(shouldShow ? 1 : 0) - .allowsHitTesting(shouldShow) - .animation(.easeInOut(duration: 0.14), value: shouldShow) + let backdropColor = Color(nsColor: Self.buttonBackdropColor( + for: appearance, + focused: isFocused, + style: fadeColorStyle + )) + ZStack(alignment: .trailing) { + HStack(spacing: 0) { + LinearGradient( + colors: [backdropColor.opacity(0), backdropColor], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: 24) + Rectangle().fill(backdropColor) + } + .frame(width: 114) + + splitButtons + .saturation(tabBarSaturation) + } + .padding(.bottom, 1) + .opacity(shouldShow ? 1 : 0) + .allowsHitTesting(shouldShow) + .animation(.easeInOut(duration: 0.14), value: shouldShow) } } } @@ -571,32 +593,31 @@ struct TabBarView: View { } // MARK: - Combined Mask (scroll fades + button area) + // + // IMPORTANT: SwiftUI's `.mask()` with `Color.clear` regions blocks hit testing on the + // masked content in those regions. Previously this mask used a 90pt clear region at the + // trailing edge to hide tabs under the split buttons; that caused clicks on tabs in that + // 90pt area to fall through the masked ScrollView to the `TabBarDragAndHoverView` + // background, which (in minimal mode) interpreted the click as a window drag instead + // of a tab tap. Keep the entire mask opaque so hit testing works on every tab; the split + // buttons' opaque backdrop (rendered in the splitButtons overlay) handles the visual + // obscuring of tabs underneath. @ViewBuilder private var combinedMask: some View { let fadeWidth: CGFloat = 24 - let shouldShowButtons = showSplitButtons && (presentationMode != "minimal" || isHoveringTabBar) - let buttonClearWidth: CGFloat = shouldShowButtons ? 90 : 0 - let buttonFadeWidth: CGFloat = shouldShowButtons ? fadeWidth : 0 - HStack(spacing: 0) { // Left scroll fade LinearGradient(colors: [.clear, .black], startPoint: .leading, endPoint: .trailing) .frame(width: canScrollLeft ? fadeWidth : 0) - // Visible content area + // Visible content area (always opaque so hit testing reaches the tabs) Rectangle().fill(Color.black) - // Right: either scroll fade or button area fade + // Right scroll fade only when scroll content actually overflows. LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing) - .frame(width: canScrollRight || shouldShowButtons ? fadeWidth : 0) - - // Button clear area (content hidden here) - if shouldShowButtons { - Color.clear.frame(width: buttonClearWidth) - } + .frame(width: canScrollRight ? fadeWidth : 0) } - .animation(.easeInOut(duration: 0.14), value: shouldShowButtons) } // MARK: - Fade Overlays @@ -713,6 +734,9 @@ private struct TabBarDragAndHoverView: NSViewRepresentable { } override func mouseDown(with event: NSEvent) { +#if DEBUG + dlog("tab.bar.bg.mouseDown isMinimal=\(isMinimalMode ? 1 : 0) clickCount=\(event.clickCount)") +#endif guard isMinimalMode, let window else { super.mouseDown(with: event) return @@ -760,6 +784,10 @@ private struct TabBarDragZoneView: NSViewRepresentable { } override func mouseDown(with event: NSEvent) { +#if DEBUG + let isMinimal = UserDefaults.standard.string(forKey: "workspacePresentationMode") == "minimal" + dlog("tab.bar.dragZone.mouseDown isMinimal=\(isMinimal ? 1 : 0) clickCount=\(event.clickCount)") +#endif guard let window = self.window else { super.mouseDown(with: event) return