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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import CoreGraphics
|
||||
import Observation
|
||||
|
||||
// MARK: - MarqueeSession
|
||||
|
||||
/// Window-local state for an in-flight rubber band — `LaneReorderSession`'s sibling, and as small
|
||||
/// for its reason: it holds only what the *pointer* contributes, because everything the selection
|
||||
/// needs beyond that is read fresh at gesture time (`MarqueeTargetRegistry`, `MarqueeMath`).
|
||||
///
|
||||
/// **The side is frozen at the origin** — "a rubber-band stays on the side of the boundary it
|
||||
/// started on" (04-interactions.md ▸ The trash). A band begun on the board and dragged across the
|
||||
/// trash column keeps selecting live cards; a band begun in the trash keeps selecting rows. That is
|
||||
/// the whole reason the side is stored here rather than re-derived from what the rect touches.
|
||||
///
|
||||
/// **Nothing here animates.** 03-board-ui.md § Motion names the marquee twice: "the rubber-band
|
||||
/// marquee tracks the cursor 1:1 (an eased band visibly lags the mouse)" and, in the
|
||||
/// animation-free-by-construction list, "the marquee rectangle (1:1 cursor following — animating
|
||||
/// input echo would be lag)". So the rect is drawn outside every animated transaction on the strip,
|
||||
/// and the selection churn it drives is deliberately un-keyed too ("never on broad state like the
|
||||
/// selection set").
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MarqueeSession {
|
||||
|
||||
/// Where the band was begun, in `BoardView.stripSpace` coordinates; `nil` when idle — which is
|
||||
/// what "no marquee" means here rather than a separate flag.
|
||||
private(set) var origin: CGPoint?
|
||||
|
||||
/// The pointer's current position, in the same space. Meaningless while `origin` is `nil`.
|
||||
private(set) var current: CGPoint = .zero
|
||||
|
||||
/// The side of the live/trash boundary this band selects on, frozen at `begin`.
|
||||
private(set) var side: Liveness = .live
|
||||
|
||||
/// How far the pointer must travel before a drag on empty space becomes a band. Larger than the
|
||||
/// lane header's threshold because this gesture arms on *any* empty surface, and a click that
|
||||
/// clears the selection must not leave a one-pixel band behind it.
|
||||
static let minimumDistance: CGFloat = 5
|
||||
|
||||
var isActive: Bool { origin != nil }
|
||||
|
||||
/// The band, normalized so it is a rectangle regardless of which way the drag ran; `nil` when
|
||||
/// idle, which is also the overlay's "draw nothing".
|
||||
var rect: CGRect? {
|
||||
guard let origin else { return nil }
|
||||
return CGRect(
|
||||
x: min(origin.x, current.x),
|
||||
y: min(origin.y, current.y),
|
||||
width: abs(current.x - origin.x),
|
||||
height: abs(current.y - origin.y)
|
||||
)
|
||||
}
|
||||
|
||||
func begin(at point: CGPoint, side: Liveness) {
|
||||
origin = point
|
||||
current = point
|
||||
self.side = side
|
||||
}
|
||||
|
||||
func update(to point: CGPoint) {
|
||||
guard isActive else { return }
|
||||
current = point
|
||||
}
|
||||
|
||||
/// Ends the band. **The selection stays exactly as the last sample computed it** — a release is
|
||||
/// not a commit here, because nothing was written: the band was only ever a way of naming a set.
|
||||
/// Idempotent, like every other session's `end`.
|
||||
func end() {
|
||||
origin = nil
|
||||
current = .zero
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MarqueeTargetRegistry
|
||||
|
||||
/// Where each sweepable item is drawn, keyed by identity — the geometry half of the rubber band.
|
||||
///
|
||||
/// **The views register themselves** (`onGeometryChange` in `BoardView.stripSpace`, removal in
|
||||
/// `onDisappear`) rather than the band re-deriving the masonry's arithmetic: the layout already
|
||||
/// computed those frames, and a second computation is a second answer that could disagree with what
|
||||
/// is on screen. It is also what makes the band correct for free across a lane resize, a reorder in
|
||||
/// flight, and a foreign reload — the frames simply re-register.
|
||||
///
|
||||
/// **Lanes are never registered.** The band selects cards, and trash rows on the trash side; a lane
|
||||
/// has no entry here at all, which is 04-interactions.md § Selection's "click-drag rubber-bands
|
||||
/// across lanes" made structural rather than filtered.
|
||||
///
|
||||
/// It is also the **begin guard's** universe: a drag that starts inside a registered frame belongs
|
||||
/// to that item's own gesture (a card drag, a drag out of the trash), never to the band. Deciding
|
||||
/// that geometrically rather than by gesture priority is what keeps the two from fighting.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MarqueeTargetRegistry {
|
||||
|
||||
private(set) var targets: [ItemID: MarqueeTarget] = [:]
|
||||
|
||||
var all: [MarqueeTarget] { Array(targets.values) }
|
||||
|
||||
func update(_ target: MarqueeTarget) {
|
||||
targets[target.id] = target
|
||||
}
|
||||
|
||||
func remove(_ id: ItemID) {
|
||||
targets.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
/// Whether `point` lands on something already drawn — the band's begin guard.
|
||||
func contains(_ point: CGPoint) -> Bool {
|
||||
targets.values.contains { $0.frame.contains(point) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - The modifier a click carried
|
||||
|
||||
extension ClickModifier {
|
||||
|
||||
/// The modifier the keyboard is holding **right now**, reduced to the grammar's three cases.
|
||||
///
|
||||
/// Read from `NSEvent.modifierFlags` rather than from the gesture value, because SwiftUI's
|
||||
/// `TapGesture` hands its handler nothing about the event — and `EventModifiers` on a
|
||||
/// `.modifiers(_:)`-qualified gesture would need one recogniser per modifier, three of which
|
||||
/// would then race to consume the same click.
|
||||
///
|
||||
/// **⌘ wins over ⇧** when both are down: 04-interactions.md gives the two no combined meaning
|
||||
/// ("⌘-click toggles; ⇧-click range-extends"), so the reduction happens once, here, and no call
|
||||
/// site re-decides it.
|
||||
@MainActor
|
||||
static var current: ClickModifier {
|
||||
let flags = NSEvent.modifierFlags
|
||||
if flags.contains(.command) { return .command }
|
||||
if flags.contains(.shift) { return .shift }
|
||||
return .plain
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The rubber band's gesture
|
||||
|
||||
/// What a board window lends its empty surfaces so each can be a rubber band: the one session, the
|
||||
/// one target registry, and the store the band selects into.
|
||||
///
|
||||
/// `LaneHeaderDrag`'s sibling in role — the strip owning state that a leaf gesture needs — but a
|
||||
/// value rather than a pair of closures, because all three surfaces (lane empty space, the board
|
||||
/// backdrop, the trash column) want the *same* gesture rather than three variations threaded with
|
||||
/// different geometry. Only the side differs, and that is the parameter.
|
||||
@MainActor
|
||||
struct MarqueeControl {
|
||||
|
||||
let session: MarqueeSession
|
||||
let registry: MarqueeTargetRegistry
|
||||
let store: BoardStore
|
||||
|
||||
/// The band, as one gesture attached with `simultaneousGesture` wherever empty space is.
|
||||
///
|
||||
/// - **The begin guard is geometric**: a drag whose start lands inside a registered frame is
|
||||
/// somebody else's (a card drag, a drag out of the trash), so no band begins and the sample
|
||||
/// loop simply keeps declining for the rest of that drag. Deciding this by frames rather than
|
||||
/// by gesture priority is what keeps the two from fighting, and it stays correct as the
|
||||
/// masonry reflows.
|
||||
/// - **The side is fixed at the origin** — 04-interactions.md ▸ The trash's rule, stored in the
|
||||
/// session so a band dragged across the boundary keeps its meaning.
|
||||
/// - **Live-updating, not commit-on-release**: each sample recomputes the whole set from the
|
||||
/// band, so the selection follows the cursor both ways. An empty band clears rather than
|
||||
/// leaving the last non-empty one standing.
|
||||
/// - **Alive under the read-only lock**: selection is not a mutation (02-architecture.md § The
|
||||
/// lock's scope), and no `isEditingInline` guard either — a click-away mid-rename already
|
||||
/// commits through the field's own focus loss.
|
||||
func gesture(side: Liveness) -> some Gesture {
|
||||
DragGesture(minimumDistance: MarqueeSession.minimumDistance, coordinateSpace: .named(BoardView.stripSpace))
|
||||
.onChanged { value in
|
||||
if !session.isActive {
|
||||
guard !registry.contains(value.startLocation) else { return }
|
||||
session.begin(at: value.startLocation, side: side)
|
||||
}
|
||||
session.update(to: value.location)
|
||||
guard let rect = session.rect else { return }
|
||||
let ids = MarqueeMath.selection(rect: rect, targets: registry.all, side: session.side)
|
||||
if ids.isEmpty {
|
||||
store.clearSelection()
|
||||
} else {
|
||||
// No anchor: a band names no click to range from, so a ⇧-click after one acts
|
||||
// plain (`TransientBoardState.selectionAnchor`).
|
||||
store.select(ids, liveness: session.side, anchor: nil)
|
||||
}
|
||||
}
|
||||
.onEnded { _ in session.end() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Registering a sweepable frame
|
||||
|
||||
extension View {
|
||||
|
||||
/// Keeps this item's drawn frame in the window's marquee registry, and takes it out again when
|
||||
/// the view goes away.
|
||||
///
|
||||
/// The frame is measured in `BoardView.stripSpace`, the one space every marquee coordinate lives
|
||||
/// in — the band's own points come from a drag gesture in the same space, so no conversion
|
||||
/// happens anywhere.
|
||||
@MainActor
|
||||
func marqueeTarget(
|
||||
_ id: ItemID,
|
||||
kind: SelectionKind,
|
||||
side: Liveness,
|
||||
in registry: MarqueeTargetRegistry
|
||||
) -> some View {
|
||||
// The space name is read here, on the main actor, rather than inside the measuring closure:
|
||||
// `BoardView` is main-actor-isolated by its `View` conformance, and the closure is not.
|
||||
let space = BoardView.stripSpace
|
||||
return onGeometryChange(for: CGRect.self) { proxy in
|
||||
proxy.frame(in: .named(space))
|
||||
} action: { frame in
|
||||
registry.update(MarqueeTarget(id: id, kind: kind, side: side, frame: frame))
|
||||
}
|
||||
.onDisappear { registry.remove(id) }
|
||||
}
|
||||
}
|
||||
@@ -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