Implement the selection model

The full pointer grammar of 04-interactions.md § Selection, stated once
as a pure function (SelectionGrammar) and reached through one store
funnel from every click surface — card face, lane header, lane empty
space, trash row:

- Plain click replaces and anchors; the lane surfaces (header and empty
  space alike, per the settled one-lane-click-behavior rule) toggle off
  on a sole-membership repeat.
- ⌘-click toggles within a homogeneous set; crossing any axis — cards
  XOR lanes, live XOR trashed, card entries XOR lane entries in the
  trash — degrades to a replace, so no click can produce a mixed
  selection.
- ⇧-click ranges from the anchor in the (side, kind) order list: flatten
  order for cards, lane order for lanes, the trash's deterministic sort
  filtered to kind — the pointer twin of the keyboard's boundary rule
  (the keyboard goes inert, the pointer skips).
- The rubber band (MarqueeSession/MarqueeMath) arms from lane empty
  space, the board backdrop, and the trash column; side frozen at the
  origin, trash bands homogeneous by topmost kind, frames self-registered
  in strip space, geometric begin guard, never animated.
- Fast plain double-click opens the card window (⌘↩'s pointer twin);
  Select All answers the standard Edit menu item via the responder
  chain, trash- and kind-respecting.
- The range anchor lives in TransientBoardState beside the selection and
  obeys the same reload vanish rule.

659 unit tests (28 new in SelectionGrammarTests).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 19:00:23 -04:00
parent 4b97ecf3f0
commit 2e229735b1
9 changed files with 1511 additions and 86 deletions
+33 -12
View File
@@ -93,10 +93,12 @@ final class TrashDragSession {
///
/// ### What is still a later card's
///
/// The **search filter** ("shown, it participates in the filter like any lane") and the full
/// **keyboard grammar** arrow walks into and out of the column, -ranges that stop at both the
/// liveness and the kind boundary, the rubber band, C copy-out are m5's. So is the drag's replica:
/// what ships here is the drop, with the target lane highlighted and the source row dimmed in place.
/// The **search filter** ("shown, it participates in the filter like any lane") and the **keyboard**
/// grammar arrow walks into and out of the column, -arrows that go inert at both the liveness and
/// the kind boundary, C copy-out are still owed. The *pointer* grammar is here: a row's click
/// runs the same `SelectionGrammar` the board does, and the column's empty space rubber-bands on the
/// trashed side. So is the drop, with the target lane highlighted and the source row dimmed in
/// place; the drag's replica is not.
struct TrashLaneView: View {
let store: BoardStore
@@ -114,6 +116,11 @@ struct TrashLaneView: View {
/// session has.
let dragSession: TrashDragSession
/// The strip's rubber band. The column's empty space is its third surface, on the **trashed**
/// side "a rubber-band stays on the side of the boundary it started on" (04-interactions.md
/// The trash) and every row registers its frame into the same registry.
let marquee: MarqueeControl
/// Reduce Motion, for the row transition below 10-accessibility.md names the trash
/// specifically ("and trash animations all get reduced variants").
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@@ -207,7 +214,8 @@ struct TrashLaneView: View {
entry: entry,
confirmations: confirmations,
drag: drag,
dragSession: dragSession
dragSession: dragSession,
registry: marquee.registry
)
// A row is a tombstoned item, so it arrives and leaves in the card's dialect
// a delete files one in, a Put Back or a purge takes one out, and both halves of
@@ -218,6 +226,11 @@ struct TrashLaneView: View {
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(6)
.contentShape(Rectangle())
// The band's trash-side surface. It only ever arms from the column's empty space a
// drag begun on a row is that row's drag-out and the begin guard makes that geometric
// rather than a matter of gesture priority (`MarqueeControl`).
.simultaneousGesture(marquee.gesture(side: .trashed))
}
}
}
@@ -262,6 +275,10 @@ private struct TrashEntryRow: View {
let drag: TrashRowDrag
let dragSession: TrashDragSession
/// Where the rubber band looks up what it is sweeping the card face's rule, on the trashed
/// side (`View.marqueeTarget`).
let registry: MarqueeTargetRegistry
private let cornerRadius: CGFloat = 6
var body: some View {
@@ -295,6 +312,7 @@ private struct TrashEntryRow: View {
.opacity(dragSession.isDragging(entry.id) ? 0.45 : 1)
.contentShape(Rectangle())
.gesture(rowGesture)
.marqueeTarget(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry)
.contextMenu { menu }
}
@@ -308,17 +326,20 @@ private struct TrashEntryRow: View {
store.selection.liveness == .trashed && store.selection.ids.contains(entry.id)
}
/// A click replaces the selection with this one row, on the **trashed** side.
/// A click selects this row on the **trashed** side, through the same grammar the board's
/// surfaces use plain replaces, toggles, ranges (`SelectionGrammar`).
///
/// Replace-only is what enforces both invariants at once here: a selection that is always exactly
/// one row can never mix live with tombstoned, nor card entries with lane entries
/// (04-interactions.md The trash). The extension grammar -click, -ranges that go inert at
/// both boundaries, the rubber band that stays on the side it started on is **m5's
/// selection-model card**, and nothing here should pre-empt it.
/// The row's kind travels with the click, and that is what keeps the trash's second homogeneity
/// axis true: a -click across the card/lane-entry boundary replaces rather than mixing, and a
/// -range walks only its own kind's rows (04-interactions.md The trash). No `togglesOnRepeat`
/// click-again-to-unselect is the lane's behaviour, not a row's.
///
/// **A double click is two of these and nothing more**: no editor, no card window, no timer.
private func select() {
store.select([entry.id], liveness: .trashed)
store.click(
SelectionTarget(id: entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed),
modifier: .current
)
}
// MARK: - Drag out