Files
lanework/Kanban/UI/Board/LaneResizeSession.swift
T
rzen 5ebf9fb90c Implement the motion language
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
2026-07-27 15:59:59 -04:00

209 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}