Skip to content

Divider drags are sessions; imposed splits stop fighting foreign resizes - #178

Closed
ejc3 wants to merge 2 commits into
manaflow-ai:mainfrom
ejc3:divider-drag-sessions
Closed

ejc3 wants to merge 2 commits into
manaflow-ai:mainfrom
ejc3:divider-drag-sessions

Conversation

@ejc3

@ejc3 ejc3 commented Jul 13, 2026 •

Copy link
Copy Markdown

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.

Written for commit 0ef263b. Summary will update on new commits.

Review in cubic

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.

Drag start and end come from the divider's mouse-tracking lifecycle
(mouseDown brackets AppKit's tracking loop), not from which event happens
to be current inside a resize callback — the old inference missed any
drag that paused before release. BonsplitController.isDividerDragActive
and the dragDidBegin/End delegate calls expose the session to hosts.

An imposed split re-applies its extent only on a fresh imposition, or
when the divider moved while the split view's size did not — putting a
drifted divider back where nothing else will. Re-asserting against a
resized container fought AppKit from inside its own layout pass,
recursively; a main-thread sample showed the whole thread in that fight.
When a refused parent apply lands on a later retry, nested imposed
splits re-apply against their settled containers.
@coderabbitai

coderabbitai Bot commented Jul 13, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Divider drag lifecycle and layout convergence

Layer / File(s) Summary
Divider drag lifecycle contract
Sources/Bonsplit/Public/BonsplitDelegate.swift, Sources/Bonsplit/Public/BonsplitController.swift, Sources/Bonsplit/Internal/Controllers/SplitViewController.swift
Adds divider-drag begin/end delegate callbacks, active-session tracking, public state access, and external session notification.
AppKit divider drag integration
Sources/Bonsplit/Internal/Views/SplitContainerView.swift
Detects divider drag sessions around AppKit tracking and forwards lifecycle changes through the coordinator and internal controller.
Imposed extent convergence
Sources/Bonsplit/Internal/Views/SplitContainerView.swift
Replaces imposed-target memoization with outcome and availability tracking, renudges nested imposed descendants after retries, and avoids synchronous reassertion during imposed drag completion.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ThemedSplitView
  participant Coordinator
  participant SplitViewController
  participant BonsplitController
  participant BonsplitDelegate
  User->>ThemedSplitView: Start divider drag
  ThemedSplitView->>Coordinator: dividerDragSessionChanged(true)
  ThemedSplitView->>SplitViewController: noteDividerDragSession(true)
  SplitViewController->>BonsplitController: onDividerDragSessionChange(true)
  BonsplitController->>BonsplitDelegate: splitTabBarDividerDragDidBegin(controller)
  ThemedSplitView->>ThemedSplitView: Track divider movement
  ThemedSplitView->>Coordinator: dividerDragSessionChanged(false)
  ThemedSplitView->>SplitViewController: noteDividerDragSession(false)
  SplitViewController->>BonsplitController: onDividerDragSessionChange(false)
  BonsplitController->>BonsplitDelegate: splitTabBarDividerDragDidEnd(controller)
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the two main changes: session-based divider dragging and reduced conflicts between imposed splits and external resizes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@greptile-apps

greptile-apps Bot commented Jul 13, 2026

Copy link
Copy Markdown

Greptile Summary

This PR makes divider drags explicit sessions and changes how imposed pane sizes settle after layout changes. The main changes are:

  • Mouse-lifecycle begin and end notifications for divider drags.
  • Public drag-session state and delegate callbacks.
  • Container-size-aware imposed-extent reconciliation.
  • Nested imposed-split reapplication after delayed parent retries.

Confidence Score: 4/5

Overlapping sessions, expanded AppKit hit regions, and container resizes can break the new drag and imposed-size contracts.

  • An end callback can fire while another session remains active.
  • AppKit can track a divider outside the manually reconstructed session region.
  • A stored imposed extent can remain stale unless the host calls the setter again after resizing.

Sources/Bonsplit/Internal/Controllers/SplitViewController.swift and Sources/Bonsplit/Internal/Views/SplitContainerView.swift

Important Files Changed

Filename Overview
Sources/Bonsplit/Internal/Controllers/SplitViewController.swift Adds aggregate drag-session state, but raw end events can be forwarded before aggregate activity ends.
Sources/Bonsplit/Internal/Views/SplitContainerView.swift Adds mouse-based drag tracking and revises imposed sizing, with gaps in effective hit testing and persistent extent enforcement.
Sources/Bonsplit/Public/BonsplitController.swift Exposes drag activity and custom session bracketing through the public controller.
Sources/Bonsplit/Public/BonsplitDelegate.swift Adds backward-compatible divider drag begin and end callbacks.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant Split as ThemedSplitView
    participant State as SplitViewController
    participant Host

    User->>Split: mouseDown on divider
    Split->>State: begin session
    State->>Host: drag did begin
    Split->>Split: AppKit tracking loop
    User->>Split: mouseUp
    Split->>State: end session
    State->>Host: drag did end
    Split->>Host: final geometry change
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User
    participant Split as ThemedSplitView
    participant State as SplitViewController
    participant Host

    User->>Split: mouseDown on divider
    Split->>State: begin session
    State->>Host: drag did begin
    Split->>Split: AppKit tracking loop
    User->>Split: mouseUp
    Split->>State: end session
    State->>Host: drag did end
    Split->>Host: final geometry change
Loading

Reviews (1): Last reviewed commit: "Divider drags are sessions; imposed spli..." | Re-trigger Greptile

Comment thread Sources/Bonsplit/Internal/Controllers/SplitViewController.swift
Comment thread Sources/Bonsplit/Internal/Views/SplitContainerView.swift Outdated
Comment thread Sources/Bonsplit/Internal/Views/SplitContainerView.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/Bonsplit/Internal/Views/SplitContainerView.swift (1)

1006-1039: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Nested imposed descendants aren't renudged when the ancestor converges on the first try.

renudgeImposedDescendants is only invoked from retryImposedIfStillShort's own eventual convergence (Lines 877-887). This syncPosition branch calls setPositionSafely(target, in: splitView, layout: true) too, and when it converges immediately (imposedRetryBudget = 0, no retry scheduled), any descendant split with its own imposed extent — proportionally resized by this same layout pass — never gets nudged back. Per splitViewDidResizeSubviews (Lines 1289-1292), that descendant explicitly skips re-syncing on its own resize notification because it has an imposed extent, so nothing else corrects it: same failure mode the retry-path renudge fixes, just reached via the more common "first attempt succeeds" path instead of the retry path.

🐛 Proposed fix: renudge descendants on immediate convergence too
                 } else if renudged || (moved && availUnchanged) {
                     setPositionSafely(target, in: splitView, layout: true)
                     lastImposedEpoch = splitState.imposedEpoch
                     lastImposedAvail = available
                     lastImposedOutcome = splitState.orientation == .horizontal
                         ? splitView.arrangedSubviews[0].frame.width
                         : splitView.arrangedSubviews[0].frame.height
                     ...
                     if abs((lastImposedOutcome ?? target) - target) > 0.01 {
                         imposedRetryBudget = 5
                         imposedRetrySplitView = splitView
                         scheduleImposedRetry()
                     } else {
                         imposedRetryBudget = 0
+                        renudgeImposedDescendants(of: splitState)
                     }
                 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Bonsplit/Internal/Views/SplitContainerView.swift` around lines 1006 -
1039, Update the immediate-convergence branch in syncPosition, after
setPositionSafely confirms the imposed position and imposedRetryBudget is set to
zero, to invoke renudgeImposedDescendants for the affected split view. Preserve
the existing retry scheduling behavior for non-converged outcomes and ensure
descendants are renudged only when convergence occurs without a deferred retry.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/Bonsplit/Internal/Controllers/SplitViewController.swift`:
- Around line 65-76: Update noteDividerDragSession(_:) so
onDividerDragSessionChange is invoked only when activeDividerDragSessions
crosses between zero and positive: notify true on 0→1 and false on 1→0, while
preserving the clamped counter behavior for overlapping or unmatched sessions.

---

Outside diff comments:
In `@Sources/Bonsplit/Internal/Views/SplitContainerView.swift`:
- Around line 1006-1039: Update the immediate-convergence branch in
syncPosition, after setPositionSafely confirms the imposed position and
imposedRetryBudget is set to zero, to invoke renudgeImposedDescendants for the
affected split view. Preserve the existing retry scheduling behavior for
non-converged outcomes and ensure descendants are renudged only when convergence
occurs without a deferred retry.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e7543008-6cae-4614-aaa1-31da76d9f86f

📥 Commits

Reviewing files that changed from the base of the PR and between 5e09ac0 and cd370be.

📒 Files selected for processing (4)
  • Sources/Bonsplit/Internal/Controllers/SplitViewController.swift
  • Sources/Bonsplit/Internal/Views/SplitContainerView.swift
  • Sources/Bonsplit/Public/BonsplitController.swift
  • Sources/Bonsplit/Public/BonsplitDelegate.swift

Comment thread Sources/Bonsplit/Internal/Controllers/SplitViewController.swift
…any divider mouseDown

With overlapping sessions (a host-bracketed custom drag alongside the
built-in tracking), ending one must not announce the drag over while the
other still owns the divider. And the session hit test cannot faithfully
reconstruct the effective divider rect AppKit tracks (our expansion
unioned with AppKit's own proposal), so any mouseDown that reaches the
split view — content clicks never do — brackets a session; a session
around a non-drag click is a harmless no-op pair. setImposedFirstExtent
now documents that a stored extent is not re-asserted across container
resizes: the host imposes fresh values for new sizes.
@ejc3

ejc3 commented Jul 13, 2026

Copy link
Copy Markdown
Author

Superseded by #179 — the same change rebased onto current main after the imposed-extent follow-up work landed there (this branch now conflicts). All review feedback from this PR is carried over. Closing in favor of #179.

@ejc3 ejc3 closed this Jul 13, 2026
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