Files
lanework/Kanban/UI/Board/LaneResizeSession.swift
T
rzen 409f430813 The lane-resize release gets its own hold — the strip stops two-stepping through the stale snapshot
LaneWidthHold: after the release the session's frozen standard and the
WRITTEN unit count keep governing the strip until a landed snapshot
carries that width back — dividing the already-grown window by the
stale unit total in between was the visible two-step. A deliberately
separate type from CommittedHold: that hold stands in for an
arrangement and any landing retires it; this one stands in for a value
and only a landing that carries it will do. Echo reads through
LaneLayoutMath.displayUnits so the width-1 key-removal case compares
right; the watch rides landedReloads so a value-equal echo still
answers; width writes now return whether bytes reached disk so a
refused or no-op write dissolves the hold instead of arming it; the
1500ms timeout family covers the rest.

Drag-perf card 231e3693.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 19:33:33 -04:00

370 lines
20 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
// MARK: - The release hold
/// The hand-off condition for **the lane-resize release hold**, as a value so the state machine is
/// testable without a filesystem.
///
/// The release *writes* the lane's new width, and that write rides the one-way flow — Writer → disk
/// → watcher → reload (02-architecture.md § Layering). Between the release and that echo the
/// snapshot still says the OLD unit count while the window has already grown, so a session that
/// cleared at release would hand the strip to `LaneLayoutMath.standardWidth` dividing the *new*
/// window width by the *stale* unit total: every lane takes a proportionally wrong width for a beat
/// — the resized one snapping back toward its old share — and the board then jumps a second time
/// when the echo lands. This is the drag's committed-overlay hold (DRAG-REORDER.md § The
/// committed-overlay hold) applied to the resize, which lives outside the drag machinery and so
/// needs its own: the frozen standard and the written unit count keep governing until the snapshot
/// carries the write.
///
/// A deliberately separate type from `CommittedHold` rather than a reuse of it. The two share only
/// their shape: that hold's subject is a *rearrangement*, retired by any snapshot landing on the
/// board, and this one's is a *value*, which can only be retired by a snapshot that actually carries
/// it — an unrelated reload landing first must NOT release this hold, or the two-step is back.
struct LaneWidthHold: Equatable, Sendable {
/// The lane the release wrote.
var laneID: ItemID
/// The unit count it wrote — what the landed snapshot has to agree with.
var units: Int
/// How long the hold may stand with no echo arriving. `CommittedHold.timeout`'s figure and its
/// reasoning: comfortably longer than a write plus a watcher round trip, short enough that a
/// write nobody echoes does not leave the board drawing a width it never got.
static let timeout: Duration = .milliseconds(1500)
/// Whether a snapshot holding `lanes` retires this hold.
///
/// The width is read exactly as the board reads it (`LaneLayoutMath.displayUnits`), which is the
/// whole of the one-unit case: **a width landing on 1 removes the `width` key**
/// (`BoardStore.writeLaneWidths`, the remove-at-default family), so the echo carrying that write
/// has no key to compare against — only the display reading sees the 1 that was asked for.
///
/// A lane no longer in the snapshot retires it too: it was deleted or moved away under the
/// gesture, no echo naming it is ever coming, and the snapshot is the authority.
func isRetired(by lanes: [Lane]) -> Bool {
guard let lane = lanes.first(where: { $0.id == laneID }) else { return true }
return LaneLayoutMath.displayUnits(of: lane) == units
}
}
// MARK: - The session
/// 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.
///
/// ### Three phases, not two
///
/// *Idle* (`laneID == nil`), *dragging* (`isDragging`), and — after the release — *holding*, which
/// is the same governance standing over a write that has not echoed back yet (`LaneWidthHold`). The
/// layout reads one question throughout, `governs(_:)`, so the strip cannot tell the last two
/// apart; only the handle's gesture does, since a hold is not something a mouse is still driving.
@MainActor
@Observable
final class LaneResizeSession {
/// The lane this session governs — being dragged, or holding its written width until the echo;
/// `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 release hold, or `nil` while the drag is still in flight (or the session idle).
/// See `LaneWidthHold`.
private(set) var hold: LaneWidthHold?
/// 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.
///
/// It stops ticking at the release and becomes **the written count** — the number the hold is
/// waiting for the snapshot to agree with, and the one the strip lays the lane out at until it
/// does.
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
@ObservationIgnored private var holdTimeoutTask: Task<Void, Never>?
/// How long this session's hold may stand with no echo arriving — `LaneWidthHold.timeout`, and a
/// `var` for one reason only: the dissolve is a `Task` sleeping on the main actor, and a test
/// that had to wait the real figure out would be 1.5 s of wall clock in the suite
/// (`LaneResizeHoldTests`). Nothing in the app writes it.
@ObservationIgnored var holdTimeout: Duration = LaneWidthHold.timeout
/// Whether the session governs the strip's layout at all — the frozen standard is in force for a
/// drag and for the hold that follows it alike.
var isActive: Bool { laneID != nil }
/// Whether a gesture is still driving it. False during the hold, which no mouse is holding.
var isDragging: Bool { laneID != nil && hold == nil }
/// Whether the release has **settled**: the width is written and the layout it produced is being
/// held until the echo reload lands (`LaneWidthHold`).
var isSettled: Bool { hold != nil }
/// Whether this session — dragging or holding — is the authority on `id`'s width.
func governs(_ id: ItemID) -> Bool { laneID == id }
/// Whether `id` is the lane a gesture is currently dragging.
func isDragging(_ id: ItemID) -> Bool { laneID == id && hold == nil }
/// The unit count `lane` spans **on screen right now**: this session's while it governs that
/// lane — the live snapped count mid-drag, the written count mid-hold — and the snapshot's
/// otherwise.
///
/// The mid-hold answer is the point: for that stretch the snapshot's `width` is the *old* one,
/// and every reader that took it at face value would draw the pre-drag layout.
func displayUnits(of lane: Lane) -> Int {
governs(lane.id) ? units : LaneLayoutMath.displayUnits(of: lane)
}
/// 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.
///
/// **A standing hold is superseded, not waited out** — a second drag is the user's newer answer
/// about the same strip, and its own release will arm the hold that matters. `units` is the
/// anchor the caller reads off the screen (`displayUnits(of:)`), so a drag begun mid-hold starts
/// from the width that is showing rather than from the stale snapshot's.
func begin(laneID: ItemID, units: Int, standard: CGFloat, gap: CGFloat, window: NSWindow?) {
endHold()
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 isDragging 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 enters the **release hold**.
///
/// 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). Clearing the
/// session here — as this used to — hands the strip back to the viewport-derived standard one
/// whole round trip too early: the window is already at its new width but the snapshot still
/// carries the old unit total, so the division is wrong for every lane and the board visibly
/// two-steps, once into that stale arrangement and again when the echo lands. So the session
/// keeps governing (`LaneWidthHold`), and all that happens here is the last sub-tick of
/// overflow/underfill animating away against the width the release actually wrote.
///
/// `commit` answers **whether bytes reached disk** (`BoardStore.setLaneWidth`). A `false` — a
/// refusal the banner is already explaining, or a drag that ended on the width it started from —
/// means no echo is ever coming, so the hold is never armed and the snapshot takes the layout
/// back immediately: the lane returns to its pre-drag width, which is the truth.
func end(commit: (ItemID, Int) -> Bool) {
guard let laneID, hold == nil else { return }
let committed = units
guard commit(laneID, committed) else { return dissolve() }
let hold = LaneWidthHold(laneID: laneID, units: committed)
self.hold = hold
let timeout = holdTimeout
holdTimeoutTask?.cancel()
holdTimeoutTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: timeout)
guard !Task.isCancelled, let self else { return }
self.expire(hold)
}
// The settle: the live width, which has been tracking the cursor, comes to rest on the slot
// the written count names — measured against the FROZEN standard, the one still governing.
withAnimation(Motion.laneResize(reduced: reducedMotion)) {
liveWidth = LaneLayoutMath.slotWidth(units: committed, standard: standard, gap: gap)
}
}
/// **The hand-off**: a walk landed, so a hold whose width the snapshot now agrees with dissolves
/// and the snapshot is the authority again.
///
/// Called from `BoardView`'s watch on `BoardStore.landedReloads` — *every* landed walk, not just
/// the ones that assigned. The condition is "the width the write named is what the current
/// snapshot says", never "a counter moved": a reload whose tree came back value-equal skips the
/// assignment and does not bump `snapshotGeneration` (02-architecture.md § Live-reload
/// resilience), and a hold keyed to that bump would sit out its whole deadline over a snapshot
/// that already agreed with it. Retiring on the *value* is right whichever counter moved.
func handOff(against lanes: [Lane]) {
guard let hold, hold.isRetired(by: lanes) else { return }
dissolve()
}
/// The deadline's own body, spelled as a method rather than inlined in the `Task` so the
/// dissolve can be pinned directly as well as through the clock (`LaneResizeHoldTests`).
///
/// A hold that has already handed off — or been superseded by a second drag's — is not this
/// one's to end.
func expire(_ hold: LaneWidthHold) {
guard self.hold == hold else { return }
dissolve()
}
/// Governance ends: the strip goes back to the viewport-derived standard and the snapshot's unit
/// counts. Animated on the resize curve because the two are only *meant* to be the same pixels —
/// on the echo path they are, and the animation shows nothing; on the timeout path they are not,
/// and what moves is the resize un-happening.
private func dissolve() {
endHold()
withAnimation(Motion.laneResize(reduced: reducedMotion)) {
laneID = nil
liveWidth = 0
}
}
private func endHold() {
hold = nil
holdTimeoutTask?.cancel()
holdTimeoutTask = nil
}
// 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)
}
}