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
+69 -2
View File
@@ -1314,11 +1314,78 @@ public final class BoardStore {
/// for "the lane that most recently held selection or a creation", and a *card* selection is
/// its lane holding selection just as much as the lane's own header click is so both are
/// noted here, and creation notes itself in `beginPlaceholder`.
public func select(_ ids: Set<ItemID>, liveness: Liveness) {
transient.select(ids, liveness: liveness)
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil) {
transient.select(ids, liveness: liveness, anchor: anchor)
transient.noteActiveLane(Self.lane(holding: ids, in: snapshot))
}
/// **Every pointer click on a selectable surface goes through here** card face, lane header,
/// lane empty space, trash row so 04-interactions.md § Selection's grammar is stated once
/// (`SelectionGrammar`) rather than four times with three of them subtly different.
///
/// The store's whole contribution is supplying the three inputs the grammar cannot see (the
/// snapshot, the selection, the anchor) and storing the outcome. An emptied outcome clears
/// rather than storing an empty set on a side, because that is what "nothing selected" is
/// everywhere else in the app.
///
/// - Parameter togglesOnRepeat: the lane's click-again-to-unselect see `SelectionGrammar`.
public func click(_ target: SelectionTarget, modifier: ClickModifier, togglesOnRepeat: Bool = false) {
let outcome = SelectionGrammar.click(
target,
modifier: modifier,
selection: selection,
anchor: transient.selectionAnchor,
snapshot: snapshot,
togglesOnRepeat: togglesOnRepeat
)
guard !outcome.selection.isEmpty else {
clearSelection()
return
}
// The anchor is passed through explicitly: `select`'s default would otherwise re-anchor a
// -range's single-member edge case on the target, and the grammar's answer is the one that
// knows whether this click was an origin or an extension.
select(outcome.selection.ids, liveness: outcome.selection.liveness, anchor: outcome.anchor)
}
/// **Select All** "all visible cards on the board" (04-interactions.md The map), with the
/// trash's own reading of the same command when the trash side is the one in play.
///
/// Two branches, and the trash's is the narrow one: it fires only when the column is **shown**,
/// the selection is on the trashed side, and it still names a row the exact conditions under
/// which "all" could mean anything but the board. It then selects every trash row **of the
/// selection's kind**, because 04 The trash's card-entries-XOR-lane-entries rule binds a
/// wholesale selection as tightly as it binds a click. A trashed selection naming nothing (a
/// foreign Put Back, a purge) falls through to the board rather than selecting the trash
/// wholesale on a guess.
///
/// The anchor **survives if it is still in the set** and is dropped otherwise: Select All is not
/// a click, so it names no new origin, but it has no business discarding one that is still
/// standing inside what it selected.
///
// m5-search: "filter-respecting, like every surface" (04 The map). The universe here is
// `SelectionGrammar`'s order lists, which is where the filter threads in one change, and both
// this command and every -range narrow together.
public func selectAll() {
if transient.isTrashVisible, selection.liveness == .trashed, !selection.isEmpty,
let kind = SelectionGrammar.kind(of: selection, in: snapshot) {
apply(Set(SelectionGrammar.trashEntries(of: kind, in: snapshot)), on: .trashed)
return
}
apply(Set(SelectionGrammar.liveCards(in: snapshot)), on: .live)
}
/// Select All's storage half: an empty universe clears rather than storing an empty set, and the
/// anchor is kept only while it is still inside what was selected.
private func apply(_ ids: Set<ItemID>, on side: Liveness) {
guard !ids.isEmpty else {
clearSelection()
return
}
let anchor = transient.selectionAnchor.flatMap { ids.contains($0) ? $0 : nil }
select(ids, liveness: side, anchor: anchor)
}
/// Selects nothing Escape's last step outward (04-interactions.md Grammar).
///
/// The last-active lane deliberately survives: it is a high-water mark of where the user has
+352
View File
@@ -0,0 +1,352 @@
import CoreGraphics
import Foundation
// MARK: - What a click names
/// Which of the board's two selectable levels an item is 04-interactions.md § Selection's
/// **cards XOR lanes** axis, named so the grammar can compare it rather than infer it per branch.
///
/// It is deliberately *not* stored anywhere: a selection is a set of ids and nothing else
/// (`ItemReferenceSet`), so the kind of a selection is always re-derived from the snapshot. Storing
/// it would be a second answer to a question the snapshot can always answer, and one that a reload
/// could falsify.
public enum SelectionKind: Sendable, Equatable {
case card
case lane
}
/// What a pointer click names: an item, its level, and the side of the live/trash boundary the
/// surface it was clicked on sits on.
///
/// The **side is the surface's, not the item's** a card face is always `.live` and a trash row is
/// always `.trashed`, because that is what the user clicked. A click on a surface whose item flipped
/// liveness a moment ago simply selects nothing the next reload will keep, which is the ordinary
/// vanish rule and not a case for this type to model.
public struct SelectionTarget: Sendable, Equatable {
public var id: ItemID
public var kind: SelectionKind
public var side: Liveness
public init(id: ItemID, kind: SelectionKind, side: Liveness) {
self.id = id
self.kind = kind
self.side = side
}
}
/// The modifier a click carried 04-interactions.md § Selection's three-way grammar ("click
/// selects; -click toggles; -click range-extends").
///
/// Three cases rather than an `OptionSet`, because the design gives and *no* combined meaning:
/// AppKit hands us both flags when both keys are down, and the grammar picks one. `ClickModifier`
/// is where that choice is made once (`ClickModifier.current`), so no call site re-decides it.
public enum ClickModifier: Sendable, Equatable {
case plain
case command
case shift
}
// MARK: - SelectionGrammar
/// 04-interactions.md § Selection's pointer grammar, plus § The trash's extensions to it, as a pure
/// function of the click, the current selection, the anchor, and the snapshot
/// (`SelectionGrammarTests`).
///
/// **Homogeneity is the invariant, and it is enforced here or nowhere.** The selection is
/// homogeneous on three axes at once cards XOR lanes (§ Selection), live XOR tombstoned, and
/// within the trash card entries XOR lane entries (§ The trash) and every one of them is a
/// property of what a *click* is allowed to produce. So no outcome below is ever mixed: a modifier
/// that would cross an axis degrades to a replace, which is the only answer that keeps the
/// invariant true without silently dropping what the user asked for.
///
/// **Pure, for `NewCardTarget`'s reason**: the branches become lines of test rather than gestures to
/// drive, and the four surfaces that clicks arrive on (card face, lane header, lane empty space,
/// trash row) share one answer instead of four near-copies of it.
public enum SelectionGrammar {
/// What a click leaves behind: the new selection, and the anchor a subsequent -click would
/// range from.
///
/// The anchor is carried *out* rather than mutated in place because it is not derivable from the
/// selection a -range replaces the whole set and deliberately leaves the anchor where it was,
/// so "the last plain or click" is a memory of a gesture and only the gesture can update it
/// (`TransientBoardState.selectionAnchor`).
public struct Outcome: Sendable, Equatable {
public var selection: ItemReferenceSet
public var anchor: ItemID?
public init(selection: ItemReferenceSet, anchor: ItemID?) {
self.selection = selection
self.anchor = anchor
}
/// Nothing selected and nothing to range from the toggle-off outcomes.
static let cleared = Outcome(selection: .empty, anchor: nil)
}
/// The grammar, one call.
///
/// - Parameters:
/// - target: what was clicked, with the surface's liveness side (see `SelectionTarget`).
/// - modifier: the effective modifier, already reduced to one of three (`ClickModifier`).
/// - selection: the board's current selection.
/// - anchor: the range origin `TransientBoardState.selectionAnchor`.
/// - snapshot: the board as it is now; every order list and every kind is derived from it.
/// - togglesOnRepeat: **the lane's click-again-to-unselect**, and only the lane's
/// (04-interactions.md § Selection: "single click selects the lane (click again to
/// unselect)", and the header "toggles like empty space (settled)"). A card face passes
/// `false`: Finder does not deselect a file by clicking it twice, and neither do we.
public static func click(
_ target: SelectionTarget,
modifier: ClickModifier,
selection: ItemReferenceSet,
anchor: ItemID?,
snapshot: BoardModel,
togglesOnRepeat: Bool = false
) -> Outcome {
switch modifier {
case .plain:
return plain(target, selection: selection, togglesOnRepeat: togglesOnRepeat)
case .command:
return command(target, selection: selection, snapshot: snapshot)
case .shift:
return shift(target, selection: selection, anchor: anchor, snapshot: snapshot)
}
}
// MARK: - The three branches
/// **Plain**: the selection becomes exactly what was clicked, and the click becomes the anchor.
///
/// The one exception is `togglesOnRepeat`, and it tests for *sole membership* rather than mere
/// containment: a lane click that lands inside a multi-lane selection narrows it to that lane
/// (replace), because "click again to unselect" is about the lane the user already had, not
/// about wiping a selection they built with .
private static func plain(
_ target: SelectionTarget,
selection: ItemReferenceSet,
togglesOnRepeat: Bool
) -> Outcome {
if togglesOnRepeat, selection.liveness == target.side, selection.ids == [target.id] {
return .cleared
}
return Outcome(
selection: ItemReferenceSet(ids: [target.id], liveness: target.side),
anchor: target.id
)
}
/// **-click toggles** but only *within* a homogeneous set. Crossing either axis (a card
/// clicked while lanes are selected, a trash row clicked while live cards are) is not a mixed
/// selection and not a refusal: it is a **replace**, the same outcome a plain click would give,
/// because the click unambiguously names a new set of one.
///
/// The current kind is derived from the snapshot rather than remembered (`kind(of:in:)`); a
/// selection whose members all name nothing the board renders counts as empty, so a -click
/// after a foreign delete starts a fresh set rather than extending a ghost.
private static func command(
_ target: SelectionTarget,
selection: ItemReferenceSet,
snapshot: BoardModel
) -> Outcome {
guard selection.liveness == target.side,
let current = kind(of: selection, in: snapshot),
current == target.kind
else {
return plain(target, selection: selection, togglesOnRepeat: false)
}
var ids = selection.ids
if ids.remove(target.id) == nil {
ids.insert(target.id)
} else if ids.isEmpty {
// The last member toggled out: nothing is selected, so there is nothing to range from.
return .cleared
}
return Outcome(
selection: ItemReferenceSet(ids: ids, liveness: target.side),
anchor: target.id
)
}
/// **-click range-extends from the anchor**, Finder-list style: the whole range replaces the
/// selection, and the anchor stays where it is so successive -clicks sweep out from the same
/// origin rather than walking it along.
///
/// The anchor is valid **iff both it and the target sit in the same order list** which folds
/// the nil anchor, the vanished anchor, and every axis crossing into one test, since a list is
/// exactly one (side, kind) pair. An invalid anchor makes the click a plain one, never a no-op:
/// the keyboard's -arrow goes inert at a boundary because its next step is ambiguous, while a
/// click names an unambiguous target and so always has something to do.
private static func shift(
_ target: SelectionTarget,
selection: ItemReferenceSet,
anchor: ItemID?,
snapshot: BoardModel
) -> Outcome {
let list = order(of: target.kind, on: target.side, in: snapshot)
guard let anchor,
let from = list.firstIndex(of: anchor),
let to = list.firstIndex(of: target.id)
else {
return plain(target, selection: selection, togglesOnRepeat: false)
}
let span = from <= to ? list[from...to] : list[to...from]
return Outcome(
selection: ItemReferenceSet(ids: Set(span), liveness: target.side),
anchor: anchor
)
}
// MARK: - The order lists
/// The list a -range walks for one (side, kind) pair **the single place a "what's on the
/// board, in what order" question is answered** for the pointer.
///
// m5-search: the filter "is the single source of truth for what's on the board ranges all
// read it" (04-interactions.md § Search). It threads in here and in `MarqueeTargetRegistry`'s
// membership, and nowhere else every range and every Select All is stated in terms of these
// four lists.
public static func order(of kind: SelectionKind, on side: Liveness, in snapshot: BoardModel) -> [ItemID] {
switch (side, kind) {
case (.live, .card): liveCards(in: snapshot)
case (.live, .lane): liveLanes(in: snapshot)
case (.trashed, _): trashEntries(of: kind, in: snapshot)
}
}
/// Live cards in **flatten order** "lane `order` first, then card `order` (a cross-lane
/// selection flattens left-to-right, top-to-bottom)", the multi-drag order the N target rule
/// and paste anchoring already share (04-interactions.md Drag and drop, The map).
///
/// The snapshot's arrays are already in display order (`Ranks.sortedForDisplay`), so the flatten
/// is one walk `NewCardTarget.resolve`'s walk, in list form.
public static func liveCards(in snapshot: BoardModel) -> [ItemID] {
var ids: [ItemID] = []
for lane in snapshot.lanes where !lane.isDeleted {
for card in lane.cards where !card.isDeleted {
ids.append(card.id)
}
}
return ids
}
/// Live lanes, left to right. Tombstoned lanes render nowhere on the board (03-board-ui.md §
/// Trash collapses each into one entry), so they are absent from the live lane order entirely.
public static func liveLanes(in snapshot: BoardModel) -> [ItemID] {
snapshot.lanes.filter { !$0.isDeleted }.map(\.id)
}
/// One kind of trash row, in the quasi-lane's own deterministic order (`TrashModel.entries`,
/// whose sort is "load-bearing for input arrow walks, -ranges, and the rubber band all read
/// it").
///
/// **Filtered to one kind, so a range skips what it cannot include.** Card and lane entries
/// interleave in one ordering, and "a selection never mixes card entries and lane entries"
/// (04-interactions.md The trash), so a -range between two card rows spans the sorted order
/// and collects only the card rows stepping over any lane row that sits between them. That is
/// the deliberate pointer twin of the keyboard's rule: a -arrow onto a lane entry is *inert*
/// because its next step is ambiguous, while a click names an unambiguous same-kind target and
/// so the range simply skips.
public static func trashEntries(of kind: SelectionKind, in snapshot: BoardModel) -> [ItemID] {
TrashModel.entries(of: snapshot)
.filter { $0.isLaneEntry == (kind == .lane) }
.map(\.id)
}
// MARK: - The current selection's kind
/// Which level the selection holds, or `nil` when it holds nothing the board renders on its own
/// side.
///
/// **Any member answers, because the set is homogeneous** but the walk is the snapshot's order
/// rather than the set's iteration order, so the answer is deterministic even for a set that
/// somehow was not. Members that name nothing are ignored, and a set of only such members reads
/// as empty: a selection the next reload will drop must not decide what a click does now.
///
/// The membership rules are exactly the order lists': on the live side an item counts when its
/// effective liveness is live, and on the trashed side only **rows** count a card under a
/// tombstoned lane has no row of its own (`TrashModel.entries`' absolute ancestor walk), so it
/// is nobody's kind.
public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? {
guard !selection.isEmpty else { return nil }
for lane in snapshot.lanes {
if Liveness(isDeleted: lane.isDeleted) == selection.liveness, selection.ids.contains(lane.id) {
return .lane
}
// A tombstoned lane subsumes its subtree on both sides: its cards render nowhere live
// and have no trash row of their own.
guard !lane.isDeleted else { continue }
for card in lane.cards
where Liveness(isDeleted: card.isDeleted) == selection.liveness && selection.ids.contains(card.id) {
return .card
}
}
return nil
}
}
// MARK: - The rubber band
/// One item the marquee can sweep: its identity, its level, its side, and where it is drawn.
///
/// The frame is in the board strip's coordinate space (`BoardView.stripSpace`) and is **registered
/// by the view that draws it** (`MarqueeTargetRegistry`) rather than computed here: the masonry's
/// geometry is the layout's own answer, and re-deriving it would be a second one.
public struct MarqueeTarget: Sendable, Equatable {
public var id: ItemID
public var kind: SelectionKind
public var side: Liveness
public var frame: CGRect
public init(id: ItemID, kind: SelectionKind, side: Liveness, frame: CGRect) {
self.id = id
self.kind = kind
self.side = side
self.frame = frame
}
}
/// What a rubber band selects, as a pure function of the band, the drawn frames, and the side the
/// band started on (`SelectionGrammarTests`).
///
/// The two rules it exists to state, both 04-interactions.md's:
///
/// - **The band stays on the side of the boundary it started on** ( The trash), which is why `side`
/// is a parameter rather than something derived from what the rect happens to touch: a band begun
/// on the board and dragged over the trash column selects live cards and nothing else.
/// - **The band never selects lanes** (§ Selection gives it to cards: "click-drag rubber-bands
/// across lanes" across them, not over them). Lanes are simply never registered as targets, and
/// the live branch filters to cards anyway so the rule holds even if one were.
public enum MarqueeMath {
/// The ids `rect` sweeps.
///
/// On the **trashed** side the band must additionally stay homogeneous by *kind*, because the
/// trash's two row kinds interleave in one column. The rule is topmost-wins: the kind of the
/// highest intersecting row decides, and rows of the other kind are dropped so a band pulled
/// down from a card row keeps collecting card rows and steps over the lane rows between them,
/// exactly as a -range does.
public static func selection(rect: CGRect, targets: [MarqueeTarget], side: Liveness) -> Set<ItemID> {
let hits = targets.filter { $0.side == side && rect.intersects($0.frame) }
guard !hits.isEmpty else { return [] }
switch side {
case .live:
return Set(hits.lazy.filter { $0.kind == .card }.map(\.id))
case .trashed:
guard let topmost = hits.min(by: isAbove) else { return [] }
return Set(hits.lazy.filter { $0.kind == topmost.kind }.map(\.id))
}
}
/// Which of two drawn rows is "higher" top edge, then leading edge, then identity.
///
/// Total rather than merely correct-for-a-column: two rows sharing a top edge must still order
/// the same way twice, or the topmost-kind rule would pick differently on identical input.
private static func isAbove(_ lhs: MarqueeTarget, _ rhs: MarqueeTarget) -> Bool {
if lhs.frame.minY != rhs.frame.minY { return lhs.frame.minY < rhs.frame.minY }
if lhs.frame.minX != rhs.frame.minX { return lhs.frame.minX < rhs.frame.minX }
return lhs.id.rawValue < rhs.id.rawValue
}
}
+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.