Build the trash quasi-lane

Deletion becomes a two-stage, Finder-style story. File > Delete and
plain Backspace tombstone the live selection; View > Show Trash (no
chord — Shift-Cmd-T stays with the system's tab bar) reveals the
quasi-lane: trailing, one fixed width unit consumed only while shown,
hatched dimmed header, count badge, no new-card button, exempt from
resize and reorder alike. Its contents are a pure view over the
snapshot — the deterministic sort (deleted newest first, folder-name
ties, unparseable stamps oldest) interleaves card rows with a
tombstoned lane's single entry, whose count names what Put Back
returns; the ancestor walk is absolute, so an own-flag card beneath a
tombstoned lane has no row and recovery is deliberately two steps.
Put Back twins Delete on Cmd-Backspace with validation enabling
exactly one; restore fidelity is byte-perfect because nothing ever
moved. Delete Immediately confirms exactly where loss is real (every
board is mode-none today; the predicate names the git carve-out for
m7), Empty Trash always confirms with the true whole-board count,
and dragging a tombstoned card onto a live lane restores it there —
positional drops and cross-board locality arrive with m5's machinery.
The banner's delete phrasing drops "move to the trash" per the naming
constraint: board deletion says Delete, "Move to Trash" stays
reserved for the system Trash. 47 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 15:38:34 -04:00
parent aa6aaf2a11
commit 2090d742b9
14 changed files with 2428 additions and 18 deletions
+36 -4
View File
@@ -68,10 +68,42 @@ enum LaneLayoutMath {
///
/// 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) })
/// single trash entry) and so consumes none of the window's width.
///
/// **`trashUnits` is the quasi-lane'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 quasi-lane 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 `LaneReorderMath.proposedIndex` 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