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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user