Skip to content

Commit d65b4a8

Browse files
authored
Merge pull request #91 from manaflow-ai/fix-pane-tab-clicks-minimal-mode
Fix split-pane tab clicks in minimal mode
2 parents 098d9fa + 9c03ca9 commit d65b4a8

3 files changed

Lines changed: 100 additions & 32 deletions

File tree

Sources/Bonsplit/Internal/Views/SplitContainerView.swift

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,10 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
118118

119119
// Keep arranged subviews stable (always 2) to avoid transient "collapse" flashes when
120120
// replacing pane<->split content. We swap the hosted content within these containers.
121-
let firstContainer = NSView()
121+
// The containers use `SplitArrangedContainerView` (rather than bare NSView) so they
122+
// override `mouseDownCanMoveWindow=false` — see `NonDraggableHostingView` in
123+
// SplitNodeView.swift for the regression this guards against.
124+
let firstContainer = SplitArrangedContainerView()
122125
firstContainer.wantsLayer = true
123126
firstContainer.layer?.backgroundColor = NSColor.clear.cgColor
124127
firstContainer.layer?.isOpaque = false
@@ -128,7 +131,7 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
128131
splitView.addArrangedSubview(firstContainer)
129132
context.coordinator.firstHostingController = firstController
130133

131-
let secondContainer = NSView()
134+
let secondContainer = SplitArrangedContainerView()
132135
secondContainer.wantsLayer = true
133136
secondContainer.layer?.backgroundColor = NSColor.clear.cgColor
134137
secondContainer.layer?.isOpaque = false
@@ -356,8 +359,8 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
356359

357360
// MARK: - Helpers
358361

