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:
@@ -93,10 +93,12 @@ final class TrashDragSession {
|
||||
///
|
||||
/// ### What is still a later card's
|
||||
///
|
||||
/// The **search filter** ("shown, it participates in the filter like any lane") and the full
|
||||
/// **keyboard grammar** — arrow walks into and out of the column, ⇧-ranges that stop at both the
|
||||
/// liveness and the kind boundary, the rubber band, ⌘C copy-out — are m5's. So is the drag's replica:
|
||||
/// what ships here is the drop, with the target lane highlighted and the source row dimmed in place.
|
||||
/// The **search filter** ("shown, it participates in the filter like any lane") and the **keyboard**
|
||||
/// grammar — arrow walks into and out of the column, ⇧-arrows that go inert at both the liveness and
|
||||
/// the kind boundary, ⌘C copy-out — are still owed. The *pointer* grammar is here: a row's click
|
||||
/// runs the same `SelectionGrammar` the board does, and the column's empty space rubber-bands on the
|
||||
/// trashed side. So is the drop, with the target lane highlighted and the source row dimmed in
|
||||
/// place; the drag's replica is not.
|
||||
struct TrashLaneView: View {
|
||||
|
||||
let store: BoardStore
|
||||
@@ -114,6 +116,11 @@ struct TrashLaneView: View {
|
||||
/// session has.
|
||||
let dragSession: TrashDragSession
|
||||
|
||||
/// The strip's rubber band. The column's empty space is its third surface, on the **trashed**
|
||||
/// side — "a rubber-band stays on the side of the boundary it started on" (04-interactions.md ▸
|
||||
/// The trash) — and every row registers its frame into the same registry.
|
||||
let marquee: MarqueeControl
|
||||
|
||||
/// Reduce Motion, for the row transition below — 10-accessibility.md names the trash
|
||||
/// specifically ("and trash animations all get reduced variants").
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@@ -207,7 +214,8 @@ struct TrashLaneView: View {
|
||||
entry: entry,
|
||||
confirmations: confirmations,
|
||||
drag: drag,
|
||||
dragSession: dragSession
|
||||
dragSession: dragSession,
|
||||
registry: marquee.registry
|
||||
)
|
||||
// A row is a tombstoned item, so it arrives and leaves in the card's dialect —
|
||||
// a delete files one in, a Put Back or a purge takes one out, and both halves of
|
||||
@@ -218,6 +226,11 @@ struct TrashLaneView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.padding(6)
|
||||
.contentShape(Rectangle())
|
||||
// The band's trash-side surface. It only ever arms from the column's empty space — a
|
||||
// drag begun on a row is that row's drag-out — and the begin guard makes that geometric
|
||||
// rather than a matter of gesture priority (`MarqueeControl`).
|
||||
.simultaneousGesture(marquee.gesture(side: .trashed))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,6 +275,10 @@ private struct TrashEntryRow: View {
|
||||
let drag: TrashRowDrag
|
||||
let dragSession: TrashDragSession
|
||||
|
||||
/// Where the rubber band looks up what it is sweeping — the card face's rule, on the trashed
|
||||
/// side (`View.marqueeTarget`).
|
||||
let registry: MarqueeTargetRegistry
|
||||
|
||||
private let cornerRadius: CGFloat = 6
|
||||
|
||||
var body: some View {
|
||||
@@ -295,6 +312,7 @@ private struct TrashEntryRow: View {
|
||||
.opacity(dragSession.isDragging(entry.id) ? 0.45 : 1)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(rowGesture)
|
||||
.marqueeTarget(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry)
|
||||
.contextMenu { menu }
|
||||
}
|
||||
|
||||
@@ -308,17 +326,20 @@ private struct TrashEntryRow: View {
|
||||
store.selection.liveness == .trashed && store.selection.ids.contains(entry.id)
|
||||
}
|
||||
|
||||
/// A click replaces the selection with this one row, on the **trashed** side.
|
||||
/// A click selects this row on the **trashed** side, through the same grammar the board's
|
||||
/// surfaces use — plain replaces, ⌘ toggles, ⇧ ranges (`SelectionGrammar`).
|
||||
///
|
||||
/// Replace-only is what enforces both invariants at once here: a selection that is always exactly
|
||||
/// one row can never mix live with tombstoned, nor card entries with lane entries
|
||||
/// (04-interactions.md ▸ The trash). The extension grammar — ⌘-click, ⇧-ranges that go inert at
|
||||
/// both boundaries, the rubber band that stays on the side it started on — is **m5's
|
||||
/// selection-model card**, and nothing here should pre-empt it.
|
||||
/// The row's kind travels with the click, and that is what keeps the trash's second homogeneity
|
||||
/// axis true: a ⌘-click across the card/lane-entry boundary replaces rather than mixing, and a
|
||||
/// ⇧-range walks only its own kind's rows (04-interactions.md ▸ The trash). No `togglesOnRepeat`
|
||||
/// — click-again-to-unselect is the lane's behaviour, not a row's.
|
||||
///
|
||||
/// **A double click is two of these and nothing more**: no editor, no card window, no timer.
|
||||
private func select() {
|
||||
store.select([entry.id], liveness: .trashed)
|
||||
store.click(
|
||||
SelectionTarget(id: entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed),
|
||||
modifier: .current
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Drag out
|
||||
|
||||
Reference in New Issue
Block a user