Skip to content

Fix split-pane tab clicks in minimal mode#91

Merged
austinywang merged 1 commit intomainfrom
fix-pane-tab-clicks-minimal-mode
Apr 7, 2026
Merged

Fix split-pane tab clicks in minimal mode#91
austinywang merged 1 commit intomainfrom
fix-pane-tab-clicks-minimal-mode

Conversation

@austinywang
Copy link
Copy Markdown

@austinywang austinywang commented Apr 7, 2026

Summary

  • Add NonDraggableHostingView/NonDraggableHostingController helpers and route Bonsplit's nested pane hosting controllers through them so AppKit cannot grab a window-drag from the inner NSHostingView chain.
  • Override mouseDownCanMoveWindow=false on PaneDragContainerView and a new SplitArrangedContainerView (used to back NSSplitView arranged subviews) so the entire pane container chain is non-draggable.
  • Stop using a Color.clear region in TabBarView.combinedMask (which silently blocks SwiftUI hit testing on tabs in the masked area) and switch to a fully opaque mask plus an opaque backdrop on the split-button overlay.
  • Add DEBUG dlog instrumentation in TabBarBackgroundNSView/TabBarDragZoneView mouseDown handlers.

Why

In minimal mode the host window has no titlebar drag region. AppKit then relies on mouseDownCanMoveWindow walking up the hit chain to decide whether a click is a window-drag intent. The default NSHostingView<AnyView> returned by NSHostingController.view reports true whenever its SwiftUI content appears opaque, so AppKit was treating clicks on Bonsplit pane tab bars as window drags and stealing the mouseUp before the SwiftUI tap gesture could fire — making split-pane tabs completely unclickable while terminal surfaces (which use a separate portal hosting path) kept working.

The cmux app already overrides mouseDownCanMoveWindow=false on its top-level MainWindowHostingView, but Bonsplit's split layout creates its own nested NSHostingController instances inside SinglePaneWrapper and SplitContainerView.makeHostingController. Those inner hosting views were never covered.

Test plan

  • Build cmux Debug for Apple Silicon, enable minimal mode, split a pane, click left and right pane tabs — both should select.
  • Same on Intel Mac via universal Debug binary.
  • Verify clicks on the split-button area still work (visually obscured by opaque backdrop, hit testing reaches the buttons).
  • Verify the title-bar-drag area for the leading pane (TabBarDragZoneView for the first pane in minimal mode) still allows window dragging.

Summary by cubic

Fixes unclickable split-pane tabs in minimal mode by making nested SwiftUI hosting views non‑draggable and fixing tab bar masking so clicks reach tabs. Prevents AppKit from treating tab clicks as window drags; tabs and split buttons now work as expected.

  • Bug Fixes
    • Added NonDraggableHostingView and NonDraggableHostingController and used them for all pane hosts to force mouseDownCanMoveWindow = false.
    • Set mouseDownCanMoveWindow = false on PaneDragContainerView and new SplitArrangedContainerView so the full pane container chain is non‑draggable.
    • Replaced Color.clear in TabBarView.combinedMask with a fully opaque mask and added an opaque split-button backdrop to preserve tab hit testing while hiding scrolled tabs.
    • Added DEBUG dlog to tab bar background/drag zone mouseDown handlers for easier diagnostics.

Written for commit 9c03ca9. Summary will update on new commits.

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Fixed unwanted window movement when interacting with split container areas.
    • Improved mouse event handling and hit-testing behavior in split panes.
    • Corrected interaction detection for tab bar split buttons.
  • UI/UX Improvements

    • Enhanced tab bar split button visual presentation with refined backdrop rendering.
    • Improved visual consistency in split pane control interactions.

In minimal mode the window has no titlebar drag region, so AppKit relies
on `mouseDownCanMoveWindow` on each NSView in the hit chain to decide
whether a click is a window-drag intent. The default `NSHostingView`
returned by `NSHostingController.view` reports `true` whenever its
SwiftUI content appears opaque, so AppKit was treating clicks on
Bonsplit pane tab bars as window-drag attempts and stealing the mouseUp
before the SwiftUI tap gesture could fire — making split-pane tabs
completely unclickable while terminal surfaces (which use a separate
portal hosting path) kept working.

