Files
lanework/Kanban/UI/Board/LaneLayoutMath.swift
T
rzen 797d020d01 Materialize the trash — faces, menus, and grammar
Phase 3 finishes the pivot at the surface. One card face serves two
containers: CardFaceView extracted with a role — board or trash — so
stripe, tint, chip, selection stroke, cut dim, marquee registration,
and drag are shared by construction, the trash side differing only in
its absences: no Open, no rename, no Style, no file-hover highlight,
and a Delete that goes through the confirmation host. The column
rewrote around the lanes' own single-column masonry so drag reflow
reads as positional slides; chrome stays the hatched header, symbol,
and count — 11 gives Empty Trash to the File menu alone. Two real
grammar bugs die here: plain Backspace on a trash selection purged
without the confirmation the menu raises, and the context menu's
Delete resolved against the standing selection, so right-clicking a
trash card under a board selection silently did nothing — it now
stages the clicked set explicitly. Open, Rename, Style, and Empty
Trash validation became testable store seams; the column is one named
accessibility container of ordinary card elements. The tombstone era
is swept: deleteItem, restoreItem, stripTombstonedChildren — dead
since lane copies stopped nesting trash — the restore verb, the
unreachable put-back banner row, and every quasi-lane doc comment.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 18:18:39 -04:00

199 lines
12 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 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 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`, 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)
}
}