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
+48 -7
View File
@@ -278,6 +278,23 @@ public final class TransientBoardState {
/// set wholesale by the gesture that owns them.
public private(set) var selection: ItemReferenceSet = .empty
/// **Where a -click ranges from** the item the last plain or click named
/// (04-interactions.md § Selection, "-click range-extends").
///
/// A *memory of a gesture*, like `lastActiveLaneID` and for the same reason: it is not
/// derivable from the selection. A range replaces the whole set and deliberately leaves the
/// anchor put, so successive -clicks sweep out from one origin instead of walking it along
/// which means nothing in the set marks it.
///
/// **A marquee and every wholesale selection pass no anchor deliberately.** There is no click
/// behind them to range from, so a -click afterwards behaves as a plain click the same
/// degrade `SelectionGrammar` gives a vanished anchor, reached honestly rather than by inventing
/// an origin the user never named.
///
/// It lives on the selection's side by construction, so `resolve(against:)` re-grounds it with
/// the same rule every other item reference gets.
public private(set) var selectionAnchor: ItemID?
/// The items a drag is carrying **empty when no drag is in flight**, which is what "no drag"
/// means here rather than a separate flag.
///
@@ -374,19 +391,27 @@ public final class TransientBoardState {
// MARK: - Selection
/// Replaces the selection.
/// Replaces the selection, and sets the anchor a subsequent -click ranges from.
///
/// Minimal on purpose the selection's real grammar (extend, range, successor-on-delete) lands
/// with the board UI. Deliberately **not** filtered against the snapshot: a caller selects what
/// it is rendering, and `resolve(against:)` on the next reload is what keeps the set honest over
/// time.
public func select(_ ids: Set<ItemID>, liveness: Liveness) {
/// The grammar itself is `SelectionGrammar`'s pure, testable, and the one funnel every click
/// surface goes through (`BoardStore.click`). This is the storage half, and its only rule of its
/// own is the **anchor default**: `nil` with a sole member anchors on that member, `nil` with
/// any other count anchors on nothing. That makes the two callers that pass nothing behave
/// exactly as they should a one-item selection made by any route is a legitimate range origin,
/// while a marquee or a Select All names no click and so leaves a -click acting plain.
///
/// Deliberately **not** filtered against the snapshot: a caller selects what it is rendering, and
/// `resolve(against:)` on the next reload is what keeps the set honest over time.
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil) {
selection = ItemReferenceSet(ids: ids, liveness: liveness)
selectionAnchor = anchor ?? (ids.count == 1 ? ids.first : nil)
}
/// Selects nothing Escape's last step outward (04-interactions.md Grammar).
/// Selects nothing Escape's last step outward (04-interactions.md Grammar). The anchor goes
/// with it: an empty selection has no origin to range from.
public func clearSelection() {
selection = .empty
selectionAnchor = nil
}
/// Records that `laneID` is where the user is working a lane selected, or created into.
@@ -540,6 +565,12 @@ public final class TransientBoardState {
/// current universe does not have. It is not an `ItemReferenceSet` only because it is one
/// optional rather than a set on a side the rule it obeys is the same one.
///
/// **`selectionAnchor` obeys it too**, on the *selection's* side: a range origin that vanished
/// or flipped liveness is gone, and the next -click acts as a plain click rather than ranging
/// from somewhere that renders nowhere. It deliberately does **not** have to stay *in* the
/// selection a -click that toggles the anchor's neighbour out leaves the anchor selected and
/// a range from it is still exactly what the user asked for.
///
/// **The style editor tracks its target set live** (03-board-ui.md § Styling Controls,
/// settled): a member that vanishes or flips liveness leaves the set so the editor's
/// mixed-state display recomputes off the survivors and a set emptied by a foreign reload
@@ -566,6 +597,16 @@ public final class TransientBoardState {
if let lane = lastActiveLaneID, !live.contains(lane) {
lastActiveLaneID = nil
}
if let anchor = selectionAnchor {
// The selection's side, because that is the side the anchor lives on by construction
// every route that sets it sets the selection to the same side in the same call. A
// vanished or liveness-flipped anchor is gone, which is the rule every item reference
// here gets: "a flip is a vanish from its side of the boundary".
let universe = selection.liveness == .live
? live
: ItemReferenceSet.idUniverse(of: snapshot, on: selection.liveness)
if !universe.contains(anchor) { selectionAnchor = nil }
}
}
/// The placeholder's two discard rules, as a pure function of the placeholder and the snapshot.