The cmux app already overrides `mouseDownCanMoveWindow=false` on its
top-level `MainWindowHostingView`, but Bonsplit's split layout creates
its own nested `NSHostingController` instances inside `SinglePaneWrapper`
and `SplitContainerView.makeHostingController`. Those inner hosting
views were never covered.

Fix:
* Add `NonDraggableHostingView<Content>: NSHostingView<Content>` and
  `NonDraggableHostingController<Content>: NSHostingController<Content>`
  helpers in SplitNodeView.swift, both forcing
  `mouseDownCanMoveWindow=false`.
* Use `NonDraggableHostingController` everywhere Bonsplit currently
  constructs an `NSHostingController` for pane content.
* Override `mouseDownCanMoveWindow=false` on `PaneDragContainerView`
  and a new `SplitArrangedContainerView` (used to back the
  `NSSplitView` arranged subviews in `SplitContainerView`) so the
  entire pane container chain is non-draggable.

Also fix a related minor hit-testing bug in `TabBarView.combinedMask`:
the previous mask used a `Color.clear` region behind the split-button
overlay, which silently blocks SwiftUI hit testing on tabs in that
trailing area. Switch to a fully opaque mask plus an opaque backdrop
on the buttons overlay so hit testing reaches every tab while the
split-button area remains visually obscured.

Adds DEBUG `dlog` instrumentation to TabBarBackgroundNSView and
TabBarDragZoneView mouseDown handlers so future regressions can be
diagnosed via `/tmp/cmux-debug*.log`.
@austinywang austinywang merged commit d65b4a8 into main Apr 7, 2026
1 check passed
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 7, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dec28f67-6fc5-4107-968d-74f9ebaaa857

📥 Commits

Reviewing files that changed from the base of the PR and between 098d9fa and 9c03ca9.

📒 Files selected for processing (3)
  • Sources/Bonsplit/Internal/Views/SplitContainerView.swift
  • Sources/Bonsplit/Internal/Views/SplitNodeView.swift
  • Sources/Bonsplit/Internal/Views/TabBarView.swift

📝 Walkthrough

Walkthrough

This PR introduces specialized, non-draggable hosting views to prevent unintended window-moving in split panes, and refactors tab-bar split-button hit-testing by replacing a transparent-area-based approach with an opaque trailing backdrop.

Changes

Cohort / File(s) Summary
Non-draggable hosting infrastructure
Sources/Bonsplit/Internal/Views/SplitNodeView.swift
Added NonDraggableHostingView and NonDraggableHostingController classes to suppress window-drag intent. Updated SinglePaneWrapper and PaneDragContainerView to use non-draggable types. Introduced SplitArrangedContainerView for split-pane arranged subviews.
Split container updates
Sources/Bonsplit/Internal/Views/SplitContainerView.swift
Replaced generic NSView() and NSHostingController instances with SplitArrangedContainerView and NonDraggableHostingController for consistent non-draggable behavior across arranged containers.
Tab-bar hit-testing refactor
Sources/Bonsplit/Internal/Views/TabBarView.swift
Removed transparent hit-test-blocking color area from combinedMask and replaced overlay hit-testing logic with opaque trailing backdrop (ZStack with Rectangle and LinearGradient). Updated fade-width dependency to canScrollRight only. Added DEBUG logging for mouse events.

Possibly related PRs

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 Mouse events now glide without a care,
Windows stay put—dragging goes nowhere,
Non-draggable hosts hold panes in place,
Tab buttons shimmer with opaque grace,
Hit-testing blooms where backdrops rise!

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-pane-tab-clicks-minimal-mode

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 7, 2026

Greptile Summary

This PR fixes split-pane tab clicks being silently consumed as window-drag events in minimal mode. The fix has two complementary parts: (1) mouseDownCanMoveWindow is overridden to false at every layer of the pane hosting chain (NonDraggableHostingView, NonDraggableHostingController, SplitArrangedContainerView, PaneDraggableContainerView), and (2) the combinedMask in TabBarView is made fully opaque so SwiftUI hit testing always reaches tab views — with a new opaque backdrop overlay handling the visual obscuring of tabs beneath the split buttons.

Confidence Score: 5/5

PR is safe to merge; all remaining findings are P2 style suggestions.

