One named home for every curve and duration — the two-voice split 03 fixes (snappy structural: drag reflow 0.18, delete 0.25, lane resize 0.2; smooth content-reflow 0.28), the scale-and-fade appear/disappear transitions (cards 0.8, lanes 0.9), and a Reduce Motion variant on every accessor (animations go instant, transitions go crossfade). A post-migration grep holds the invariant: zero motion literals outside Motion.swift. The animate-vs-snap split lands where Lanework's one-way flow puts it: the store's reload seam. An app-mediated echo applies its snapshot inside the structural transaction; foreign and reconciling reloads — and every bracket-ending wholesale reload — assign bare, because live-reload is the board becoming what's on disk, not an event to perform. A window that merged foreign events into an app-mediated span animates, deliberately: the ratified merge rule makes it indistinguishable from a pure echo, and a test pins that reading so a future mixed case fails loudly. The lane-reorder reflow keys on the drop proposal alone (pointer tracking stays 1:1), the resize session freezes its Reduce Motion answer at drag start so the unit tick and the window resize can never disagree, and the m5 drag / m5 search / m7 undo voices are named seams waiting for their call sites. 13 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
209 lines
12 KiB
Swift
209 lines
12 KiB
Swift
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<Int> {
|
||
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)
|
||
}
|
||
}
|