Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -70,6 +70,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)
}
}

init(rootNode: SplitNode? = nil) {
let resolvedRoot: SplitNode
let initialFocusedPaneId: PaneID?
Expand Down
160 changes: 134 additions & 26 deletions Sources/Bonsplit/Internal/Views/SplitContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,40 @@ private class ThemedSplitView: NSSplitView, BonsplitManagedSplitView {
// 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 @@ -247,6 +281,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)
Comment thread
ejc3 marked this conversation as resolved.
Resolved
}
}

// Capture animation origin before it gets cleared
let animationOrigin = splitState.animationOrigin
Expand Down Expand Up @@ -648,12 +697,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 @@ -705,8 +754,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 @@ -722,6 +771,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 @@ -847,6 +915,17 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
? splitView.arrangedSubviews[0].frame.width
: splitView.arrangedSubviews[0].frame.height
mirrorImposedOutcome(lastImposedOutcome ?? current, in: splitView)
// 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 @@ -859,6 +938,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.syncDividerNow?()
}
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 @@ -901,7 +991,7 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
if let imposed = splitState.imposedFirstExtent {
let target = clampedDividerPosition(imposed, in: splitView)
setPositionSafely(target, in: splitView, layout: true)
lastImposedTarget = target
lastImposedAvail = available
lastImposedEpoch = splitState.imposedEpoch
lastImposedOutcome = splitState.orientation == .horizontal
? splitView.arrangedSubviews[0].frame.width
Expand Down Expand Up @@ -948,39 +1038,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 @@ -1231,12 +1329,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)
}
self.onGeometryChange?(false)
return
}
Expand Down
38 changes: 38 additions & 0 deletions Sources/Bonsplit/Public/BonsplitController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,30 @@ public final class BonsplitController {
self.configuration = configuration
self.internalController = SplitViewController()
internalController.publicController = self
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 @@ -1024,6 +1048,15 @@ public final class BonsplitController {
/// express the required pixel size losslessly. Repeating the same extent
/// is idempotent; after an external constraint change, use
/// `retryImposedFirstExtent(forSplit:)` to request one fresh bounded apply.
///
/// 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 Expand Up @@ -1052,6 +1085,11 @@ public final class BonsplitController {
/// `setImposedFirstExtent(_:forSplit:fromExternal:)`, whose identical
/// updates are intentionally idempotent.
public func retryImposedFirstExtent(forSplit splitId: UUID) -> Bool {
// A live drag session owns the divider: re-asserting an imposed
// extent now would yank it out from under the pointer mid-gesture.
// Refuse instead of deferring — the host's drag-end sync imposes
// fresh values (or retries) once the session closes.
guard internalController.activeDividerDragSessions == 0 else { return false }
guard let split = internalController.findSplit(splitId),
split.imposedFirstExtent != nil
else { return false }
Expand Down
Loading