Build the full-visibility lane layout

The lane strip replaces the placeholder board: window width divides
across the lanes' width units (no horizontal scroll, no minimum width,
degenerate compression accepted), a lane of n units flowing its cards
into n round-robin masonry columns via a measurement-cached Layout.
Width has two deliberately opposite controls, both landing here: the
right-edge drag (ported verbatim from the pathfinder's ColumnResize)
freezes the 1x standard at drag start, snaps between integer widths
with the asymmetric shadow-leads tick and 10pt re-entry, grows the
window one standard width per snap so siblings keep their exact
pixels, and rubber-bands at the screen's visible frame — uncapped
otherwise; the Increase/Decrease Lane Width items (new Board menu,
Cmd-Opt-arrows) are the stepper's keyboard face and re-divide the
existing window width instead, never touching the window. Width
changes write through the new .resize WriteOperation ("Couldn't
resize…" in the banner vocabulary, which grows with the surfaces by
design); malformed width values render as one unit and stay untouched
on disk. 27 new tests port the pathfinder's resize-math suite onto
the uncapped range and pin the write path's fidelity.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 08:16:14 -04:00
parent fccdf56cf4
commit ff3ba298f0
15 changed files with 1427 additions and 56 deletions
+164
View File
@@ -0,0 +1,164 @@
import CoreGraphics
/// The board strip's geometry, as pure arithmetic no view, no window, no state
/// (`LaneLayoutMathTests`).
///
/// Two rules from 03-board-ui.md meet here, and they are deliberately *different* mechanisms
/// sharing one set of numbers:
///
/// - **Full visibility** (§ Layout full visibility): the window's width divides across the lanes'
/// width units, so `standardWidth` is the whole of the resting layout. There is no horizontal
/// scroll and no minimum lane width to honour enough units in a small window compress every
/// lane, and that is accepted rather than floored.
/// - **The right-edge drag** (§ Lane): a snap between whole unit counts that grows or shrinks the
/// *window* by one standard width per tick, so the other lanes keep their exact pixels.
/// `slotWidth`, `snappedUnits`, `resistedWidth` and `maxUnits` are that interaction's arithmetic,
/// ported from the pathfinder's proven `ColumnResizeMath` (its reasoning is reproduced below,
/// since the behaviour is what was proven, not the code).
///
/// The one behavioural difference from the pathfinder: **Lanework has no upper width cap.** A lane
/// spans any whole number of units 1, so `allowedRange`'s ceiling is only ever the on-screen fit
/// (`maxUnits`) there is no `Column.widthRange` equivalent to fold in, and shrinking is always
/// allowed.
enum LaneLayoutMath {
// MARK: - The resting layout
/// The 1× (one unit) lane width for a strip `stripWidth` points wide laying out `totalUnits`
/// whole units with `gap` between lanes **and `gap` again outside the first and the last**
/// hence `totalUnits + 1` gaps: the `totalUnits - 1` interior ones plus the strip's two outer
/// margins. The strip therefore always exactly fills, which is what "every lane is always on
/// screen" means arithmetically (03-board-ui.md § Layout full visibility).
///
/// **Floored at 1pt, and at nothing else.** The design is explicit that the degenerate case is
/// accepted, not floored: a minimum lane width would reintroduce horizontal scroll, which was
/// considered in the pathfinder and deliberately rejected. The 1pt floor exists only so a frame
/// is never zero or negative the pathological input (a strip narrower than its own gaps) must
/// not produce a negative size for SwiftUI to complain about.
static func standardWidth(stripWidth: CGFloat, totalUnits: Int, gap: CGFloat) -> CGFloat {
let count = CGFloat(max(1, totalUnits))
return max(1, (stripWidth - gap * (count + 1)) / count)
}
/// The rendered width of a `units`-unit lane: `units` standard widths plus the `units - 1`
/// interior gaps it swallows. `BoardView`'s per-lane frame is this exact expression, so a
/// snapped slot and a committed lane are the same pixels.
static func slotWidth(units: Int, standard: CGFloat, gap: CGFloat) -> CGFloat {
standard * CGFloat(units) + gap * CGFloat(units - 1)
}
/// The whole units a lane spans on screen: its `width` when that read as a valid integer, 1
/// otherwise.
///
/// `Lane.width` is a **lenient** field (01-storage-format.md § Frontmatter): a missing key, a
/// non-numeric value, a fraction, a zero or a negative all arrive here as `.missing` or
/// `.malformed` and render as one unit the bytes on disk are left exactly as the author wrote
/// them until the user actually changes the width, at which point the Writer replaces them with
/// an integer (`BoardStore.setLaneWidth`). The `max(1,)` is belt over braces: the read side
/// already refuses anything below 1, and this function is the single place the rest of the UI
/// asks "how many units does this lane span".
static func displayUnits(of lane: Lane) -> Int {
max(1, lane.width.value ?? 1)
}
/// The unit total a strip of `lanes` divides across the sum of their display units, never
/// below 1 so `standardWidth` cannot be handed a zero divisor for an empty board.
///
/// The caller decides *which* lanes: the strip passes the live ones in snapshot order, because
/// a tombstoned lane renders nowhere on the board (03-board-ui.md § Trash collapses it to a
/// single trash entry) and so consumes none of the window's width. When the trash quasi-lane
/// arrives it joins this total as one fixed unit "Show/Hide Trash is a re-divide trigger".
static func totalUnits(of lanes: [Lane]) -> Int {
max(1, lanes.reduce(0) { $0 + displayUnits(of: $1) })
}
// MARK: - The drag's snap
/// The snapped unit count after a live-width change: `currentUnits` unless the live width has
/// moved far enough into "shadow leads" territory for an adjacent slot, in which case it ticks
/// by exactly one.
///
/// This is an ASYMMETRIC snap, not a midpoint-±-band around a boundary: the shadow (the snapped
/// count, which sizes the visible slot) leads the live edge going up and deliberately lags it
/// coming back down.
///
/// Tick UP fires the instant the live edge clears the FAR side of the gap that trails slot
/// `k` `liveWidth > slotWidth(k) + gap` which is exactly where the next lane's content
/// would start. The moment the cursor has eaten the whole gap, the bigger slot is already
/// the honest read of what is under it, so the shadow jumps there right away: it never
/// lags, and the live edge can only ever overhang the shadow by at most one gap width,
/// transiently, in the instant just before a tick.
/// Tick DOWN fires only once the live edge has retreated `reentry` (10pt, fixed) back INTO
/// that same gap `liveWidth < slotWidth(k - 1) + gap - reentry` rather than at the
/// mirror image of the tick-up threshold. Shrinking back the instant the edge re-enters the
/// gap it just cleared would flap the window on the smallest jitter right at the crossing;
/// requiring a real 10pt of retreat means the cursor has to mean it. (03-board-ui.md § Lane
/// names exactly this: "shadow snaps at the inter-column gap with 10pt release hysteresis".)
///
/// `reentry` is the ONLY hysteresis in this design it exists to give the tick-down threshold
/// room, not to make the two thresholds symmetric. Stability follows from the two thresholds
/// never meeting: for shadow `k` the hold band is `(slotWidth(k - 1) + gap - reentry,
/// slotWidth(k) + gap]`, which stays non-empty as long as `standard` is many times larger than
/// 10pt (true of every lane width a real window produces), so calling this on every drag event
/// never oscillates.
///
/// Ticks are capped to `allowedRange`, which in Lanework folds in **only** the on-screen fit
/// (`maxUnits`) there is no width cap to respect (03-board-ui.md § Lane: "1×, 2×, 3×, no
/// cap"), and the uncapped widths beyond the screen's capacity are the stepper's business, not
/// the drag's.
static func snappedUnits(
liveWidth: CGFloat,
currentUnits: Int,
standard: CGFloat,
gap: CGFloat,
allowedRange: ClosedRange<Int>,
reentry: CGFloat
) -> Int {
let currentSlot = slotWidth(units: currentUnits, standard: standard, gap: gap)
if currentUnits < allowedRange.upperBound, liveWidth > currentSlot + gap {
return currentUnits + 1
}
if currentUnits > allowedRange.lowerBound {
let previousSlot = slotWidth(units: currentUnits - 1, standard: standard, gap: gap)
if liveWidth < previousSlot + gap - reentry {
return currentUnits - 1
}
}
return currentUnits
}
/// The live width rubber-banded to stay near the allowed slot range: inside `[minSlot,
/// maxSlot]` the proposed width passes through untouched; beyond either end only `resistance`
/// (0.25) of the overshoot is applied, so the edge visibly resists but still gives, signalling
/// the bound without a hard stop (03-board-ui.md § Lane: "Growth hard-stops at the screen's
/// visible frame, with rubber-band feedback"). The snap tick never follows the width past the
/// bound (see `snappedUnits`' clamp), so this is purely cosmetic give.
static func resistedWidth(
proposed: CGFloat,
minSlot: CGFloat,
maxSlot: CGFloat,
resistance: CGFloat
) -> CGFloat {
if proposed < minSlot { return minSlot - (minSlot - proposed) * resistance }
if proposed > maxSlot { return maxSlot + (proposed - maxSlot) * resistance }
return proposed
}
/// The largest unit count that fits on screen: `currentUnits` plus as many whole `step`
/// (= standard + gap) growths as the window has room to expand into before its right edge would
/// pass the screen's visible frame. **Never less than `currentUnits`** shrinking is always
/// allowed regardless of screen room, including from a window already hanging off the edge
/// (negative headroom).
///
/// Unlike the pathfinder's twin there is no width ceiling to `min` against: the drag's only
/// bound is the screen, because it is the mechanism that grows the window. Larger widths are
/// reachable through the stepper, which re-divides instead (03-board-ui.md § Lane).
///
/// Pure so it can be unit-tested; the session computes `headroom` from the live window and its
/// screen and defers the arithmetic here.
static func maxUnits(currentUnits: Int, headroom: CGFloat, step: CGFloat) -> Int {
guard step > 0, headroom.isFinite else { return currentUnits }
let extra = Int(floor(max(0, min(headroom, CGFloat(Int.max) / 2)) / step))
return max(currentUnits, currentUnits + extra)
}
}