359-
private func makeHostingController(for node: SplitNode) -> NSHostingController<AnyView> {
360-
let hostingController = NSHostingController(rootView: AnyView(makeView(for: node)))
362+
private func makeHostingController(for node: SplitNode) -> NonDraggableHostingController<AnyView> {
363+
let hostingController = NonDraggableHostingController(rootView: AnyView(makeView(for: node)))
361364
if #available(macOS 13.0, *) {
362365
// NSSplitView owns pane geometry. Keep NSHostingController from publishing
363366
// intrinsic-size constraints that force a minimum pane width.
@@ -379,7 +382,7 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
379382
return hostingController
380383
}
381384

382-
private func installHostingController(_ hostingController: NSHostingController<AnyView>, into container: NSView) {
385+
private func installHostingController(_ hostingController: NonDraggableHostingController<AnyView>, into container: NSView) {
383386
let hostedView = hostingController.view
384387
hostedView.frame = container.bounds
385388
hostedView.autoresizingMask = [.width, .height]
@@ -392,7 +395,7 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
392395
in container: NSView,
393396
node: SplitNode,
394397
nodeTypeChanged: Bool,
395-
controller: inout NSHostingController<AnyView>?
398+
controller: inout NonDraggableHostingController<AnyView>?
396399
) {
397400
// Historically we recreated the NSHostingController when the child node type changed
398401
// (pane <-> split) to force a full detach/reattach of native AppKit subviews.
@@ -469,8 +472,8 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
469472
var firstNodeType: SplitNode.NodeType
470473
var secondNodeType: SplitNode.NodeType
471474
/// Retain hosting controllers so SwiftUI content stays alive
472-
var firstHostingController: NSHostingController<AnyView>?
473-
var secondHostingController: NSHostingController<AnyView>?
475+
var firstHostingController: NonDraggableHostingController<AnyView>?
476+
var secondHostingController: NonDraggableHostingController<AnyView>?
474477

475478
init(
476479
splitState: SplitState,

Sources/Bonsplit/Internal/Views/SplitNodeView.swift

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,46 @@ struct SplitNodeView<Content: View, EmptyContent: View>: View {
4444
}
4545
}
4646

47+
/// NSHostingView subclass that refuses to act as a window-drag handle.
48+
///
49+
/// Bonsplit nests SwiftUI panes inside their own `NSHostingController` instances
50+
/// (via `SinglePaneWrapper` and `SplitContainerView.makeHostingController`).
51+
/// The default `NSHostingView` returned by `NSHostingController.view` inherits the
52+
/// AppKit default of `mouseDownCanMoveWindow == true` when the view appears opaque.
53+
/// In `presentationMode == "minimal"` (where the window has no titlebar drag region)
54+
/// AppKit was treating clicks on pane tab bars as window-drag intents and stealing the
55+
/// mouseUp before the SwiftUI tap gesture could fire — making split pane tabs
56+
/// completely unclickable. Routing clicks through this subclass keeps the entire pane
57+
/// hosting chain non-draggable so SwiftUI gesture recognizers receive every click.
58+
final class NonDraggableHostingView<Content: View>: NSHostingView<Content> {
59+
override var mouseDownCanMoveWindow: Bool { false }
60+
}
61+
62+
/// NSHostingController whose view is a `NonDraggableHostingView`. See the comment on
63+
/// `NonDraggableHostingView` for the rationale — this exists so call sites can keep using
64+
/// the controller-based lifecycle (root-view swapping, sizing options, etc.) without
65+
/// having to construct and reparent the hosting view manually.
66+
final class NonDraggableHostingController<Content: View>: NSHostingController<Content> {
67+
override func loadView() {
68+
view = NonDraggableHostingView(rootView: rootView)
69+
}
70+
}
71+
4772
/// Container NSView for a pane inside SinglePaneWrapper.
4873
class PaneDragContainerView: NSView {
4974
override var isOpaque: Bool { false }
75+
// Mirror the override on `NonDraggableHostingView` so AppKit cannot grab a window
76+
// drag from this container either, even before the click reaches the inner hosting
77+
// view. See `NonDraggableHostingView` for the full rationale.
78+
override var mouseDownCanMoveWindow: Bool { false }
79+
}
80+
81+
/// Bare container used by `SplitContainerView` to back NSSplitView arranged subviews.
82+
/// Like `PaneDragContainerView`, this exists purely to suppress AppKit window-drag
83+
/// intent so split-pane tab clicks are not consumed by drag detection in minimal mode.
84+
final class SplitArrangedContainerView: NSView {
85+
override var isOpaque: Bool { false }
86+
override var mouseDownCanMoveWindow: Bool { false }
5087
}
5188

5289
/// Wrapper that uses NSHostingController for proper AppKit layout constraints
@@ -68,7 +105,7 @@ struct SinglePaneWrapper<Content: View, EmptyContent: View>: NSViewRepresentable
68105
showSplitButtons: showSplitButtons,
69106
contentViewLifecycle: contentViewLifecycle
70107
)
71-
let hostingController = NSHostingController(rootView: paneView)
108+
let hostingController = NonDraggableHostingController(rootView: paneView)
72109
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
73110

74111
let containerView = PaneDragContainerView()
@@ -116,6 +153,6 @@ struct SinglePaneWrapper<Content: View, EmptyContent: View>: NSViewRepresentable
116153
}
117154

118155
class Coordinator {
119-
var hostingController: NSHostingController<PaneContainerView<Content, EmptyContent>>?
156+
var hostingController: NonDraggableHostingController<PaneContainerView<Content, EmptyContent>>?
120157
}
121158
}

Sources/Bonsplit/Internal/Views/TabBarView.swift

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -183,18 +183,40 @@ struct TabBarView: View {
183183
}
184184
.frame(height: TabBarMetrics.barHeight)
185185
.mask(combinedMask)
186-
// Buttons float on top. No backdrop color needed because
187-
// the mask hides scroll content and the tab bar's own
188-
// background shows through naturally.
186+
// Split buttons sit on top of the tab strip in their own opaque backdrop.
187+
// The backdrop visually obscures any tabs that scroll under the buttons,
188+
// and (critically) does not break hit testing on tabs outside the backdrop —
189+
// unlike the prior approach of using a `Color.clear` region in `combinedMask`,
190+
// which silently blocked SwiftUI hit tests in the masked-out area and let
191+
// tab clicks fall through to `TabBarDragAndHoverView` (which performs a
192+
// window drag in minimal mode).
189193
.overlay(alignment: .trailing) {
190194
if showSplitButtons {
191195
let shouldShow = presentationMode != "minimal" || isHoveringTabBar
192-
splitButtons
193-
.saturation(tabBarSaturation)
194-
.padding(.bottom, 1)
195-
.opacity(shouldShow ? 1 : 0)
196-
.allowsHitTesting(shouldShow)
197-
.animation(.easeInOut(duration: 0.14), value: shouldShow)
196+
let backdropColor = Color(nsColor: Self.buttonBackdropColor(
197+
for: appearance,
198+
focused: isFocused,
199+
style: fadeColorStyle
200+
))
201+
ZStack(alignment: .trailing) {
202+
HStack(spacing: 0) {
203+
LinearGradient(
204+
colors: [backdropColor.opacity(0), backdropColor],
205+
startPoint: .leading,
206+
endPoint: .trailing
207+
)
208+
.frame(width: 24)
209+
Rectangle().fill(backdropColor)
210+
}
211+
.frame(width: 114)
212+
213+
splitButtons
214+
.saturation(tabBarSaturation)
215+
}
216+
.padding(.bottom, 1)
217+
.opacity(shouldShow ? 1 : 0)
218+
.allowsHitTesting(shouldShow)
219+
.animation(.easeInOut(duration: 0.14), value: shouldShow)
198220
}
199221
}
200222
}
@@ -571,32 +593,31 @@ struct TabBarView: View {
571593
}
572594

