diff --git a/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift b/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift index 35b742dc..aebfb066 100644 --- a/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift +++ b/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift @@ -70,6 +70,37 @@ 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) + if !isActive { + // Imposed applies refuse while a session is live and stay + // armed. Give every still-imposed split one deferred apply + // now that the drag released the divider — no epoch bump, so + // a split already at its target just refreshes its memos. + // The split that was dragged cleared its imposition when the + // gesture took ownership, so it skips itself here. + for split in allSplits where split.imposedFirstExtent != nil { + split.syncDividerNow?() + } + } + } + } + init(rootNode: SplitNode? = nil) { let resolvedRoot: SplitNode let initialFocusedPaneId: PaneID? diff --git a/Sources/Bonsplit/Internal/Views/SplitContainerView.swift b/Sources/Bonsplit/Internal/Views/SplitContainerView.swift index ac2671cb..68778554 100644 --- a/Sources/Bonsplit/Internal/Views/SplitContainerView.swift +++ b/Sources/Bonsplit/Internal/Views/SplitContainerView.swift @@ -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 @@ -247,6 +281,28 @@ struct SplitContainerView: NSViewRepresentabl context.coordinator.secondHostingController = secondController context.coordinator.splitView = splitView + let internalController = controller + context.coordinator.isTreeDragSessionActive = { [weak internalController] in + (internalController?.activeDividerDragSessions ?? 0) > 0 + } + splitView.onDividerDragSession = { [weak coordinator = context.coordinator, weak internalController] active in + // The coordinator arms isDragging at begin, before any delegate + // reacts to the session. At end the counter's zero crossing + // delivers the final geometry notification — forced past the + // external-update suppression window — before the delegate hears + // drag-end: the delegate contract promises the settled geometry + // has already been reported when drag-end runs. Hosts that + // suppress geometry callbacks while the session is live lose + // nothing — the model is final by then, and drag-end is the + // signal to read it. + if active { + coordinator?.dividerDragSessionChanged(true) + internalController?.noteDividerDragSession(true) + } else { + coordinator?.dividerDragSessionChanged(false) + internalController?.noteDividerDragSession(false) + } + } // Capture animation origin before it gets cleared let animationOrigin = splitState.animationOrigin @@ -454,6 +510,12 @@ struct SplitContainerView: NSViewRepresentabl (splitView as? ThemedSplitView)?.customDividerHitExpansion = appearance.dividerHitExpansion (splitView as? ThemedSplitView)?.stampedInternalController = controller (splitView as? ThemedSplitView)?.stampedSplitId = splitState.id + // Re-install alongside the identity stamps above so a reused + // coordinator keeps answering for the tree it currently renders. + let internalController = controller + context.coordinator.isTreeDragSessionActive = { [weak internalController] in + (internalController?.activeDividerDragSessions ?? 0) > 0 + } // Update orientation if changed splitView.isVertical = splitState.orientation == .horizontal @@ -646,14 +708,19 @@ struct SplitContainerView: NSViewRepresentabl /// Retry a few turns so entry animations are not dropped on first layout. var initialDividerApplyAttempts = 0 var onGeometryChange: ((_ isDragging: Bool) -> Void)? + /// Answers whether a divider drag session is live anywhere in this + /// tree. Consulted before every imposed apply: mid-drag the user owns + /// the divider, so an apply refuses and stays armed instead of moving + /// it under the pointer. Installed from makeNSView/updateNSView. + var isTreeDragSessionActive: (() -> Bool)? /// 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 is put back synchronously (same + /// size) or re-armed for one deferred apply at the settled size. var lastImposedOutcome: CGFloat? + var lastImposedAvail: CGFloat? var lastImposedEpoch: Int? var imposedRetryBudget = 0 var imposedApplyPending = false @@ -705,8 +772,8 @@ struct SplitContainerView: NSViewRepresentabl splitStateId = newState.id splitState = newState lastAppliedPosition = newState.dividerPosition - lastImposedTarget = nil lastImposedOutcome = nil + lastImposedAvail = nil lastImposedEpoch = nil imposedRetryBudget = 0 didApplyInitialDividerPosition = false @@ -722,6 +789,22 @@ struct SplitContainerView: 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. + /// The model itself is maintained by the resize callbacks during the + /// drag; the guaranteed drag-end geometry notification is delivered by + /// the internal controller's zero crossing right after this — from + /// there it bypasses the external-update suppression window, which + /// would otherwise swallow a release landing within ~50ms of a + /// fromExternal call. + func dividerDragSessionChanged(_ active: Bool) { +#if DEBUG + dlog("divider.session split=\(String(splitState.id.uuidString.prefix(5))) \(active ? "begin" : "end")") +#endif + isDragging = active + } + private func splitTotalSize(in splitView: NSSplitView) -> CGFloat { splitState.orientation == .horizontal ? splitView.bounds.width @@ -832,6 +915,9 @@ struct SplitContainerView: NSViewRepresentabl let imposed = splitState.imposedFirstExtent, splitView.arrangedSubviews.count >= 2 else { return } + // Refuse mid-drag without consuming budget: the chain resumes + // from syncPosition when the session-end renudge lands. + if isTreeDragSessionActive?() == true { return } imposedRetryBudget -= 1 let target = clampedDividerPosition(imposed, in: splitView) let current = splitState.orientation == .horizontal @@ -847,6 +933,17 @@ struct SplitContainerView: 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)))" @@ -859,6 +956,17 @@ struct SplitContainerView: 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 @@ -901,7 +1009,7 @@ struct SplitContainerView: 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 @@ -934,6 +1042,17 @@ struct SplitContainerView: NSViewRepresentabl // exact points and mirrors the resulting fraction back into the // model for readers. if let imposed = splitState.imposedFirstExtent { + // While a drag session owns a divider anywhere in this tree, + // an imposed apply refuses outright: no divider write, no + // memo or epoch bookkeeping, no retry-budget consumption. + // Every imposed-apply trigger funnels through here (fresh + // impositions via syncDividerNow, drift renudges, descendant + // renudges), so this is the single gate that keeps a + // main-queue apply from yanking the divider out from under + // the pointer mid-gesture. The pending extent stays armed; + // the internal controller's session-end renudge re-runs this + // sync once the user's hand is off the divider. + if isTreeDragSessionActive?() == true { return } guard splitView.arrangedSubviews.count >= 2 else { return } let available = splitAvailableSize(in: splitView) guard available > 0 else { return } @@ -948,8 +1067,6 @@ struct SplitContainerView: 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 @@ -957,21 +1074,37 @@ struct SplitContainerView: NSViewRepresentabl // 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 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 synchronously; one apply settles it, since + // applying cannot resize the split view. If the split view + // WAS resized, applying synchronously from inside the + // resize's own layout pass fights AppKit, so re-arm one + // deferred apply against the settled size instead (below). + // We cannot just park and wait for the host: a host whose + // per-pane ideals are container-independent re-imposes the + // SAME extent, and only when its own inputs change — an + // apply may never terminate off-target without a re-arm + // edge, or the divider stays at the proportional position + // indefinitely. 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 @@ -979,8 +1112,7 @@ struct SplitContainerView: NSViewRepresentabl 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 @@ -997,6 +1129,22 @@ struct SplitContainerView: NSViewRepresentabl } else { imposedRetryBudget = 0 } + } else if moved && !availUnchanged { + // The container resized under an unchanged imposed + // extent. Recording the new avail immediately bounds + // this to one re-arm per size change; the deferred + // apply runs a turn later, after AppKit's resize pass + // has finished, so there is no recursive fighting. + lastImposedAvail = available + imposedRetryBudget = max(imposedRetryBudget, 1) + imposedRetrySplitView = splitView + scheduleImposedRetry() + } else if imposedRetryBudget > 0 { + // A retry chain the drag-session gate interrupted parked + // here with budget left; the session-end renudge lands in + // this sync, so pick the chain back up. + imposedRetrySplitView = splitView + scheduleImposedRetry() } mirrorImposedOutcome(lastImposedOutcome ?? current, in: splitView) return @@ -1231,12 +1379,34 @@ struct SplitContainerView: 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: 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. But it cannot just park + // and wait for the host either: a host whose per-pane + // ideals are container-independent re-imposes the SAME + // extent, and only when its own inputs change, so nothing + // would ever move the divider off AppKit's proportional + // position. Re-arm one deferred apply against the settled + // size instead — recording the new avail immediately + // bounds this to one re-arm per size change, and the + // apply runs a turn later, outside this layout pass. A + // mid-drag retry refuses without consuming the budget and + // the session-end renudge resumes the chain. + if self.splitState.imposedFirstExtent == nil { + let statePosition = self.splitState.dividerPosition + self.syncPosition(statePosition, in: splitView) + } else if abs(availableSize - (self.lastImposedAvail ?? -1)) > 0.01 { + self.lastImposedAvail = availableSize + self.imposedRetryBudget = max(self.imposedRetryBudget, 1) + self.imposedRetrySplitView = splitView + self.scheduleImposedRetry() + } self.onGeometryChange?(false) return } diff --git a/Sources/Bonsplit/Public/BonsplitController.swift b/Sources/Bonsplit/Public/BonsplitController.swift index 5d90ae6a..124502a2 100644 --- a/Sources/Bonsplit/Public/BonsplitController.swift +++ b/Sources/Bonsplit/Public/BonsplitController.swift @@ -119,6 +119,38 @@ 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 { + // Drag-end promises the settled geometry has already been + // reported when dragDidEnd runs. Deliver it here, on the + // session's zero crossing, and force it past the external- + // update suppression window: a quick flick can release + // within that window's ~50ms of a fromExternal call, and + // the gate would silently swallow the one notification the + // contract guarantees. + self.notifyGeometryChange(force: true) + 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 @@ -1021,9 +1053,25 @@ public final class BonsplitController { /// for readers. A user divider drag clears the imposition and returns /// 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. Repeating the same extent - /// is idempotent; after an external constraint change, use - /// `retryImposedFirstExtent(forSplit:)` to request one fresh bounded apply. + /// express the required pixel size losslessly. Every explicit call + /// re-arms one bounded apply, including a call that repeats the stored + /// extent: hosts re-impose after their container resizes, and the fresh + /// plan often computes the SAME extent (per-pane ideals are container- + /// independent) while the resize moved the divider off it — deduping the + /// call by value would leave the divider wedged at the drifted position. + /// Repeating the extent stays cheap when the divider is already at the + /// target (the coordinator refreshes its memos without a layout pass). + /// + /// The extent is applied when set, re-applied if the divider drifts + /// while the split view's own size is unchanged, and re-applied once + /// (deferred a runloop turn, clamped to the new bounds) after the split + /// view itself resizes. The resize case cannot apply synchronously — + /// that fights AppKit from inside its own layout pass — but it cannot + /// just wait for the host either: a host whose per-pane ideals are + /// container-independent re-imposes the same extent, and only when its + /// own inputs change, so a divider parked at AppKit's proportional + /// position would stay there until some unrelated input. An apply never + /// terminates off-target without re-arming on the next size change. public func setImposedFirstExtent( _ extent: CGFloat?, forSplit splitId: UUID, fromExternal: Bool = false ) -> Bool { @@ -1035,6 +1083,15 @@ public final class BonsplitController { if split.imposedFirstExtent != normalizedExtent { split.imposedFirstExtent = normalizedExtent split.imposedEpoch &+= 1 + } else if normalizedExtent != nil { + // Same extent, explicit call: re-arm one apply anyway. The + // coordinator's renudge path is memo-only when the divider is + // already at the target, so this cannot churn layout; when the + // divider drifted (a container resize rescaled it while the + // re-planned extent came out identical), this puts it back + // without waiting for the coordinator's own resize re-arm — + // see `syncPosition`'s "bounded by explicit calls" contract. + split.imposedEpoch &+= 1 } split.syncDividerNow?() if fromExternal { @@ -1052,6 +1109,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 } @@ -1066,9 +1128,14 @@ public final class BonsplitController { } /// Notify geometry change to delegate (internal use) - /// - Parameter isDragging: Whether the change is due to active divider dragging - internal func notifyGeometryChange(isDragging: Bool = false) { - guard !internalController.isExternalUpdateInProgress else { return } + /// - Parameters: + /// - isDragging: Whether the change is due to active divider dragging + /// - force: Deliver even inside the external-update suppression window. + /// Only the drag-end path passes true: its notification is the one + /// the delegate contract guarantees, so the anti-echo gate must not + /// swallow it. + internal func notifyGeometryChange(isDragging: Bool = false, force: Bool = false) { + guard force || !internalController.isExternalUpdateInProgress else { return } // If dragging, check if delegate wants notifications during drag if isDragging { diff --git a/Sources/Bonsplit/Public/BonsplitDelegate.swift b/Sources/Bonsplit/Public/BonsplitDelegate.swift index 1444a418..1e127590 100644 --- a/Sources/Bonsplit/Public/BonsplitDelegate.swift +++ b/Sources/Bonsplit/Public/BonsplitDelegate.swift @@ -76,6 +76,19 @@ 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`. That report is delivered even + /// inside the brief suppression window a `fromExternal` update opens, so + /// a release right after an external echo cannot lose it. + func splitTabBarDividerDragDidEnd(_ controller: BonsplitController) } // MARK: - Default Implementations (all methods optional) @@ -99,4 +112,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) {} } diff --git a/Sources/Bonsplit/Public/DebugEventLog.swift b/Sources/Bonsplit/Public/DebugEventLog.swift index 9e0edda1..eafff206 100644 --- a/Sources/Bonsplit/Public/DebugEventLog.swift +++ b/Sources/Bonsplit/Public/DebugEventLog.swift @@ -11,6 +11,28 @@ public final class DebugEventLog: @unchecked Sendable { private let queue = DispatchQueue(label: "cmux.debug-event-log") private static let logPath = resolveLogPath() + /// When set, lines are handed to this closure instead of appended here. + /// A host app whose own debug log writes to the same file installs a + /// sink at startup so the file has exactly one serialized append path; + /// two independent appenders interleave and reorder lines under load. + /// Confined to `queue`. + private var externalSink: (@Sendable (String) -> Void)? + + /// Fallback appender for standalone use (no sink installed): one handle + /// opened with O_APPEND and kept open, so a concurrent appender cannot + /// clobber whole lines. Confined to `queue`. + private var appendHandle: FileHandle? + + /// Routes every subsequent line to `sink` (or back to the built-in file + /// append when `nil`). The sink receives the raw message, without the + /// timestamp prefix, so the receiving log applies its own line format. + public static func setExternalSink(_ sink: (@Sendable (String) -> Void)?) { + let log = shared + log.queue.async { + log.externalSink = sink + } + } + private static let formatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "HH:mm:ss.SSS" @@ -55,29 +77,43 @@ public final class DebugEventLog: @unchecked Sendable { public func log(_ msg: String) { let ts = Self.formatter.string(from: Date()) - let entry = "\(ts) \(msg)" queue.async { + if let sink = self.externalSink { + sink(msg) + return + } + let entry = "\(ts) \(msg)" if self.entries.count >= self.capacity { self.entries.removeFirst() } self.entries.append(entry) // Append to file for real-time tail -f - let line = entry + "\n" - if let data = line.data(using: .utf8) { - if let handle = FileHandle(forWritingAtPath: Self.logPath) { - defer { try? handle.close() } - guard (try? handle.seekToEnd()) != nil else { return } - try? handle.write(contentsOf: data) - } else { - FileManager.default.createFile(atPath: Self.logPath, contents: data) + guard let data = (entry + "\n").data(using: .utf8) else { return } + if self.appendHandle == nil { + let fd = open(Self.logPath, O_WRONLY | O_APPEND | O_CREAT, 0o644) + if fd >= 0 { + self.appendHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) } } + guard let handle = self.appendHandle else { return } + do { + try handle.write(contentsOf: data) + } catch { + // The file may have been rotated away; reopen on the next line. + self.appendHandle = nil + } } } /// Write all buffered entries to the log file (full dump, replacing contents). public func dump() { queue.async { + // With a sink installed the receiving log owns the file; replacing + // its contents here would throw away the other writer's lines. + if self.externalSink != nil { return } + // The atomic write replaces the inode; reopen the handle lazily. + try? self.appendHandle?.close() + self.appendHandle = nil let content = self.entries.joined(separator: "\n") + "\n" try? content.write(toFile: Self.logPath, atomically: true, encoding: .utf8) } diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 60a211fb..e593777c 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -146,6 +146,28 @@ final class BonsplitTests: XCTestCase { } } + private final class DividerDragEventRecorder: BonsplitDelegate { + enum Event: Equatable { + case dragBegan + case dragEnded + case geometryChanged + } + + var events: [Event] = [] + + func splitTabBarDividerDragDidBegin(_ controller: BonsplitController) { + events.append(.dragBegan) + } + + func splitTabBarDividerDragDidEnd(_ controller: BonsplitController) { + events.append(.dragEnded) + } + + func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { + events.append(.geometryChanged) + } + } + private final class ObservationInvalidationFlag: @unchecked Sendable { var didInvalidate = false } @@ -3506,6 +3528,367 @@ final class BonsplitTests: XCTestCase { ) } + /// A host that imposes exact extents re-imposes after its container + /// resizes, and the fresh plan often computes the SAME number: per-pane + /// ideals are container-independent (a row of N terminal cells is the + /// same points in any container). Meanwhile AppKit's proportional resize + /// has moved the divider off the imposed extent. Two recovery edges must + /// both work, and this test pins each to its own trigger: the container + /// size change re-arms one apply of its own, and an explicit identical + /// re-imposition must be accepted, not deduped by value. The second half + /// isolates the re-imposition by moving the divider at CONSTANT + /// container size — the size-change re-arm cannot fire there, and the + /// test proves nothing heals the divider until the re-impose call runs. + @MainActor + func testReimposingSameExtentAfterContainerResizeRetargetsExactly() throws { + var configuration = BonsplitConfiguration() + configuration.appearance.minimumPaneWidth = 1 + configuration.appearance.minimumPaneHeight = 1 + configuration.appearance.enableAnimations = false + configuration.dividerPositionRange = 0...1 + let controller = BonsplitController(configuration: configuration) + _ = controller.createTab(title: "Left") + let sourcePane = try XCTUnwrap(controller.focusedPaneId) + XCTAssertNotNil(controller.splitPane( + sourcePane, + orientation: .horizontal, + initialDividerPosition: 0.5 + )) + guard case .split(let split) = controller.treeSnapshot() else { + XCTFail("Expected split root") + return + } + let splitId = try XCTUnwrap(UUID(uuidString: split.id)) + + let hostingView = NSHostingView( + rootView: BonsplitView(controller: controller) { _, _ in + Color.clear + } emptyPane: { _ in + Color.clear + } + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + let contentView = try XCTUnwrap(window.contentView) + hostingView.frame = contentView.bounds + hostingView.autoresizingMask = [.width, .height] + contentView.addSubview(hostingView) + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + contentView.layoutSubtreeIfNeeded() + let splitView = try XCTUnwrap(firstDescendant(ofType: NSSplitView.self, in: hostingView)) + XCTAssertEqual(splitView.arrangedSubviews.count, 2) + + func settleImposed(_ extent: CGFloat) -> CGFloat { + _ = controller.setImposedFirstExtent(extent, forSplit: splitId, fromExternal: true) + for _ in 0..<12 { + splitView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.02)) + contentView.layoutSubtreeIfNeeded() + if abs(splitView.arrangedSubviews[0].frame.width - extent) <= 1 { break } + } + return splitView.arrangedSubviews[0].frame.width + } + + let imposed = CGFloat(120) + XCTAssertEqual(settleImposed(imposed), imposed, accuracy: 1.5) + + // The container shrinks; AppKit rescales the split proportionally. + // Capture the divider before any runloop turn — the re-arm's heal is + // deferred a turn, so the drift must be observable here or the + // recovery assertions below would pass vacuously. + window.setContentSize(NSSize(width: 300, height: 300)) + contentView.layoutSubtreeIfNeeded() + splitView.layoutSubtreeIfNeeded() + let drifted = splitView.arrangedSubviews[0].frame.width + XCTAssertLessThan( + drifted, + imposed - 4, + "expected the proportional resize to move the divider off the imposed extent" + ) + + // The size change itself re-arms one deferred apply: the divider + // comes back with no fresh impose call. + for _ in 0..<12 { + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.02)) + if abs(splitView.arrangedSubviews[0].frame.width - imposed) <= 1 { break } + } + XCTAssertEqual( + splitView.arrangedSubviews[0].frame.width, + imposed, + accuracy: 1.5, + "the container resize must re-arm one apply of the stored extent" + ) + + // Now isolate the re-imposition edge from the size-change edge: move + // the divider at CONSTANT container size, behind the coordinator's + // back (a direct AppKit setPosition keeps the imposition stored and + // changes no avail, so the size-change re-arm cannot fire). + let perturbed = CGFloat(200) + splitView.setPosition(perturbed, ofDividerAt: 0) + splitView.layoutSubtreeIfNeeded() + XCTAssertEqual( + splitView.arrangedSubviews[0].frame.width, + perturbed, + accuracy: 1.5, + "the programmatic perturbation must actually move the divider" + ) + + // With no size change and no impose call, nothing may heal this: + // pumping must leave the divider where the perturbation put it. + for _ in 0..<6 { + splitView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.02)) + contentView.layoutSubtreeIfNeeded() + } + XCTAssertEqual( + splitView.arrangedSubviews[0].frame.width, + perturbed, + accuracy: 1.5, + "at constant container size, no autonomous heal may move the divider — recovery below is attributable to the re-impose call alone" + ) + + XCTAssertEqual( + settleImposed(imposed), + imposed, + accuracy: 1.5, + "an identical re-imposition must be accepted, not deduped away — it is the only actor left" + ) + } + + /// While a divider drag session is live, the user owns the divider: an + /// imposed extent arriving mid-session must not move it. The imposition + /// stays armed instead, and the session's end applies it with no fresh + /// impose call from the host. + @MainActor + func testImposeDuringDragSessionDefersUntilSessionEnds() throws { + var configuration = BonsplitConfiguration() + configuration.appearance.minimumPaneWidth = 1 + configuration.appearance.minimumPaneHeight = 1 + configuration.appearance.enableAnimations = false + configuration.dividerPositionRange = 0...1 + let controller = BonsplitController(configuration: configuration) + _ = controller.createTab(title: "Left") + let sourcePane = try XCTUnwrap(controller.focusedPaneId) + XCTAssertNotNil(controller.splitPane( + sourcePane, + orientation: .horizontal, + initialDividerPosition: 0.5 + )) + guard case .split(let split) = controller.treeSnapshot() else { + XCTFail("Expected split root") + return + } + let splitId = try XCTUnwrap(UUID(uuidString: split.id)) + + let hostingView = NSHostingView( + rootView: BonsplitView(controller: controller) { _, _ in + Color.clear + } emptyPane: { _ in + Color.clear + } + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + let contentView = try XCTUnwrap(window.contentView) + hostingView.frame = contentView.bounds + hostingView.autoresizingMask = [.width, .height] + contentView.addSubview(hostingView) + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + contentView.layoutSubtreeIfNeeded() + let splitView = try XCTUnwrap(firstDescendant(ofType: NSSplitView.self, in: hostingView)) + XCTAssertEqual(splitView.arrangedSubviews.count, 2) + + let widthBeforeImpose = splitView.arrangedSubviews[0].frame.width + let imposed = CGFloat(120) + XCTAssertGreaterThan( + abs(widthBeforeImpose - imposed), + 20, + "the imposed extent must differ from the current divider for this test to mean anything" + ) + + controller.noteDividerDragSession(true) + XCTAssertTrue(controller.setImposedFirstExtent(imposed, forSplit: splitId)) + for _ in 0..<6 { + splitView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.02)) + contentView.layoutSubtreeIfNeeded() + } + XCTAssertEqual( + splitView.arrangedSubviews[0].frame.width, + widthBeforeImpose, + accuracy: 1.5, + "an imposed extent must not move the divider while a drag session is live" + ) + + controller.noteDividerDragSession(false) + for _ in 0..<12 { + splitView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.02)) + contentView.layoutSubtreeIfNeeded() + if abs(splitView.arrangedSubviews[0].frame.width - imposed) <= 1 { break } + } + XCTAssertEqual( + splitView.arrangedSubviews[0].frame.width, + imposed, + accuracy: 1.5, + "the armed extent must apply once the session closes, with no fresh impose call" + ) + } + + /// A container resize rescales the split proportionally, moving the + /// divider off its imposed extent. Hosts whose per-pane ideals are + /// container-independent re-impose the SAME extent, and only when their + /// own inputs change — so if nothing re-applies the stored extent here, + /// the divider stays at the proportional position indefinitely. The + /// imposed sync must give the parked divider one bounded apply against + /// the settled size, with no fresh imposition and no drag session. + @MainActor + func testImposedExtentReappliesAfterContainerResizeWithoutFreshImposition() throws { + var configuration = BonsplitConfiguration() + configuration.appearance.minimumPaneWidth = 1 + configuration.appearance.minimumPaneHeight = 1 + configuration.appearance.enableAnimations = false + configuration.dividerPositionRange = 0...1 + let controller = BonsplitController(configuration: configuration) + _ = controller.createTab(title: "Left") + let sourcePane = try XCTUnwrap(controller.focusedPaneId) + XCTAssertNotNil(controller.splitPane( + sourcePane, + orientation: .horizontal, + initialDividerPosition: 0.5 + )) + guard case .split(let split) = controller.treeSnapshot() else { + XCTFail("Expected split root") + return + } + let splitId = try XCTUnwrap(UUID(uuidString: split.id)) + + let hostingView = NSHostingView( + rootView: BonsplitView(controller: controller) { _, _ in + Color.clear + } emptyPane: { _ in + Color.clear + } + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + let contentView = try XCTUnwrap(window.contentView) + hostingView.frame = contentView.bounds + hostingView.autoresizingMask = [.width, .height] + contentView.addSubview(hostingView) + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + contentView.layoutSubtreeIfNeeded() + let splitView = try XCTUnwrap(firstDescendant(ofType: NSSplitView.self, in: hostingView)) + XCTAssertEqual(splitView.arrangedSubviews.count, 2) + + let imposed = CGFloat(120) + XCTAssertTrue(controller.setImposedFirstExtent(imposed, forSplit: splitId)) + for _ in 0..<12 { + splitView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.02)) + contentView.layoutSubtreeIfNeeded() + if abs(splitView.arrangedSubviews[0].frame.width - imposed) <= 1 { break } + } + XCTAssertEqual(splitView.arrangedSubviews[0].frame.width, imposed, accuracy: 1.5) + + // Grow the container. No further imposition, no drag session: the + // stored extent is the only authority left, and it still fits. + // Capture the divider before any runloop turn — the heal is deferred + // a turn, so the proportional drift must be observable here or the + // recovery assertion below would pass vacuously. + window.setContentSize(NSSize(width: 640, height: 300)) + contentView.layoutSubtreeIfNeeded() + splitView.layoutSubtreeIfNeeded() + XCTAssertGreaterThan( + splitView.bounds.width, + 500, + "the window resize must have reached the split view for this test to mean anything" + ) + XCTAssertGreaterThan( + splitView.arrangedSubviews[0].frame.width, + imposed + 20, + "expected the proportional resize to move the divider off the imposed extent (~192pt for 400→640)" + ) + + for _ in 0..<12 { + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.02)) + splitView.layoutSubtreeIfNeeded() + if abs(splitView.arrangedSubviews[0].frame.width - imposed) <= 1 { break } + } + XCTAssertEqual( + splitView.arrangedSubviews[0].frame.width, + imposed, + accuracy: 1.5, + "a parked imposed divider must get one bounded apply when the container settles" + ) + } + + /// Drag-end promises the settled geometry has already been reported when + /// `splitTabBarDividerDragDidEnd` runs. A `fromExternal` update opens a + /// short suppression window for outgoing geometry notifications; a + /// session that ends inside that window (a quick flick released right + /// after the host echoed a position) must still get its final + /// `didChangeGeometry`, and get it before drag-end. + @MainActor + func testDragEndGeometryBypassesExternalUpdateSuppression() throws { + let controller = BonsplitController(configuration: BonsplitConfiguration( + appearance: .init(enableAnimations: false) + )) + let recorder = DividerDragEventRecorder() + controller.delegate = recorder + _ = controller.createTab(title: "Base") + let sourcePane = try XCTUnwrap(controller.focusedPaneId) + XCTAssertNotNil(controller.splitPane( + sourcePane, + orientation: .horizontal, + initialDividerPosition: 0.5 + )) + guard case .split(let split) = controller.treeSnapshot() else { + XCTFail("Expected split root") + return + } + let splitId = try XCTUnwrap(UUID(uuidString: split.id)) + + recorder.events.removeAll() + controller.noteDividerDragSession(true) + XCTAssertEqual(recorder.events, [.dragBegan]) + + // The external echo lands, then the session ends immediately — well + // inside the suppression window the external update opened. + XCTAssertTrue(controller.setDividerPosition(0.45, forSplit: splitId, fromExternal: true)) + recorder.events.removeAll() + controller.noteDividerDragSession(false) + + XCTAssertEqual( + recorder.events, + [.geometryChanged, .dragEnded], + "session end must deliver the final geometry, ahead of drag-end, even while an external update suppresses notifications" + ) + } + @MainActor func testTranslucentSplitWrappersStayClear() { let appearance = BonsplitConfiguration.Appearance(