import AppKit import QuartzCore import SwiftUI /// Window-local state for an in-flight lane resize — the right-edge drag of 03-board-ui.md § Lane. /// At most one runs per board window at a time; `BoardView` owns it as `@State` and hands it to the /// lanes and their grab strips. /// /// The interaction has three collaborating pieces: `LaneLayoutMath` (the pure geometry), this /// session (the live state plus the per-tick window resize), and `LaneResizeHandle` (the invisible /// grab strip that drives it from a `DragGesture`). All three are ported from the pathfinder's /// proven `ColumnResize.swift`, which is what 03-board-ui.md's "pathfinder behavior, proven" refers /// to. /// /// ### The invariant that makes it feel solid /// /// **While a session is active, every OTHER lane keeps its exact pixel width.** That is achieved by /// freezing the strip's standard (1×) width at drag start and sizing the *window* so that after /// each snap tick the ordinary viewport-derived formula reproduces that frozen standard exactly — /// so releasing the drag hands back to the resting layout with no pixel jump. This is the opposite /// mechanism from the stepper (and its ⌥⌘→/⌥⌘← keyboard face), which never touches the window and /// re-divides the existing width across the new unit total; the design is explicit that /// window-growing behaviour belongs to the drag alone. /// /// The session owns the two things that must move together on each tick: the SwiftUI unit count /// (`units`, which drives the shadow slot, the siblings' positions, and the resizing lane's masonry /// column count) and the host window's width. They animate on matching curves — `Motion.laneResize` /// and `Motion.laneResizeWindowDuration`, the two faces of 03-board-ui.md § Motion's lane-resize /// entry — so the window edge and the lanes to its right travel as one. @MainActor @Observable final class LaneResizeSession { /// The lane being resized; `nil` when idle. Observed — flipping it drives `BoardView`'s /// frozen-standard override and the shadow slot on and off, and `LaneView`'s column count. private(set) var laneID: ItemID? /// The dragged lane's live rendered width — tracks the cursor continuously (rubber-banded at /// the ends), so its masonry reflows live between ticks. private(set) var liveWidth: CGFloat = 0 /// The snapped unit count k. Drives the shadow slot width, the layout slot the siblings /// position off, and the resizing lane's masonry column count. Ticks by ±1 and animates. private(set) var units: Int = 1 /// The strip's standard (1×) width, frozen at drag start. Used for ALL lane widths in /// `BoardView` while a session is active — the window is animating mid-session, so recomputing /// the standard from the live viewport width would feed the animation back into the layout and /// pulse every lane. Read within renders already triggered by the observed properties above, so /// it need not itself be observed. @ObservationIgnored private(set) var standard: CGFloat = 1 /// The strip's inter-lane gap (== `BoardView.spacing`), captured at begin. @ObservationIgnored private var gap: CGFloat = 12 /// The committed unit count at drag start — the anchor the drag translation is measured from. @ObservationIgnored private var startUnits: Int = 1 /// The largest unit count that fits on screen. The drag's only ceiling: Lanework's `width` has /// no cap (03-board-ui.md § Lane), so nothing else bounds growth. @ObservationIgnored private var fittingUnits: Int = 1 /// The host window, resized by ±(standard + gap) on each tick. Weak — a window can close, /// though a resize cannot outlive the gesture that drives it. @ObservationIgnored private weak var window: NSWindow? /// Reduce Motion, read once at `begin` and frozen for the gesture (10-accessibility.md's /// lane-resize commitment — "the lane-resize rubber-band feedback … gets reduced variants"). /// /// Frozen rather than re-read per tick because the two halves of a tick — the unit count and the /// window's width — must never disagree about which variant they are in: a setting flipped /// between them would animate the lanes inside a window that jumped, which is the one thing this /// session's whole matched-curve design exists to prevent. Read from AppKit rather than from the /// SwiftUI environment because this type animates an `NSWindow` and has no environment to read /// (`Motion.prefersReducedMotion`). @ObservationIgnored private var reducedMotion = false var isActive: Bool { laneID != nil } func isResizing(_ id: ItemID) -> Bool { laneID == id } /// The tick-down re-entry distance: how far the live edge must retreat back into a gap it has /// already crossed before the shadow shrinks (03-board-ui.md § Lane's "10pt release /// hysteresis" — see `LaneLayoutMath.snappedUnits`). Fixed in points, not proportional to /// `standard`: it only needs to be comfortably larger than cursor jitter, which 10pt is /// regardless of lane size. private let reentry: CGFloat = 10 /// The rubber-band overshoot fraction past the end slots. private let resistance: CGFloat = 0.25 /// Unit counts the tick may reach: one up to the on-screen fit. The floor is 1 because a lane /// spans at least one unit; there is no ceiling but the screen. private var allowedRange: ClosedRange { 1...max(1, fittingUnits) } private var minSlot: CGFloat { LaneLayoutMath.slotWidth(units: allowedRange.lowerBound, standard: standard, gap: gap) } private var maxSlot: CGFloat { LaneLayoutMath.slotWidth(units: allowedRange.upperBound, standard: standard, gap: gap) } // MARK: - Lifecycle /// Starts a resize of `laneID`, freezing the standard width and the gap and measuring how far /// the window can grow on its current screen. func begin(laneID: ItemID, units: Int, standard: CGFloat, gap: CGFloat, window: NSWindow?) { self.laneID = laneID self.startUnits = units self.units = units self.standard = standard self.gap = gap self.window = window self.reducedMotion = Motion.prefersReducedMotion self.liveWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap) self.fittingUnits = Self.fittingMaxUnits(currentUnits: units, standard: standard, gap: gap, window: window) } /// Applies a drag translation (points, measured from the gesture's start): tracks the live width /// to the cursor with end-resistance, then ticks the snapped unit count to its fixed point and /// applies the result in one animated step. /// /// A single call to `snappedUnits` only ever steps by ±1, but a fast flick can carry the live /// width across two (or more) thresholds between consecutive gesture events, so it is iterated /// here until it stops moving — bounded by `allowedRange`'s width, so this never loops more than /// a couple of times in practice. `tick(to:)` already computes a correct multi-step window-size /// delta from `units` to the target, so only the FINAL target gets one `tick` call, not one per /// intermediate step. func update(translation: CGFloat) { guard isActive else { return } let startSlot = LaneLayoutMath.slotWidth(units: startUnits, standard: standard, gap: gap) liveWidth = LaneLayoutMath.resistedWidth( proposed: startSlot + translation, minSlot: minSlot, maxSlot: maxSlot, resistance: resistance) var target = units while true { let next = LaneLayoutMath.snappedUnits( liveWidth: liveWidth, currentUnits: target, standard: standard, gap: gap, allowedRange: allowedRange, reentry: reentry) if next == target { break } target = next } if target != units { tick(to: target) } } /// Commits the snapped unit count and dismisses the session. Order matters for a flash-free /// handoff: write the model FIRST (the session is still active, so the frozen standard still /// governs and the shadow slot does not budge), THEN clear the session inside the snap animation /// — at which point `BoardView` reverts to the viewport-derived standard, which the window /// sizing has kept equal to the frozen one, so the resting layout reproduces the same pixels /// while the live width animates the last sub-tick of overflow/underfill away. The window and /// the siblings are already in place; neither is touched here. /// /// The commit is a *write*, not a snapshot mutation: it goes to disk through the Writer and /// comes back as an ordinary reload (02-architecture.md § Layering's one-way flow), so the lane /// briefly renders at its pre-drag width if the write fails — which is exactly the honesty the /// banner then explains. func end(commit: (ItemID, Int) -> Void) { guard let laneID else { return } commit(laneID, units) withAnimation(Motion.laneResize(reduced: reducedMotion)) { self.laneID = nil self.liveWidth = 0 } } // MARK: - Tick /// A single snapped step: animate the unit count (which resizes the shadow slot, translates the /// lanes to the right, and reflows the resizing lane's interior columns) and the window's width /// on the two matching lane-resize curves. The window grows and shrinks at its RIGHT edge — /// width changes by ±step with `origin.x` and height held — so everything to the left, including /// this lane's own left edge and the drag's coordinate origin, stays put. private func tick(to newUnits: Int) { let delta = CGFloat(newUnits - units) * (standard + gap) withAnimation(Motion.laneResize(reduced: reducedMotion)) { units = newUnits } guard let window else { return } var frame = window.frame frame.size.width += delta // right-edge growth: origin and height unchanged // Reduce Motion's variant of the rubber-band feedback is the *instant* one // (10-accessibility.md): the window takes its new width outright, matching the unit count // that just did the same. Spelled as the absence of an animation group rather than as a // zero-duration one — see `Motion.laneResizeWindowDuration` for why a zero is not trusted. guard !reducedMotion else { window.setFrame(frame, display: true) return } NSAnimationContext.runAnimationGroup { context in context.duration = Motion.laneResizeWindowDuration context.timingFunction = Motion.laneResizeWindowTiming context.allowsImplicitAnimation = true window.setFrame(frame, display: true) } } /// The on-screen fit, from the window's headroom to its screen's visible frame — the hard stop /// 03-board-ui.md § Lane requires ("Growth hard-stops at the screen's visible frame"). Defers /// the arithmetic to `LaneLayoutMath.maxUnits`; with no window to measure, the current count is /// the honest answer (growth needs a window to grow). private static func fittingMaxUnits(currentUnits: Int, standard: CGFloat, gap: CGFloat, window: NSWindow?) -> Int { guard let window, let screen = window.screen ?? NSScreen.main else { return currentUnits } let headroom = screen.visibleFrame.maxX - window.frame.maxX return LaneLayoutMath.maxUnits(currentUnits: currentUnits, headroom: headroom, step: standard + gap) } }