Files
lanework/Kanban/UI/Board/LaneLayoutMath.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

334 lines
19 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 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 two mechanisms meet at the screen's visible frame** (settled 2026-08-08, § Lane): once the
/// window can grow no further the drag stops moving it and degrades to the re-divide — the same
/// fixed width across one more unit per tick, siblings compressing, which is precisely what the
/// stepper does. `pinnedStripWidth`, `resizeStandard`, `resizeMaxUnits` and `resizeWindowDelta` are
/// that second regime; they are written so the boundary itself costs no pixels, since the re-divided
/// standard at the screen fit *is* the frozen standard.
///
/// 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 not a width range but the
/// strip's own capacity (`resizeMaxUnits`) — 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 or
/// a non-numeric/fractional value arrives here as `.missing` or `.malformed` and renders as
/// one unit, while an exact-integer reading below 1 (zero, negative) is no longer malformed at
/// all — it coerces to 1 at the read side (**ranges are part of the sensible reading**,
/// settled). Either way 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 never
/// produces 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 snapshot's, in order. There is no
/// liveness question left to ask — "Cards only. Lanes are never trashed" (03-board-ui.md §
/// Trash), so every lane the snapshot holds is a lane on screen consuming its units.
///
/// **`trashUnits` is the trash column's fixed one unit, and it is *only* consumed while shown**
/// (03-board-ui.md § Trash): the trash "spans a fixed one width unit — no `width` frontmatter,
/// and neither the stepper nor the edge drag applies — consumed only while shown". Passing it
/// here rather than fabricating a `Lane` for the trash is what keeps that true: there is no lane
/// value anywhere that a reorder, a resize or a width write could reach.
///
/// Show/Hide Trash is therefore a **re-divide trigger** and nothing more — the window is
/// untouched, and the existing width divides across one more (or one fewer) unit, exactly as a
/// lane add does (§ Layout — full visibility).
static func totalUnits(of lanes: [Lane], trashUnits: Int = 0) -> Int {
max(1, lanes.reduce(0) { $0 + displayUnits(of: $1) } + max(0, trashUnits))
}
// MARK: - Hit testing
/// Which lane sits under `x` in strip coordinates (0 at the strip's leading edge, outer margin
/// included) — an index into `unitCounts`, or `nil` when `x` is not over a lane at all.
///
/// **The gaps and the margins answer `nil` deliberately**, and so does everything past the last
/// lane — which is where the trash column sits. That is the whole of drag-to-restore's
/// "a drop anywhere else is a no-op" (03-board-ui.md § Trash): a drop that does not land
/// squarely on a live lane writes nothing rather than guessing at the nearest one.
///
/// Same analytic geometry as `DropSlotMath.laneExtents` — resting positions computed from
/// the unit counts, never measured frames (03-board-ui.md § Motion, "motion never feeds back
/// into logic").
static func laneIndex(atX x: CGFloat, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> Int? {
var left = gap
for (index, units) in unitCounts.enumerated() {
let width = slotWidth(units: units, standard: standard, gap: gap)
if x >= left, x < left + width { return index }
left += width + gap
}
return nil
}
// 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`, whose ceiling is the strip's capacity (`resizeMaxUnits`)
/// — there is no width cap to respect (03-board-ui.md § Lane: "1×, 2×, 3×, … — no cap"), and
/// past the screen fit the tick keeps firing, re-dividing rather than growing the window.
///
/// **`slotFor` rather than a standard**, because the standard is no longer one number for the
/// whole gesture: beyond the screen fit each further unit re-divides the pinned strip, so slot
/// `k` and slot `k + 1` are measured against *different* standards (`resizeStandard`). The
/// thresholds are unchanged in form — they simply ask the caller how wide each slot would be.
/// The band stays non-empty in the re-divide too: the slots still grow strictly with `k`, since
/// a unit added to a lane takes more from the strip than the re-divide gives back.
static func snappedUnits(
liveWidth: CGFloat,
currentUnits: Int,
slotFor: (Int) -> CGFloat,
gap: CGFloat,
allowedRange: ClosedRange<Int>,
reentry: CGFloat
) -> Int {
if currentUnits < allowedRange.upperBound, liveWidth > slotFor(currentUnits) + gap {
return currentUnits + 1
}
if currentUnits > allowedRange.lowerBound {
if liveWidth < slotFor(currentUnits - 1) + gap - reentry {
return currentUnits - 1
}
}
return currentUnits
}
/// The same snap where every slot is measured against one standard — the whole of the gesture
/// below the screen fit, and the shape the hit-testing and layout call sites think in.
static func snappedUnits(
liveWidth: CGFloat,
currentUnits: Int,
standard: CGFloat,
gap: CGFloat,
allowedRange: ClosedRange<Int>,
reentry: CGFloat
) -> Int {
snappedUnits(
liveWidth: liveWidth,
currentUnits: currentUnits,
slotFor: { slotWidth(units: $0, standard: standard, gap: gap) },
gap: gap,
allowedRange: allowedRange,
reentry: reentry)
}
/// 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: the rubber band "moves to the true end
/// of travel — the strip's own capacity"). 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 this is **not the tick's ceiling** — it is the boundary where
/// one mechanism hands over to the other (settled 2026-08-08, 03-board-ui.md § Lane). Up to it
/// the drag grows the window and the siblings keep their pixels; past it the window is spent and
/// each further tick re-divides instead (`resizeStandard`), which is how a lane keeps growing at
/// the siblings' expense on a full screen. A window with no headroom at all answers
/// `currentUnits`, so the very first tick is already a re-divide.
///
/// 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)
}
// MARK: - The drag past the screen: the re-divide
/// The strip's width once the window has grown as far as its screen allows — the width every
/// tick past the screen fit re-divides, since the window is pinned from there on.
///
/// The drag-start width is **derived, not measured**: the strip always fills exactly
/// (`standardWidth`), so a frozen standard `s` over `T` units means a strip of `s·T + g·(T + 1)`
/// and nothing else. Growth adds one `s + g` step per unit of on-screen headroom. Deriving it is
/// what makes the boundary free: a measured viewport width, carrying whatever half-point the
/// layout rounded to, would put a visible step where the two regimes meet.
static func pinnedStripWidth(
startUnits: Int,
startStandard: CGFloat,
startTotalUnits: Int,
fittingUnits: Int,
gap: CGFloat
) -> CGFloat {
let total = CGFloat(max(1, startTotalUnits))
let atStart = startStandard * total + gap * (total + 1)
return atStart + CGFloat(max(0, fittingUnits - startUnits)) * (startStandard + gap)
}
/// The strip's standard (1×) width part-way through a right-edge drag, for a dragged lane
/// spanning `units` — the whole of the drag's two regimes in one function (03-board-ui.md
/// § Lane, settled 2026-08-08).
///
/// At or below the screen fit the answer is the standard frozen at drag start: the window takes
/// the step, so the division is unchanged and every other lane keeps its exact pixels. Past the
/// fit the window is pinned, so each further unit the dragged lane claims is one more unit the
/// same `pinnedStripWidth` has to divide across — the siblings compress, which is the stepper's
/// mechanism arriving under the drag's fingers.
///
/// **The regimes meet with no pixel jump.** At `units == fittingUnits` the pinned width divided
/// across its own unit total is `startStandard` exactly, by the exact-fill identity
/// `pinnedStripWidth` is built from — the same reason the release hands back to the resting
/// layout without a flinch (`LaneResizeSession`).
static func resizeStandard(
forUnits units: Int,
startUnits: Int,
startStandard: CGFloat,
startTotalUnits: Int,
fittingUnits: Int,
gap: CGFloat
) -> CGFloat {
guard units > fittingUnits else { return startStandard }
return standardWidth(
stripWidth: pinnedStripWidth(
startUnits: startUnits, startStandard: startStandard,
startTotalUnits: startTotalUnits, fittingUnits: fittingUnits, gap: gap),
totalUnits: max(1, startTotalUnits) + (units - startUnits),
gap: gap)
}
/// The drag's ceiling — the true end of travel, which past the screen fit is a question about
/// the strip's capacity rather than about the screen.
///
/// The re-divide can always take one more unit, but not usefully forever: at some total
/// `standardWidth`'s 1pt floor engages and the strip stops filling exactly, which is the point
/// where the arithmetic stops describing anything on screen. That total is the largest `T`
/// satisfying `(W g·(T + 1)) / T ≥ 1`, i.e. `floor((W g) / (1 + g))`, and the dragged lane's
/// ceiling is that total read back through the units it contributed. This is where the rubber
/// band now sits (§ Lane: "the true end of travel — the strip's own capacity").
///
/// **Never below the screen fit**, and so never below the count the drag started from —
/// shrinking is always allowed. A degenerate strip (a non-finite standard, a gap at or below
/// 1pt, a capacity under one whole unit) falls back to the fit rather than inventing a bound.
static func resizeMaxUnits(
startUnits: Int,
startStandard: CGFloat,
startTotalUnits: Int,
fittingUnits: Int,
gap: CGFloat
) -> Int {
let fit = max(startUnits, fittingUnits)
guard startStandard.isFinite, gap.isFinite, gap > -1 else { return fit }
let width = pinnedStripWidth(
startUnits: startUnits, startStandard: startStandard,
startTotalUnits: startTotalUnits, fittingUnits: fittingUnits, gap: gap)
guard width.isFinite else { return fit }
let capacity = (width - gap) / (1 + gap)
guard capacity >= 1 else { return fit }
let total = Int(min(capacity, CGFloat(Int.max) / 2).rounded(.down))
return max(fit, startUnits + (total - max(1, startTotalUnits)))
}
/// How far the window moves on a tick from `oldUnits` to `newUnits`: one `step` per unit of that
/// change lying **inside** the screen fit, and nothing at all for the part beyond it, where the
/// re-divide has taken over and the window is pinned (03-board-ui.md § Lane).
///
/// A difference of clamped counts rather than a per-step walk, because a flick can cross the
/// boundary in one gesture event and the two sides must net out exactly: `F 1 → F + 2` is one
/// step (only the first unit was ever the window's to give), `F + 2 → F + 5` is none, and
/// `F + 2 → F 1` hands that one step back. Shrinking mirrors growing by construction.
static func resizeWindowDelta(
from oldUnits: Int,
to newUnits: Int,
fittingUnits: Int,
step: CGFloat
) -> CGFloat {
CGFloat(min(newUnits, fittingUnits) - min(oldUnits, fittingUnits)) * step
}
}