Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Sources/Bonsplit/Internal/Controllers/SplitViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,26 @@ final class SplitViewController {
/// Callback for geometry changes
var onGeometryChange: (() -> Void)?

/// Live divider drag sessions across every split in this tree (0 or 1 in
/// practice — AppKit tracks one divider at a time). Sessions bracket the
/// divider's mouse-tracking lifecycle, so external sizing can consult
/// this before writing geometry: mid-drag the user owns the divider.
@ObservationIgnored private(set) var activeDividerDragSessions = 0
@ObservationIgnored var onDividerDragSessionChange: ((Bool) -> Void)?

func noteDividerDragSession(_ active: Bool) {
// Notify only when the count crosses zero: with overlapping sessions
// (a host-bracketed custom drag alongside the built-in tracking),
// ending one must not announce "drag over" while the other still
// owns the divider — a host would resume imposing under the pointer.
let wasActive = activeDividerDragSessions > 0
activeDividerDragSessions = max(0, activeDividerDragSessions + (active ? 1 : -1))
let isActive = activeDividerDragSessions > 0
if wasActive != isActive {
onDividerDragSessionChange?(isActive)
}
}
Comment thread
ejc3 marked this conversation as resolved.
Resolved

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
init(rootNode: SplitNode? = nil) {
if let rootNode {
self.rootNode = rootNode
Expand Down
158 changes: 133 additions & 25 deletions Sources/Bonsplit/Internal/Views/SplitContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,40 @@ private class ThemedSplitView: NSSplitView {
// See `NonDraggableHostingView` in SplitNodeView.swift for the rest of
// the chain.
override var mouseDownCanMoveWindow: Bool { false }

/// Brackets a divider drag as a session: fires `true` when a mouseDown
/// lands on the divider's effective hit rect, `false` when AppKit's
/// divider tracking loop returns at mouseUp. Deterministic — taken from
/// the mouse lifecycle itself, never inferred from which event happens
/// to be current when a resize callback fires (that inference misses a
/// drag-pause-release, where no resize coincides with the mouseUp).
var onDividerDragSession: ((Bool) -> Void)?

override func mouseDown(with event: NSEvent) {
// Any mouseDown that reaches the split view itself is a divider
// interaction: arranged subviews cover all pane content, so content
// clicks never route here, and reconstructing AppKit's exact
// effective divider rect (drawn rect grown by the delegate's
// expansion, UNIONED with AppKit's own proposal) cannot be done
// faithfully from here — a rect test narrower than AppKit's would
// let AppKit track a drag with no session, and sizing would impose
// under the pointer. A session around a click that AppKit does not
// turn into a drag is harmless: begin and end fire back to back and
// the host's drag-end sync finds nothing changed.
let inDivider = arrangedSubviews.count >= 2
#if DEBUG
if inDivider {
let location = convert(event.locationInWindow, from: nil)
dlog("divider.session.mouseDown loc=\(Int(location.x)),\(Int(location.y))")
}
#endif
if inDivider { onDividerDragSession?(true) }
// For a divider hit, super runs AppKit's tracking loop and returns
// only after the mouse is released — the session end below is the
// guaranteed drag-end signal.
defer { if inDivider { onDividerDragSession?(false) } }
super.mouseDown(with: event)
}
}

#if DEBUG
Expand Down Expand Up @@ -213,6 +247,21 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
context.coordinator.secondHostingController = secondController

context.coordinator.splitView = splitView
let internalController = controller
splitView.onDividerDragSession = { [weak coordinator = context.coordinator, weak internalController] active in
// Order matters at session END: the counter must drop to zero
// BEFORE the coordinator's final geometry notification, or the
// delegate still reads the drag as live and skips the drag-end
// sync. At BEGIN the coordinator arms first so isDragging is set
// before any delegate reacts to the session.
if active {
coordinator?.dividerDragSessionChanged(true)
internalController?.noteDividerDragSession(true)
} else {
internalController?.noteDividerDragSession(false)
coordinator?.dividerDragSessionChanged(false)
}
}

// Capture animation origin before it gets cleared
let animationOrigin = splitState.animationOrigin
Expand Down Expand Up @@ -604,12 +653,12 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
var onGeometryChange: ((_ isDragging: Bool) -> Void)?
/// Track last applied position to detect external changes
var lastAppliedPosition: CGFloat = 0.5
/// The imposed-extent memo: the last target we asked AppKit for and
/// the position it actually produced. Re-apply only when the target
/// changes or the divider moved away from that outcome — never in a
/// loop against a target AppKit's constraints refuse to reach.
var lastImposedTarget: CGFloat?
/// What the last imposed apply actually produced, and how big the
/// split view was at the time. syncPosition compares against both to
/// decide whether a moved divider should be put back or left for the
/// host to re-impose at the new size.
var lastImposedOutcome: CGFloat?
var lastImposedAvail: CGFloat?
var lastImposedEpoch: Int?
var imposedRetryBudget = 0
var imposedApplyPending = false
Expand Down Expand Up @@ -661,8 +710,8 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
splitStateId = newState.id
splitState = newState
lastAppliedPosition = newState.dividerPosition
lastImposedTarget = nil
lastImposedOutcome = nil
lastImposedAvail = nil
lastImposedEpoch = nil
imposedRetryBudget = 0
didApplyInitialDividerPosition = false
Expand All @@ -678,6 +727,25 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
splitState = newState
}

/// Deterministic drag session, bracketed by `ThemedSplitView.mouseDown`
/// around AppKit's divider tracking loop. Begin arms the drag before
/// any resize callback needs to infer it; end always fires at release
/// and delivers the drag-end geometry notification — whether or not a
/// final resize callback coincided with the mouseUp. The model itself
/// is maintained by the resize callbacks during the drag; end only has
/// to announce that the user's hand is off the divider.
func dividerDragSessionChanged(_ active: Bool) {
#if DEBUG
dlog("divider.session split=\(String(splitState.id.uuidString.prefix(5))) \(active ? "begin" : "end")")
#endif
if active {
isDragging = true
return
}
isDragging = false
onGeometryChange?(false)
}

private func splitTotalSize(in splitView: NSSplitView) -> CGFloat {
splitState.orientation == .horizontal
? splitView.bounds.width
Expand Down Expand Up @@ -791,6 +859,17 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
lastImposedOutcome = splitState.orientation == .horizontal
? splitView.arrangedSubviews[0].frame.width
: splitView.arrangedSubviews[0].frame.height
// When this retry finally lands, it resizes everything nested
// inside — AFTER those nested splits already applied their own
// extents and recorded the result. AppKit resizes them
// proportionally, so their dividers end up off the extents they
// were given, and nothing else would ever correct that. Ask each
// nested imposed split to apply its extent again now that its
// container has settled. Runs at most once per retry, and
// retries are budgeted.
if abs((lastImposedOutcome ?? target) - target) <= 0.01 {
renudgeImposedDescendants(of: splitState)
}
#if DEBUG
dlog(
"bonsplit.impose.retry split=\(String(splitState.id.uuidString.prefix(5)))"
Expand All @@ -803,6 +882,17 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
}
}

private func renudgeImposedDescendants(of state: SplitState) {
for child in [state.first, state.second] {
guard case .split(let childState) = child else { continue }
if childState.imposedFirstExtent != nil {
childState.imposedEpoch &+= 1
childState.applyImposedNow?()
}
renudgeImposedDescendants(of: childState)
}
}

func setPositionSafely(_ position: CGFloat, in splitView: NSSplitView, layout: Bool = true) {
#if DEBUG
// Wobble hunt: name every divider write while an imposition is
Expand Down Expand Up @@ -874,39 +964,47 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
// `current != target` then re-layouts on every pass — a
// main-thread spin. Re-apply only when the target changed or
// something ELSE moved the divider off our last outcome.
let moved = abs(current - (lastImposedOutcome ?? .infinity)) > 0.01
let retargeted = abs(target - (lastImposedTarget ?? .infinity)) > 0.01
// A fresh imposition call re-arms one apply attempt even for
// an identical target: AppKit may have refused this exact
// target earlier (transient pane minimums mid-churn), and
// with neither target nor divider moving since, nothing else
// would ever retry — panes would sit wedged at the refused
// layout. Bounded by explicit calls, so it cannot spin.
let renudged = lastImposedEpoch != splitState.imposedEpoch
// Never apply when the divider already sits at the target:
// a same-position setPosition still runs a layout pass,
// which re-applies surface sizes, which re-imposes — a
// sustained once-per-turn churn loop at full CPU. Renudge
// and retarget only bypass the refusal memo; distance from
// the target is what justifies touching AppKit.
// A new imposition always applies. Beyond that, when the
// divider is not where we left it, what to do depends on
// whether the split view itself was resized since we last
// applied. If it was, our stored extent is out of date and
// the host will send a new one computed for the new size —
// re-applying the old one here just moves the divider back
// and forth against AppKit on every update until it does.
// If the split view is the SAME size, no new extent is
// coming (the host only recomputes when something it can see
// changed), so a nudged divider would stay wrong forever —
// put it back; one apply settles it, since applying cannot
// resize the split view. And never apply when the divider is
// already at the target: a same-position setPosition still
// runs a layout pass, which re-applies surface sizes, which
// re-imposes — a once-per-turn churn loop at full CPU.
let moved = abs(current - (lastImposedOutcome ?? .infinity)) > 0.01
let availUnchanged = abs(available - (lastImposedAvail ?? -1)) <= 0.01
if abs(current - target) <= 0.01 {
lastImposedTarget = target
lastImposedEpoch = splitState.imposedEpoch
lastImposedOutcome = current
lastImposedAvail = available
imposedRetryBudget = 0
} else if renudged || retargeted || moved {
} else if renudged || (moved && availUnchanged) {
setPositionSafely(target, in: splitView, layout: true)
lastImposedTarget = target
lastImposedEpoch = splitState.imposedEpoch
lastImposedAvail = available
lastImposedOutcome = splitState.orientation == .horizontal
? splitView.arrangedSubviews[0].frame.width
: splitView.arrangedSubviews[0].frame.height
#if DEBUG
dlog(
"bonsplit.impose split=\(String(splitState.id.uuidString.prefix(5)))"
+ " target=\(Int(target)) outcome=\(Int(lastImposedOutcome ?? -1))"
+ " avail=\(Int(available)) renudged=\(renudged ? 1 : 0)"
+ " retargeted=\(retargeted ? 1 : 0) moved=\(moved ? 1 : 0)"
+ " avail=\(Int(available))"
)
#endif
// A refused apply (AppKit clamped against constraints
Expand Down Expand Up @@ -1161,12 +1259,22 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
"divider.resizeIgnored split=\(splitState.id.uuidString.prefix(5)) eventType=\(eventType) leftDown=\(leftDown ? 1 : 0) isDragging=\(isDragging ? 1 : 0) normalized=\(String(format: "%.3f", normalizedPosition)) model=\(String(format: "%.3f", self.splitState.dividerPosition))"
)
#endif
let statePosition = self.splitState.dividerPosition
// Re-assert synchronously. setPositionSafely sets isSyncingProgrammatically=true,
// so the recursive splitViewDidResizeSubviews call is caught by the guard above.
// Deferring to the next runloop turn would allow the transient frame to propagate
// through SwiftUI layout → ghostty terminal resize → reflow, causing content shifts.
self.syncPosition(statePosition, in: splitView)
// A split the user positions by fraction puts its divider
// back right here, synchronously (setPositionSafely sets
// isSyncingProgrammatically, so the recursive didResize is
// caught by the guard above; waiting a turn would let the
// in-between frame reach ghostty and reflow content). A
// split with an imposed extent must NOT do that: when its
// container resizes, the stored extent is out of date, and
// putting the divider back from inside the very layout
// pass that moved it starts another layout pass — that
// recursion is what pinned the main thread. The host sends
// a new extent for the new size; until it lands, a
// not-yet-right frame is fine.
if self.splitState.imposedFirstExtent == nil {
let statePosition = self.splitState.dividerPosition
self.syncPosition(statePosition, in: splitView)
}
Comment thread
ejc3 marked this conversation as resolved.
Resolved
self.onGeometryChange?(false)
return
}
Expand Down
33 changes: 33 additions & 0 deletions Sources/Bonsplit/Public/BonsplitController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,30 @@ public final class BonsplitController {
public init(configuration: BonsplitConfiguration = .default) {
self.configuration = configuration
self.internalController = SplitViewController()
internalController.onDividerDragSessionChange = { [weak self] active in
guard let self else { return }
if active {
self.delegate?.splitTabBarDividerDragDidBegin(self)
} else {
self.delegate?.splitTabBarDividerDragDidEnd(self)
}
}
}

/// True while the user is dragging a divider anywhere in this tree.
/// Sessions bracket the divider's mouse-tracking lifecycle, so this is
/// authoritative — not inferred from resize callbacks. While true,
/// external sizing must not impose extents: the drag owns geometry.
public var isDividerDragActive: Bool {
internalController.activeDividerDragSessions > 0
}

/// Brackets a divider drag session from outside the built-in divider
/// tracking — for hosts with custom drag surfaces, and for tests that
/// need the session state without synthesizing mouse events. Balanced
/// begin/end pairs are the caller's responsibility.
public func noteDividerDragSession(_ active: Bool) {
internalController.noteDividerDragSession(active)
}

// MARK: - Tab Operations
Expand Down Expand Up @@ -992,6 +1016,15 @@ public final class BonsplitController {
/// the split to fraction semantics. Use this when pane contents are
/// grid-quantized (terminal cells) and a normalized fraction cannot
/// express the required pixel size losslessly.
///
/// The extent is applied when set (and re-applied if the divider drifts
/// while the split view's own size is unchanged), but it is NOT
/// re-asserted after the split view resizes: a stored extent computed
/// for the old size is stale at the new one, and re-applying it from
/// inside the resize's layout pass fights AppKit recursively. A host
/// that imposes extents is expected to impose fresh values when the
/// container it derived them from changes; until it does, the divider
/// rides AppKit's proportional resize.
public func setImposedFirstExtent(
_ extent: CGFloat?, forSplit splitId: UUID, fromExternal: Bool = false
) -> Bool {
Expand Down
13 changes: 13 additions & 0 deletions Sources/Bonsplit/Public/BonsplitDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ public protocol BonsplitDelegate: AnyObject {

/// Called to check if notifications should be sent during divider drag (opt-in for real-time sync)
func splitTabBar(_ controller: BonsplitController, shouldNotifyDuringDrag: Bool) -> Bool

/// A divider drag session started: the mouse went down on a divider and
/// AppKit's tracking loop is running. Until the matching end, the drag
/// owns divider geometry — hosts that impose extents should defer.
func splitTabBarDividerDragDidBegin(_ controller: BonsplitController)

/// The divider drag session ended (mouse released). Delivered from the
/// tracking lifecycle itself, so it fires even when no resize callback
/// coincides with the release; the final geometry has already been
/// reported through `didChangeGeometry`.
func splitTabBarDividerDragDidEnd(_ controller: BonsplitController)
}

// MARK: - Default Implementations (all methods optional)
Expand All @@ -99,4 +110,6 @@ public extension BonsplitDelegate {
func splitTabBar(_ controller: BonsplitController, didRequestTabMoveToDestination destinationId: String, for tab: Tab, inPane pane: PaneID) {}
func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) {}
func splitTabBar(_ controller: BonsplitController, shouldNotifyDuringDrag: Bool) -> Bool { false }
func splitTabBarDividerDragDidBegin(_ controller: BonsplitController) {}
func splitTabBarDividerDragDidEnd(_ controller: BonsplitController) {}
}