573595
// MARK: - Combined Mask (scroll fades + button area)
596+
//
597+
// IMPORTANT: SwiftUI's `.mask()` with `Color.clear` regions blocks hit testing on the
598+
// masked content in those regions. Previously this mask used a 90pt clear region at the
599+
// trailing edge to hide tabs under the split buttons; that caused clicks on tabs in that
600+
// 90pt area to fall through the masked ScrollView to the `TabBarDragAndHoverView`
601+
// background, which (in minimal mode) interpreted the click as a window drag instead
602+
// of a tab tap. Keep the entire mask opaque so hit testing works on every tab; the split
603+
// buttons' opaque backdrop (rendered in the splitButtons overlay) handles the visual
604+
// obscuring of tabs underneath.
574605

575606
@ViewBuilder
576607
private var combinedMask: some View {
577608
let fadeWidth: CGFloat = 24
578-
let shouldShowButtons = showSplitButtons && (presentationMode != "minimal" || isHoveringTabBar)
579-
let buttonClearWidth: CGFloat = shouldShowButtons ? 90 : 0
580-
let buttonFadeWidth: CGFloat = shouldShowButtons ? fadeWidth : 0
581-
582609
HStack(spacing: 0) {
583610
// Left scroll fade
584611
LinearGradient(colors: [.clear, .black], startPoint: .leading, endPoint: .trailing)
585612
.frame(width: canScrollLeft ? fadeWidth : 0)
586613

587-
// Visible content area
614+
// Visible content area (always opaque so hit testing reaches the tabs)
588615
Rectangle().fill(Color.black)
589616

590-
// Right: either scroll fade or button area fade
617+
// Right scroll fade only when scroll content actually overflows.
591618
LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing)
592-
.frame(width: canScrollRight || shouldShowButtons ? fadeWidth : 0)
593-
594-
// Button clear area (content hidden here)
595-
if shouldShowButtons {
596-
Color.clear.frame(width: buttonClearWidth)
597-
}
619+
.frame(width: canScrollRight ? fadeWidth : 0)
598620
}
599-
.animation(.easeInOut(duration: 0.14), value: shouldShowButtons)
600621
}
601622

602623
// MARK: - Fade Overlays
@@ -713,6 +734,9 @@ private struct TabBarDragAndHoverView: NSViewRepresentable {
713734
}
714735

715736
override func mouseDown(with event: NSEvent) {
737+
#if DEBUG
738+
dlog("tab.bar.bg.mouseDown isMinimal=\(isMinimalMode ? 1 : 0) clickCount=\(event.clickCount)")
739+
#endif
716740
guard isMinimalMode, let window else {
717741
super.mouseDown(with: event)
718742
return
@@ -760,6 +784,10 @@ private struct TabBarDragZoneView: NSViewRepresentable {
760784
}
761785

762786
override func mouseDown(with event: NSEvent) {
787+
#if DEBUG
788+
let isMinimal = UserDefaults.standard.string(forKey: "workspacePresentationMode") == "minimal"
789+
dlog("tab.bar.dragZone.mouseDown isMinimal=\(isMinimal ? 1 : 0) clickCount=\(event.clickCount)")
790+
#endif
763791
guard let window = self.window else {
764792
super.mouseDown(with: event)
765793
return

0 commit comments

Comments
 (0)