The root cause is correctly identified and addressed with a defense-in-depth approach — mouseDownCanMoveWindow=false at every layer of the view hierarchy, plus the mask hit-testing fix. All three remaining comments are non-blocking P2 style items (dead code, a magic number, and an undocumented pattern assumption).

No files require special attention; the P2 items in TabBarView.swift and SplitNodeView.swift are minor cleanup suggestions.

Important Files Changed

Filename Overview
Sources/Bonsplit/Internal/Views/SplitNodeView.swift Adds NonDraggableHostingView/Controller and container view overrides to suppress mouseDownCanMoveWindow throughout the pane hosting chain; well-documented and correct.
Sources/Bonsplit/Internal/Views/SplitContainerView.swift Wires in NonDraggableHostingController and SplitArrangedContainerView across all hosting controller and container references; mechanical change with no logical issues.
Sources/Bonsplit/Internal/Views/TabBarView.swift Fixes combinedMask to be fully opaque so hit testing always reaches tabs; replaces Color.clear region with an opaque backdrop overlay for split buttons; fadeOverlays is now dead code (P2).

Sequence Diagram

sequenceDiagram
    participant User
    participant AppKit
    participant Container as NSView / SplitArrangedContainerView
    participant HostingView as NSHostingView / NonDraggableHostingView
    participant SwiftUI
    participant DragZone as TabBarDragZoneView

    Note over User,DragZone: BEFORE — tab click stolen as window drag
    User->>AppKit: mouseDown on pane tab bar
    AppKit->>Container: mouseDownCanMoveWindow?
    Container-->>AppKit: true (bare NSView default)
    AppKit->>HostingView: mouseDownCanMoveWindow?
    HostingView-->>AppKit: true (opaque SwiftUI content)
    AppKit-->>User: window drag — mouseUp consumed
    Note over SwiftUI: SwiftUI tap gesture never fires

    Note over User,DragZone: AFTER — tap reaches SwiftUI
    User->>AppKit: mouseDown on pane tab bar
    AppKit->>Container: mouseDownCanMoveWindow?
    Container-->>AppKit: false (SplitArrangedContainerView override)
    AppKit->>HostingView: mouseDownCanMoveWindow?
    HostingView-->>AppKit: false (NonDraggableHostingView override)
    AppKit->>SwiftUI: deliver mouseDown
    SwiftUI-->>User: tab selected ✓
    Note over DragZone: Only receives events on truly empty space
Loading

Comments Outside Diff (1)

  1. Sources/Bonsplit/Internal/Views/TabBarView.swift, line 629-641 (link)

    P2 fadeOverlays is dead code

    This computed property is defined but never called anywhere in the codebase. After this PR's changes it is also now byte-for-byte identical to combinedMask (both produce a left/right scroll fade with a fully opaque center rectangle). It can be safely removed.

Reviews (1): Last reviewed commit: "Fix split-pane tab clicks in minimal mod..." | Re-trigger Greptile

Comment on lines +202 to +211
HStack(spacing: 0) {
LinearGradient(
colors: [backdropColor.opacity(0), backdropColor],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: 24)
Rectangle().fill(backdropColor)
}
.frame(width: 114)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hardcoded 114pt backdrop width

The .frame(width: 114) is a magic number derived from the old buttonClearWidth (90 pt) + fadeWidth (24 pt). If a new split button is added or the button layout changes, the backdrop will under-cover the button area without an obvious failure signal. A named constant or inline comment would make the coupling explicit.

Suggested change
HStack(spacing: 0) {
LinearGradient(
colors: [backdropColor.opacity(0), backdropColor],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: 24)
Rectangle().fill(backdropColor)
}
.frame(width: 114)
.frame(width: 90 + 24) // buttonClearWidth (90) + fadeWidth (24)

Comment on lines +66 to +70
final class NonDraggableHostingController<Content: View>: NSHostingController<Content> {
override func loadView() {
view = NonDraggableHostingView(rootView: rootView)
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 loadView() relies on undocumented NSHostingController internals

Overriding loadView() to substitute NonDraggableHostingView without calling super.loadView() works because NSHostingController forwards rootView writes to self.view cast as NSHostingView<Content> at runtime — but this is not a documented contract. This is a well-known and widely-used pattern in the AppKit/SwiftUI community, so it's unlikely to break, but a brief comment acknowledging the assumption (as done for the other overrides in this file) would help future maintainers.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant