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
334 lines
19 KiB
Swift
334 lines
19 KiB
Swift
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
|
||
}
|
||
}
|