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
+61
View File
@@ -173,6 +173,67 @@ struct LaneDisplayUnitsTests {
#expect(LaneLayoutMath.displayUnits(of: wide) == 40)
#expect(LaneLayoutMath.totalUnits(of: [wide]) == 40)
}
@Test("The trash's one fixed unit joins the total only while it is shown")
func trashUnitJoinsTheDivision() throws {
// 03-board-ui.md § Trash: the quasi-lane "spans a fixed one width unit consumed only
// while shown", and Show/Hide Trash is therefore a re-divide trigger the window is never
// touched, the same width simply divides across one more unit.
let loaded = try lanes(widths: ["2", nil, "3"])
#expect(LaneLayoutMath.totalUnits(of: loaded) == 6)
#expect(LaneLayoutMath.totalUnits(of: loaded, trashUnits: 1) == 7)
// Shown on a zero-lane board it is the whole division, not a second unit alongside the
// empty board's floor of one.
#expect(LaneLayoutMath.totalUnits(of: [], trashUnits: 1) == 1)
}
}
// MARK: - Hit testing
/// `laneIndex(atX:)` the strip's half of drag-to-restore (03-board-ui.md § Trash). Same lane
/// geometry as the layout above: standard 100, gap 12, so with lanes of 1× and 2× units the slots
/// run [12, 112), [124, 336) and everything past 348 is the trash's side of the strip.
@Suite("LaneLayoutMath ▸ hit testing")
struct LaneHitTestingTests {
private let units = [1, 2, 1]
private func hit(_ x: CGFloat) -> Int? {
LaneLayoutMath.laneIndex(atX: x, unitCounts: units, standard: 100, gap: 12)
}
@Test("A point inside a lane's slot names that lane, width counted in units")
func insideALaneSlot() {
#expect(hit(12) == 0)
#expect(hit(111.9) == 0)
#expect(hit(124) == 1)
// The 2× lane swallows the interior gap it spans, so its slot runs 212pt, not 200.
#expect(hit(335.9) == 1)
#expect(hit(348) == 2)
#expect(hit(447.9) == 2)
}
@Test("The margins, the gaps, and everything past the last lane name nothing")
func gapsAndMarginsAreNotLanes() {
// The outer margin, before the first lane.
#expect(hit(0) == nil)
#expect(hit(11.9) == nil)
// The inter-lane gaps.
#expect(hit(112) == nil)
#expect(hit(123.9) == nil)
#expect(hit(336) == nil)
// Past the last lane which is exactly where the trash quasi-lane sits, so a row dropped
// back into the trash writes nothing.
#expect(hit(448) == nil)
#expect(hit(10_000) == nil)
// A negative x (the pointer dragged off the leading edge) is not a lane either.
#expect(hit(-5) == nil)
}
@Test("An empty strip has no lane under any point")
func emptyStrip() {
#expect(LaneLayoutMath.laneIndex(atX: 50, unitCounts: [], standard: 100, gap: 12) == nil)
}
}
// MARK: - The snap