Divider drags are sessions; imposed splits stop fighting foreign resizes

Closed
EJ
3mo ago
+199-25

When a host imposes exact pane sizes (the setImposedFirstExtent API from #176) while users can also drag dividers, two pieces of divider behavior turned out to be built on guesses, and both guesses failed in a host under real use.

Drag detection guessed from event coincidence. The delegate decided "the user is dragging" by checking which event happened to be current inside a resize callback, and detected the release only when a resize callback happened to arrive while the mouseUp was still the current event. Drag a divider, hold it still, release: no resize coincides with the mouseUp, no drag end is ever reported, and a host that syncs the final position elsewhere (cmux sends it to a remote tmux) never hears about the drag. The reverse guess was worse: code treated "this split has an imposed extent" as proof it was not being dragged, so a host that re-imposed on its own schedule mid-drag silently discarded the user's gesture.

Dragging is now a session with a hard start and end taken from the mouse lifecycle itself: mouseDown on the divider's hit rect begins it, and the end fires when AppKit's own tracking loop returns at release — it cannot miss a drag-pause-release. BonsplitController.isDividerDragActive exposes the session so hosts can hold their own sizing while the user's hand is on the divider, splitTabBarDividerDragDidBegin/End notify the delegate, and noteDividerDragSession lets hosts with custom drag surfaces — and tests — bracket a session themselves.

Imposed splits fought every resize they didn't initiate. When frames moved under an imposed split — a window resize, a parent divider landing — the resize callback re-asserted the stored extent synchronously, inside the very layout pass that had moved the frames. Each re-assert ran another layout pass, which moved more frames, recursively across every mounted split; a main-thread sample during the resulting beach-ball showed 100% of the time in that fight. The update path did the same whenever the divider sat off its last recorded outcome.

The rule now: an imposed split re-applies its extent only when the host imposes again (each call bumps an epoch), or when the divider moved while the split view itself did NOT change size — that is drift with nobody else coming to correct it, and one apply settles it, since applying cannot resize the split view. When the split view's size DID change, the stored extent is out of date and the host will impose a new one computed for the new size; re-applying the old one meanwhile just moves the divider back and forth against AppKit. One addition to #176's budgeted retry: when a refused parent apply finally lands a turn later, it resizes everything nested inside after those splits already applied their extents — the retry now asks each nested imposed split to apply again against its settled container.

The drag session also arms for a divider parked against a pane at its minimum size (the old hit test skipped panes under a point wide — exactly the divider a user grabs to restore a collapsed pane), and DEBUG logging follows the new shape: session begin/end, and each imposed apply with its target, outcome, and the container size it applied in.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Make divider drags explicit sessions bracketed by mouseDown/mouseUp, and stop imposed splits from re-applying during foreign resizes. Fixes missed drag-end notifications and removes recursive layout churn during window/parent resizes.

  • New Features

    • Deterministic drag sessions from the divider’s mouse lifecycle with zero-crossing notifications that stack correctly with host-bracketed sessions.
    • Session begins on any mouseDown that reaches the split view, ensuring all AppKit-tracked drags are bracketed.
    • Public API: BonsplitController.isDividerDragActive, noteDividerDragSession(_:), and delegate hooks splitTabBarDividerDragDidBegin/End.
    • Nested imposed splits re-apply after a parent’s delayed retry settles.
  • Bug Fixes

    • Imposed extents re-apply only on a fresh impose or when the divider drifted without the split view changing size; never during container resizes, avoiding recursive layout loops.
    • Drag-end is always delivered, including pause-then-release cases with no final resize callback; setImposedFirstExtent now documents that stored extents are not re-asserted across container resizes.

Summary by CodeRabbit

  • New Features

    • Added divider-drag lifecycle notifications for drag start and completion.
    • Exposed whether divider dragging is currently active.
    • Added support for tracking divider-drag sessions initiated by custom interfaces.
    • Added delegate callbacks for responding to divider drag begin and end events.
  • Bug Fixes

    • Improved layout stability during constrained divider movement.
    • Prevented recursive layout updates and improved nested split convergence after divider adjustments.