Files
lanework/Kanban/UI/Board/LaneResizeSession.swift
T
rzen 9a52b795b2 The drag learns the stepper's trick — past the screen's edge, lane growth re-divides instead of stopping
The right-edge drag's growth was capped at the screen's visible frame,
because each snap tick grows the window; on a window near the screen edge
that left a lane stuck at a tick or two of headroom. Settled 2026-08-08
(03-board-ui.md § Lane, superseding the pathfinder's hard stop): at the
screen the window pins and each further tick re-divides the fixed strip
width across one more unit — siblings compress, the stepper's mechanism
arriving under the drag's fingers. The regimes meet with no pixel jump
(the re-divided standard at the fit IS the frozen standard, by the
exact-fill identity), shrinking mirrors the way back, the rubber band
moves to the strip's own capacity, and a window with no headroom at all —
full screen included — re-divides from the very first snap.

New pure arithmetic in LaneLayoutMath (pinnedStripWidth, resizeStandard,
resizeMaxUnits, resizeWindowDelta, snappedUnits over per-count slots);
LaneResizeSession splits the tick across the regimes and derives its
standard from the live count; the handle and BoardView hand the session
the strip's whole divide. 2709 unit tests green (+11).

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 21:36:56 -04:00

435 lines
24 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 the window still has screen to grow into, 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.
///
/// **At the screen's visible frame that stops and the drag degrades to the re-divide** (settled
/// 2026-08-08, 03-board-ui.md § Lane). Past the fit the window is pinned and each further tick
/// divides the same width across one more unit — the stepper's mechanism, arriving under the drag's
/// fingers — so the siblings compress and the lane keeps growing at their expense. A window with no
/// headroom to begin with (already at the edge, or full screen) re-divides from the very first
/// snap, which is what makes the drag work at all on a maximised window. The boundary costs nothing:
/// the re-divided standard at the fit *is* the frozen standard (`LaneLayoutMath.resizeStandard`), so
/// `standard` below is a function of the live unit count rather than one number for the gesture.
///
/// 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. Past the fit only the first
/// of the two moves, and the window is left exactly where the screen ended.
///
/// ### 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 session-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 **as of the live unit count**. 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.
///
/// Within the screen fit this is the value frozen at drag start and nothing moves but the
/// window; past it the pinned strip re-divides, and this is how every *other* lane learns to
/// compress — `BoardView.standardWidth` hands the same number to all of them
/// (`LaneLayoutMath.resizeStandard`).
var standard: CGFloat { standard(forUnits: units) }
/// The strip's standard (1×) width frozen at drag start — regime A's answer whole, and regime
/// B's starting point.
@ObservationIgnored private var startStandard: 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 strip's whole unit total at drag start — every lane's display units plus the trash
/// column's fixed one while it is shown, exactly the total `BoardView` divides the resting
/// layout by. The re-divide needs it twice over: it is what the pinned strip width is derived
/// from, and what each extra unit the drag claims is added to.
@ObservationIgnored private var startTotalUnits: Int = 1
/// The largest unit count that still fits on screen — **the boundary between the two regimes**,
/// not a ceiling. Up to it a tick grows the window; past it a tick re-divides
/// (`LaneLayoutMath.maxUnits`, `fittingMaxUnits`).
@ObservationIgnored private var fittingUnits: Int = 1
/// The tick's actual ceiling: the strip's own capacity, the count past which the re-divide would
/// break the exact fill (`LaneLayoutMath.resizeMaxUnits`). Computed once at begin, since every
/// input to it is frozen there.
@ObservationIgnored private var ceilingUnits: Int = 1
/// The host window, resized by ±(standard + gap) on each tick that still has screen to move
/// into, and left alone on the re-divide's. 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 — its `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 strip's capacity. The floor is 1 because a lane
/// spans at least one unit; the ceiling is where the re-divide runs out of strip to divide, the
/// screen having stopped bounding it (03-board-ui.md § Lane, settled 2026-08-08).
private var allowedRange: ClosedRange<Int> {
1...max(1, ceilingUnits)
}
/// The standard the strip would be drawn at with the dragged lane spanning `units` — frozen
/// within the screen fit, re-divided past it. Every slot the gesture measures goes through here.
private func standard(forUnits units: Int) -> CGFloat {
LaneLayoutMath.resizeStandard(
forUnits: units, startUnits: startUnits, startStandard: startStandard,
startTotalUnits: startTotalUnits, fittingUnits: fittingUnits, gap: gap)
}
/// The dragged lane's rendered width at `units` — its own units against the standard *that* count
/// implies, which past the fit is not the standard the neighbouring counts imply.
private func slot(forUnits units: Int) -> CGFloat {
LaneLayoutMath.slotWidth(units: units, standard: standard(forUnits: units), gap: gap)
}
private var minSlot: CGFloat { slot(forUnits: allowedRange.lowerBound) }
private var maxSlot: CGFloat { slot(forUnits: allowedRange.upperBound) }
// 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.
///
/// `totalUnits` is the strip's whole divide — every lane plus the shown trash's fixed one, the
/// same total the resting layout uses. The re-divide past the screen fit is arithmetic *about
/// the strip*, not about the dragged lane, so it cannot be reconstructed from the lane alone.
func begin(
laneID: ItemID,
units: Int,
standard: CGFloat,
gap: CGFloat,
totalUnits: Int,
window: NSWindow?
) {
endHold()
self.laneID = laneID
self.startUnits = units
self.units = units
self.startStandard = standard
self.startTotalUnits = max(1, totalUnits)
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)
self.ceilingUnits = LaneLayoutMath.resizeMaxUnits(
startUnits: units, startStandard: standard, startTotalUnits: self.startTotalUnits,
fittingUnits: self.fittingUnits, gap: gap)
}
/// 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 }
liveWidth = LaneLayoutMath.resistedWidth(
proposed: slot(forUnits: startUnits) + translation,
minSlot: minSlot, maxSlot: maxSlot, resistance: resistance)
var target = units
while true {
let next = LaneLayoutMath.snappedUnits(
liveWidth: liveWidth, currentUnits: target,
slotFor: slot(forUnits:), 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 standard THAT count implies, which is the
// one still governing the strip (frozen within the fit, re-divided past it).
withAnimation(Motion.laneResize(reduced: reducedMotion)) {
liveWidth = slot(forUnits: committed)
}
}
/// **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.
///
/// **The window only takes the part of the step that fits on screen**
/// (`LaneLayoutMath.resizeWindowDelta`): past the fit the delta is zero, the unit count moves
/// alone, and what the eye sees is the siblings compressing into the width the window already
/// has. A zero delta skips the animation group outright rather than animating a frame to itself —
/// running the group for nothing would leave every re-divide tick paying for a window animation.
private func tick(to newUnits: Int) {
let delta = LaneLayoutMath.resizeWindowDelta(
from: units, to: newUnits, fittingUnits: fittingUnits, step: startStandard + gap)
withAnimation(Motion.laneResize(reduced: reducedMotion)) { units = newUnits }
guard let window, delta != 0 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 — **where window
/// growth ends and the re-divide begins** (03-board-ui.md § Lane: "at the screen's visible frame
/// the window stops and the drag degrades to the re-divide"), which is a handover and not a stop.
/// Defers the arithmetic to `LaneLayoutMath.maxUnits`; with no window to measure, the current
/// count is the honest answer — growth needs a window to grow, so every tick re-divides, exactly
/// as it does for a window already pinned to the screen's edge.
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)
}
}