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