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