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