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