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
+59 -24
View File
@@ -28,7 +28,8 @@ struct LaneHeaderDrag {
/// Leading SF Symbol from `icon` lenient, an unknown name renders the `square.stack` default
/// (`ItemSymbol`) then the title or its quiet "Untitled" placeholder, a quiet secondary
/// card-count badge, and a trailing quiet new-card button. **The whole bar is the drag surface**:
/// a plain click selects the lane, movement past a small threshold begins a reorder
/// a click selects the lane toggling off on a repeat, exactly as empty space does
/// (04-interactions.md § Selection, settled) and movement past a small threshold begins a reorder
/// (`LaneReorderSession`). The one thing carved out of the drag region is the button, which sits in
/// an overlay outside the gesture so a click on it can never be read as the beginning of a drag.
///
@@ -64,6 +65,10 @@ struct LaneView: View {
let headerDrag: LaneHeaderDrag
/// The strip's rubber band: the lane's empty space is one of its three surfaces, and every card
/// face registers its frame into the same registry (`MarqueeControl`).
let marquee: MarqueeControl
/// Opens a card's window 's second half (04-interactions.md Grammar, "commits and opens
/// the card window"). Supplied by the strip, which is supplied by the host: a lane has no
/// business knowing about `WindowGroup` keys.
@@ -289,10 +294,16 @@ struct LaneView: View {
if reorder.isDragging(lane.id) {
headerDrag.commit()
} else {
// A plain click on the header always selects unlike lane empty space, it does
// not toggle off. 04 gives the click-again-to-unselect behaviour to empty space
// only, and a full lane has no empty space to reach for.
store.select([lane.id], liveness: .live)
// **The header toggles like empty space** (04-interactions.md § Selection,
// settled): "a click on the already-selected lane's header unselects, one
// lane-click behavior everywhere, so a full lane keeps a pointer path out of
// selection". Hence the same `togglesOnRepeat` the empty space passes the two
// surfaces differ only in where they are.
store.click(
SelectionTarget(id: lane.id, kind: .lane, side: .live),
modifier: .current,
togglesOnRepeat: true
)
}
}
}
@@ -312,7 +323,12 @@ struct LaneView: View {
Group {
switch slot {
case let .card(card):
CardFaceView(store: store, card: card, openCard: openCard)
CardFaceView(
store: store,
card: card,
registry: marquee.registry,
openCard: openCard
)
case .placeholder:
NewCardStubView(store: store, openCard: openCard)
}
@@ -334,7 +350,19 @@ struct LaneView: View {
guard !store.isReadOnly, !store.isEditingInline else { return }
store.transient.beginPlaceholder(inLane: lane.id)
}
.onTapGesture { toggleLaneSelection() }
// "Single click selects the lane (click again to unselect)" the toggle the header
// shares (04-interactions.md § Selection), and the modifier grammar on top of it.
.onTapGesture {
store.click(
SelectionTarget(id: lane.id, kind: .lane, side: .live),
modifier: .current,
togglesOnRepeat: true
)
}
// The rubber band's first surface "click-drag rubber-bands across lanes". Simultaneous
// so the taps above stay instant; the band's own begin guard is what keeps a drag that
// started on a card face out of it (`MarqueeControl`).
.simultaneousGesture(marquee.gesture(side: .live))
// The same menu the header carries "one menu, invoked on the header or lane empty
// space alike" (03-board-ui.md § Lane, settled).
.contextMenu { laneMenu }
@@ -375,21 +403,6 @@ struct LaneView: View {
store.selection.liveness == .live && store.selection.ids.contains(lane.id)
}
/// Click on empty space: select, or clear when this lane is already *the* selection.
///
/// "Single click selects the lane (click again to unselect)". The toggle-off tests for a
/// sole-membership selection rather than mere containment, so a future -click multi-selection
/// of lanes is narrowed by a click rather than wiped by it the modifier grammar itself
/// (-click toggles, -click range-extends, rubber band, homogeneity enforcement) is **m5's
/// selection-model card**, and nothing here should pre-empt it.
private func toggleLaneSelection() {
if store.selection.liveness == .live, store.selection.ids == [lane.id] {
store.clearSelection()
} else {
store.select([lane.id], liveness: .live)
}
}
/// The selection treatment: a subtle whole-lane accent wash and stroke. Deliberately quiet
/// 03-board-ui.md gives lane *colour* to the top-edge accent band, so selection must not read as
/// a fill that would compete with it once that lands.
@@ -476,6 +489,11 @@ private struct CardFaceView: View {
let store: BoardStore
let card: Card
/// Where the rubber band looks up what it is sweeping. The face registers its own drawn frame
/// here and takes it out again when it leaves see `View.marqueeTarget`.
let registry: MarqueeTargetRegistry
let openCard: (ItemID) -> Void
/// The app-wide quick-style recents see `LaneView`'s own note.
@@ -512,8 +530,25 @@ private struct CardFaceView: View {
// **Clicking never edits** (04-interactions.md Selection, a pivot from the pathfinder's
// two-stage Finder rename): one click selects and that is all it does no timer, no
// slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
// Board Rename.
.onTapGesture { store.select([card.id], liveness: .live) }
// Board Rename. The modifier grammar plain replaces, toggles, ranges is
// `SelectionGrammar`'s, reached through the store's one funnel.
.onTapGesture {
store.click(SelectionTarget(id: card.id, kind: .card, side: .live), modifier: .current)
}
// "A fast double-click opens the card window ('s pointer twin)" (04 Selection).
//
// `simultaneousGesture` rather than a second `onTapGesture(count: 2)`, deliberately: a
// second tap recogniser on the same view makes the single click *wait* to see whether a
// second one arrives, and selection must stay instant. Simultaneous means the first click
// of the pair selects and the second opens Finder's own behaviour.
//
// **Plain only.** and double-clicks are selection gestures that happened twice; opening
// a window out from under a range the user is still building would be a surprise.
.simultaneousGesture(TapGesture(count: 2).onEnded {
guard ClickModifier.current == .plain else { return }
openCard(card.id)
})
.marqueeTarget(card.id, kind: .card, side: .live, in: registry)
.contextMenu { cardMenu }
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)