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:
+118
-41
@@ -21,7 +21,10 @@ import SwiftUI
|
||||
/// - **Lane reorder** — the whole title bar is the drag surface (`LaneReorderSession`,
|
||||
/// `LaneReorderMath`); the travelling lane rides above its siblings while they show the would-be
|
||||
/// order.
|
||||
/// - **The keyboard's narrow slice** — Return's create/rename dispatch and Escape's step outward.
|
||||
/// - **The rubber band** — a drag from any empty surface sweeps a selection (`MarqueeSession`,
|
||||
/// `MarqueeMath`); the strip owns the session and the target registry, and hands both down.
|
||||
/// - **The keyboard's narrow slice** — Return's create/rename dispatch, Escape's step outward, and
|
||||
/// Select All.
|
||||
///
|
||||
/// - **The trash quasi-lane** — trailing, one fixed unit, joining and leaving the width division as
|
||||
/// View ▸ Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash).
|
||||
@@ -29,9 +32,7 @@ import SwiftUI
|
||||
/// ### What is deliberately not here yet
|
||||
///
|
||||
/// The toolbar, search, and drag & drop's real machinery (multi-drag, cross-board locality, the
|
||||
/// shadow's hold rule) all belong to later milestone cards. The **selection grammar** here is
|
||||
/// likewise minimal — a click replaces the selection, and that is all: ⌘-click toggling, ⇧-click
|
||||
/// ranges, the rubber band and the cards-XOR-lanes homogeneity rule are m5's selection-model card.
|
||||
/// shadow's hold rule) all belong to later milestone cards.
|
||||
struct BoardView: View {
|
||||
|
||||
let store: BoardStore
|
||||
@@ -64,6 +65,13 @@ struct BoardView: View {
|
||||
/// One drag out of the trash at a time, per window — same lifetime again.
|
||||
@State private var trashDrag = TrashDragSession()
|
||||
|
||||
/// One rubber band at a time, per window (`MarqueeSession`).
|
||||
@State private var marquee = MarqueeSession()
|
||||
|
||||
/// Where every sweepable item is drawn, in strip coordinates. Owned here because the band is —
|
||||
/// the cards and trash rows only *register* into it (`MarqueeTargetRegistry`).
|
||||
@State private var marqueeTargets = MarqueeTargetRegistry()
|
||||
|
||||
/// The name of the strip's coordinate space, which is what a drop out of the trash is resolved
|
||||
/// in: `LaneLayoutMath.laneIndex` reads an x measured from the strip's leading edge, outer margin
|
||||
/// included, and no global or lane-local space is that.
|
||||
@@ -110,48 +118,22 @@ struct BoardView: View {
|
||||
// reflow to make room. A drag whose lane has vanished from the snapshot proposes nothing
|
||||
// and shows the plain order; its release then cancels ("an emptied drag cancels itself").
|
||||
let shown = move.map { LaneReorderMath.reordered(lanes, from: $0.from, to: $0.to) } ?? lanes
|
||||
HStack(alignment: .top, spacing: spacing) {
|
||||
ForEach(Array(shown.enumerated()), id: \.element.id) { position, lane in
|
||||
laneSlot(lane, at: position, among: shown, standard: standard)
|
||||
// "Appear/disappear is scale + fade … lanes ~0.9" (03-board-ui.md § Motion).
|
||||
// A create, a delete and a Put Back all reach the strip as a lane arriving in
|
||||
// or leaving this `ForEach`; whether that *performs* is decided upstream, at
|
||||
// the reload that carried it (`Motion.reloadAnimates`) — a transition with no
|
||||
// animated transaction around it is simply an appearance.
|
||||
.transition(Motion.laneTransition(reduced: reduceMotion))
|
||||
}
|
||||
if isTrashVisible {
|
||||
// Trailing, always — the quasi-lane has no position of its own to lose, which is
|
||||
// also why it never appears in the reorder proposal's inputs (those are built
|
||||
// from `liveLanes`).
|
||||
TrashLaneView(
|
||||
store: store,
|
||||
confirmations: confirmations,
|
||||
drag: TrashRowDrag { x in laneUnder(x: x, standard: standard) },
|
||||
dragSession: trashDrag
|
||||
)
|
||||
.frame(width: LaneLayoutMath.slotWidth(units: 1, standard: standard, gap: spacing))
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
// It arrives and leaves like a lane, because that is what it looks like — the
|
||||
// column scales and fades while every real lane compresses to make room for its
|
||||
// unit (03-board-ui.md § Motion, § Trash's re-divide). The transaction is the
|
||||
// menu toggle's (`ShowTrashCommand`).
|
||||
.transition(Motion.laneTransition(reduced: reduceMotion))
|
||||
}
|
||||
ZStack(alignment: .topLeading) {
|
||||
backdrop
|
||||
laneStrip(shown, standard: standard, move: move)
|
||||
}
|
||||
// The drag's reflow-to-make-room, keyed on the **drop proposal** and nothing else
|
||||
// (03-board-ui.md § Motion: transactions are keyed narrowly, "on the drag's drop
|
||||
// proposal … never on broad state"). The travelling lane's own offset changes on every
|
||||
// pointer sample and none of those samples touch this value, so the replica keeps
|
||||
// tracking the cursor 1:1 — which is the same bullet's other half. At the instant the
|
||||
// proposal ticks, the lane's slot and its offset move by equal and opposite amounts, so
|
||||
// animating both under one curve is what keeps it pinned under the cursor.
|
||||
.animation(Motion.dragReflow(reduced: reduceMotion), value: move?.to)
|
||||
.padding(spacing)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
// The band, drawn **outside the padding** so its offset is a strip coordinate directly —
|
||||
// and outside every animated modifier above, because 03-board-ui.md § Motion puts the
|
||||
// marquee in the animation-free-by-construction list ("1:1 cursor following — animating
|
||||
// input echo would be lag").
|
||||
.overlay(alignment: .topLeading) { marqueeBand }
|
||||
// The space a drop out of the trash is resolved in — see `BoardView.stripSpace`. It goes
|
||||
// on the padded container so x = 0 is the strip's leading edge with the outer margin
|
||||
// included, which is the origin `LaneLayoutMath`'s arithmetic assumes.
|
||||
// included, which is the origin `LaneLayoutMath`'s arithmetic assumes. Every marquee
|
||||
// coordinate — the band's own drag samples and each registered item frame — is measured
|
||||
// here too, so nothing ever converts between spaces.
|
||||
.coordinateSpace(.named(Self.stripSpace))
|
||||
}
|
||||
.background(boardBackground)
|
||||
@@ -176,6 +158,100 @@ struct BoardView: View {
|
||||
.onKeyPress(.return) { handleReturn() }
|
||||
.onKeyPress(.escape) { handleEscape() }
|
||||
.onKeyPress(keys: [.delete], phases: .down) { handleDelete($0) }
|
||||
// **Select All** (04-interactions.md ▸ The map). Edit ▸ Select All is the standard menu
|
||||
// item and it dispatches `selectAll:` down the responder chain, so the board answers it as a
|
||||
// responder rather than growing a second menu item with the same title — which titles-are-API
|
||||
// forbids outright (04 ▸ Configurable bindings). A focused text field consumes it first, so
|
||||
// ⌘A inside an inline editor stays text selection with no guard needed here.
|
||||
.onCommand(#selector(NSText.selectAll(_:))) { store.selectAll() }
|
||||
}
|
||||
|
||||
// MARK: - The strip's layers
|
||||
|
||||
/// The empty surface behind the lanes: a plain click clears the selection, a drag rubber-bands.
|
||||
///
|
||||
/// `Color.clear` with a `contentShape` rather than a real fill — the board's *painted*
|
||||
/// background is `boardBackground`, outside the geometry reader, and this layer exists only to
|
||||
/// be hit. Modified clicks are deliberately no-ops: ⌘ and ⇧ on the backdrop name no target, and
|
||||
/// Finder's own desktop behaves the same way.
|
||||
private var backdrop: some View {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard ClickModifier.current == .plain else { return }
|
||||
store.clearSelection()
|
||||
}
|
||||
.simultaneousGesture(marqueeControl.gesture(side: .live))
|
||||
}
|
||||
|
||||
/// The lanes themselves, plus the trash column when it is shown.
|
||||
@ViewBuilder
|
||||
private func laneStrip(_ shown: [Lane], standard: CGFloat, move: (from: Int, to: Int)?) -> some View {
|
||||
HStack(alignment: .top, spacing: spacing) {
|
||||
ForEach(Array(shown.enumerated()), id: \.element.id) { position, lane in
|
||||
laneSlot(lane, at: position, among: shown, standard: standard)
|
||||
// "Appear/disappear is scale + fade … lanes ~0.9" (03-board-ui.md § Motion).
|
||||
// A create, a delete and a Put Back all reach the strip as a lane arriving in
|
||||
// or leaving this `ForEach`; whether that *performs* is decided upstream, at
|
||||
// the reload that carried it (`Motion.reloadAnimates`) — a transition with no
|
||||
// animated transaction around it is simply an appearance.
|
||||
.transition(Motion.laneTransition(reduced: reduceMotion))
|
||||
}
|
||||
if isTrashVisible {
|
||||
// Trailing, always — the quasi-lane has no position of its own to lose, which is
|
||||
// also why it never appears in the reorder proposal's inputs (those are built
|
||||
// from `liveLanes`).
|
||||
TrashLaneView(
|
||||
store: store,
|
||||
confirmations: confirmations,
|
||||
drag: TrashRowDrag { x in laneUnder(x: x, standard: standard) },
|
||||
dragSession: trashDrag,
|
||||
marquee: marqueeControl
|
||||
)
|
||||
.frame(width: LaneLayoutMath.slotWidth(units: 1, standard: standard, gap: spacing))
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
// It arrives and leaves like a lane, because that is what it looks like — the
|
||||
// column scales and fades while every real lane compresses to make room for its
|
||||
// unit (03-board-ui.md § Motion, § Trash's re-divide). The transaction is the
|
||||
// menu toggle's (`ShowTrashCommand`).
|
||||
.transition(Motion.laneTransition(reduced: reduceMotion))
|
||||
}
|
||||
}
|
||||
// The drag's reflow-to-make-room, keyed on the **drop proposal** and nothing else
|
||||
// (03-board-ui.md § Motion: transactions are keyed narrowly, "on the drag's drop
|
||||
// proposal … never on broad state"). The travelling lane's own offset changes on every
|
||||
// pointer sample and none of those samples touch this value, so the replica keeps
|
||||
// tracking the cursor 1:1 — which is the same bullet's other half. At the instant the
|
||||
// proposal ticks, the lane's slot and its offset move by equal and opposite amounts, so
|
||||
// animating both under one curve is what keeps it pinned under the cursor.
|
||||
//
|
||||
// It stays on the `HStack` rather than moving out to the `ZStack`, so the marquee band
|
||||
// drawn beside it is never inside an animated transaction (03 § Motion again).
|
||||
.animation(Motion.dragReflow(reduced: reduceMotion), value: move?.to)
|
||||
}
|
||||
|
||||
/// The rubber band itself: a translucent accent fill with a hairline border, in strip
|
||||
/// coordinates and **never animated** (03-board-ui.md § Motion — the marquee "tracks the cursor
|
||||
/// 1:1", and an eased band visibly lags the mouse).
|
||||
///
|
||||
/// Hit-testing off, because the band is feedback: the drag that draws it is already recognised,
|
||||
/// and a rectangle that swallowed clicks would eat the release.
|
||||
@ViewBuilder
|
||||
private var marqueeBand: some View {
|
||||
if let rect = marquee.rect {
|
||||
Rectangle()
|
||||
.fill(Color.accentColor.opacity(0.12))
|
||||
.frame(width: rect.width, height: rect.height)
|
||||
.overlay(Rectangle().strokeBorder(Color.accentColor.opacity(0.5), lineWidth: 1))
|
||||
.offset(x: rect.minX, y: rect.minY)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the strip lends its empty surfaces and its sweepable items — the band's session, the
|
||||
/// registry, and the store it selects into (`MarqueeControl`).
|
||||
private var marqueeControl: MarqueeControl {
|
||||
MarqueeControl(session: marquee, registry: marqueeTargets, store: store)
|
||||
}
|
||||
|
||||
// MARK: - Styling
|
||||
@@ -245,6 +321,7 @@ struct BoardView: View {
|
||||
columns: units,
|
||||
reorder: reorder,
|
||||
headerDrag: headerDrag(at: position, among: shown, standard: standard),
|
||||
marquee: marqueeControl,
|
||||
openCard: openCard
|
||||
)
|
||||
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
|
||||
|
||||
Reference in New Issue
Block a user