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,352 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
// MARK: - What a click names
|
||||
|
||||
/// Which of the board's two selectable levels an item is — 04-interactions.md § Selection's
|
||||
/// **cards XOR lanes** axis, named so the grammar can compare it rather than infer it per branch.
|
||||
///
|
||||
/// It is deliberately *not* stored anywhere: a selection is a set of ids and nothing else
|
||||
/// (`ItemReferenceSet`), so the kind of a selection is always re-derived from the snapshot. Storing
|
||||
/// it would be a second answer to a question the snapshot can always answer, and one that a reload
|
||||
/// could falsify.
|
||||
public enum SelectionKind: Sendable, Equatable {
|
||||
case card
|
||||
case lane
|
||||
}
|
||||
|
||||
/// What a pointer click names: an item, its level, and the side of the live/trash boundary the
|
||||
/// surface it was clicked on sits on.
|
||||
///
|
||||
/// The **side is the surface's, not the item's** — a card face is always `.live` and a trash row is
|
||||
/// always `.trashed`, because that is what the user clicked. A click on a surface whose item flipped
|
||||
/// liveness a moment ago simply selects nothing the next reload will keep, which is the ordinary
|
||||
/// vanish rule and not a case for this type to model.
|
||||
public struct SelectionTarget: Sendable, Equatable {
|
||||
public var id: ItemID
|
||||
public var kind: SelectionKind
|
||||
public var side: Liveness
|
||||
|
||||
public init(id: ItemID, kind: SelectionKind, side: Liveness) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.side = side
|
||||
}
|
||||
}
|
||||
|
||||
/// The modifier a click carried — 04-interactions.md § Selection's three-way grammar ("click
|
||||
/// selects; ⌘-click toggles; ⇧-click range-extends").
|
||||
///
|
||||
/// Three cases rather than an `OptionSet`, because the design gives ⌘ and ⇧ *no* combined meaning:
|
||||
/// AppKit hands us both flags when both keys are down, and the grammar picks one. `ClickModifier`
|
||||
/// is where that choice is made once (`ClickModifier.current`), so no call site re-decides it.
|
||||
public enum ClickModifier: Sendable, Equatable {
|
||||
case plain
|
||||
case command
|
||||
case shift
|
||||
}
|
||||
|
||||
// MARK: - SelectionGrammar
|
||||
|
||||
/// 04-interactions.md § Selection's pointer grammar, plus § The trash's extensions to it, as a pure
|
||||
/// function of the click, the current selection, the anchor, and the snapshot
|
||||
/// (`SelectionGrammarTests`).
|
||||
///
|
||||
/// **Homogeneity is the invariant, and it is enforced here or nowhere.** The selection is
|
||||
/// homogeneous on three axes at once — cards XOR lanes (§ Selection), live XOR tombstoned, and
|
||||
/// within the trash card entries XOR lane entries (§ The trash) — and every one of them is a
|
||||
/// property of what a *click* is allowed to produce. So no outcome below is ever mixed: a modifier
|
||||
/// that would cross an axis degrades to a replace, which is the only answer that keeps the
|
||||
/// invariant true without silently dropping what the user asked for.
|
||||
///
|
||||
/// **Pure, for `NewCardTarget`'s reason**: the branches become lines of test rather than gestures to
|
||||
/// drive, and the four surfaces that clicks arrive on (card face, lane header, lane empty space,
|
||||
/// trash row) share one answer instead of four near-copies of it.
|
||||
public enum SelectionGrammar {
|
||||
|
||||
/// What a click leaves behind: the new selection, and the anchor a subsequent ⇧-click would
|
||||
/// range from.
|
||||
///
|
||||
/// The anchor is carried *out* rather than mutated in place because it is not derivable from the
|
||||
/// selection — a ⇧-range replaces the whole set and deliberately leaves the anchor where it was,
|
||||
/// so "the last plain or ⌘ click" is a memory of a gesture and only the gesture can update it
|
||||
/// (`TransientBoardState.selectionAnchor`).
|
||||
public struct Outcome: Sendable, Equatable {
|
||||
public var selection: ItemReferenceSet
|
||||
public var anchor: ItemID?
|
||||
|
||||
public init(selection: ItemReferenceSet, anchor: ItemID?) {
|
||||
self.selection = selection
|
||||
self.anchor = anchor
|
||||
}
|
||||
|
||||
/// Nothing selected and nothing to range from — the toggle-off outcomes.
|
||||
static let cleared = Outcome(selection: .empty, anchor: nil)
|
||||
}
|
||||
|
||||
/// The grammar, one call.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - target: what was clicked, with the surface's liveness side (see `SelectionTarget`).
|
||||
/// - modifier: the effective modifier, already reduced to one of three (`ClickModifier`).
|
||||
/// - selection: the board's current selection.
|
||||
/// - anchor: the range origin — `TransientBoardState.selectionAnchor`.
|
||||
/// - snapshot: the board as it is now; every order list and every kind is derived from it.
|
||||
/// - togglesOnRepeat: **the lane's click-again-to-unselect**, and only the lane's
|
||||
/// (04-interactions.md § Selection: "single click selects the lane (click again to
|
||||
/// unselect)", and the header "toggles like empty space (settled)"). A card face passes
|
||||
/// `false`: Finder does not deselect a file by clicking it twice, and neither do we.
|
||||
public static func click(
|
||||
_ target: SelectionTarget,
|
||||
modifier: ClickModifier,
|
||||
selection: ItemReferenceSet,
|
||||
anchor: ItemID?,
|
||||
snapshot: BoardModel,
|
||||
togglesOnRepeat: Bool = false
|
||||
) -> Outcome {
|
||||
switch modifier {
|
||||
case .plain:
|
||||
return plain(target, selection: selection, togglesOnRepeat: togglesOnRepeat)
|
||||
case .command:
|
||||
return command(target, selection: selection, snapshot: snapshot)
|
||||
case .shift:
|
||||
return shift(target, selection: selection, anchor: anchor, snapshot: snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The three branches
|
||||
|
||||
/// **Plain**: the selection becomes exactly what was clicked, and the click becomes the anchor.
|
||||
///
|
||||
/// The one exception is `togglesOnRepeat`, and it tests for *sole membership* rather than mere
|
||||
/// containment: a lane click that lands inside a multi-lane selection narrows it to that lane
|
||||
/// (replace), because "click again to unselect" is about the lane the user already had, not
|
||||
/// about wiping a selection they built with ⌘.
|
||||
private static func plain(
|
||||
_ target: SelectionTarget,
|
||||
selection: ItemReferenceSet,
|
||||
togglesOnRepeat: Bool
|
||||
) -> Outcome {
|
||||
if togglesOnRepeat, selection.liveness == target.side, selection.ids == [target.id] {
|
||||
return .cleared
|
||||
}
|
||||
return Outcome(
|
||||
selection: ItemReferenceSet(ids: [target.id], liveness: target.side),
|
||||
anchor: target.id
|
||||
)
|
||||
}
|
||||
|
||||
/// **⌘-click toggles** — but only *within* a homogeneous set. Crossing either axis (a card
|
||||
/// clicked while lanes are selected, a trash row clicked while live cards are) is not a mixed
|
||||
/// selection and not a refusal: it is a **replace**, the same outcome a plain click would give,
|
||||
/// because the click unambiguously names a new set of one.
|
||||
///
|
||||
/// The current kind is derived from the snapshot rather than remembered (`kind(of:in:)`); a
|
||||
/// selection whose members all name nothing the board renders counts as empty, so a ⌘-click
|
||||
/// after a foreign delete starts a fresh set rather than extending a ghost.
|
||||
private static func command(
|
||||
_ target: SelectionTarget,
|
||||
selection: ItemReferenceSet,
|
||||
snapshot: BoardModel
|
||||
) -> Outcome {
|
||||
guard selection.liveness == target.side,
|
||||
let current = kind(of: selection, in: snapshot),
|
||||
current == target.kind
|
||||
else {
|
||||
return plain(target, selection: selection, togglesOnRepeat: false)
|
||||
}
|
||||
|
||||
var ids = selection.ids
|
||||
if ids.remove(target.id) == nil {
|
||||
ids.insert(target.id)
|
||||
} else if ids.isEmpty {
|
||||
// The last member toggled out: nothing is selected, so there is nothing to range from.
|
||||
return .cleared
|
||||
}
|
||||
return Outcome(
|
||||
selection: ItemReferenceSet(ids: ids, liveness: target.side),
|
||||
anchor: target.id
|
||||
)
|
||||
}
|
||||
|
||||
/// **⇧-click range-extends from the anchor**, Finder-list style: the whole range replaces the
|
||||
/// selection, and the anchor stays where it is so successive ⇧-clicks sweep out from the same
|
||||
/// origin rather than walking it along.
|
||||
///
|
||||
/// The anchor is valid **iff both it and the target sit in the same order list** — which folds
|
||||
/// the nil anchor, the vanished anchor, and every axis crossing into one test, since a list is
|
||||
/// exactly one (side, kind) pair. An invalid anchor makes the click a plain one, never a no-op:
|
||||
/// the keyboard's ⇧-arrow goes inert at a boundary because its next step is ambiguous, while a
|
||||
/// click names an unambiguous target and so always has something to do.
|
||||
private static func shift(
|
||||
_ target: SelectionTarget,
|
||||
selection: ItemReferenceSet,
|
||||
anchor: ItemID?,
|
||||
snapshot: BoardModel
|
||||
) -> Outcome {
|
||||
let list = order(of: target.kind, on: target.side, in: snapshot)
|
||||
guard let anchor,
|
||||
let from = list.firstIndex(of: anchor),
|
||||
let to = list.firstIndex(of: target.id)
|
||||
else {
|
||||
return plain(target, selection: selection, togglesOnRepeat: false)
|
||||
}
|
||||
let span = from <= to ? list[from...to] : list[to...from]
|
||||
return Outcome(
|
||||
selection: ItemReferenceSet(ids: Set(span), liveness: target.side),
|
||||
anchor: anchor
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - The order lists
|
||||
|
||||
/// The list a ⇧-range walks for one (side, kind) pair — **the single place a "what's on the
|
||||
/// board, in what order" question is answered** for the pointer.
|
||||
///
|
||||
// m5-search: the filter "is the single source of truth for what's on the board … ranges … all
|
||||
// read it" (04-interactions.md § Search). It threads in here and in `MarqueeTargetRegistry`'s
|
||||
// membership, and nowhere else — every range and every Select All is stated in terms of these
|
||||
// four lists.
|
||||
public static func order(of kind: SelectionKind, on side: Liveness, in snapshot: BoardModel) -> [ItemID] {
|
||||
switch (side, kind) {
|
||||
case (.live, .card): liveCards(in: snapshot)
|
||||
case (.live, .lane): liveLanes(in: snapshot)
|
||||
case (.trashed, _): trashEntries(of: kind, in: snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
/// Live cards in **flatten order** — "lane `order` first, then card `order` (a cross-lane
|
||||
/// selection flattens left-to-right, top-to-bottom)", the multi-drag order the ⌘N target rule
|
||||
/// and paste anchoring already share (04-interactions.md ▸ Drag and drop, ▸ The map).
|
||||
///
|
||||
/// The snapshot's arrays are already in display order (`Ranks.sortedForDisplay`), so the flatten
|
||||
/// is one walk — `NewCardTarget.resolve`'s walk, in list form.
|
||||
public static func liveCards(in snapshot: BoardModel) -> [ItemID] {
|
||||
var ids: [ItemID] = []
|
||||
for lane in snapshot.lanes where !lane.isDeleted {
|
||||
for card in lane.cards where !card.isDeleted {
|
||||
ids.append(card.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/// Live lanes, left to right. Tombstoned lanes render nowhere on the board (03-board-ui.md §
|
||||
/// Trash collapses each into one entry), so they are absent from the live lane order entirely.
|
||||
public static func liveLanes(in snapshot: BoardModel) -> [ItemID] {
|
||||
snapshot.lanes.filter { !$0.isDeleted }.map(\.id)
|
||||
}
|
||||
|
||||
/// One kind of trash row, in the quasi-lane's own deterministic order (`TrashModel.entries`,
|
||||
/// whose sort is "load-bearing for input — arrow walks, ⇧-ranges, and the rubber band all read
|
||||
/// it").
|
||||
///
|
||||
/// **Filtered to one kind, so a range skips what it cannot include.** Card and lane entries
|
||||
/// interleave in one ordering, and "a selection never mixes card entries and lane entries"
|
||||
/// (04-interactions.md ▸ The trash), so a ⇧-range between two card rows spans the sorted order
|
||||
/// and collects only the card rows — stepping over any lane row that sits between them. That is
|
||||
/// the deliberate pointer twin of the keyboard's rule: a ⇧-arrow onto a lane entry is *inert*
|
||||
/// because its next step is ambiguous, while a click names an unambiguous same-kind target and
|
||||
/// so the range simply skips.
|
||||
public static func trashEntries(of kind: SelectionKind, in snapshot: BoardModel) -> [ItemID] {
|
||||
TrashModel.entries(of: snapshot)
|
||||
.filter { $0.isLaneEntry == (kind == .lane) }
|
||||
.map(\.id)
|
||||
}
|
||||
|
||||
// MARK: - The current selection's kind
|
||||
|
||||
/// Which level the selection holds, or `nil` when it holds nothing the board renders on its own
|
||||
/// side.
|
||||
///
|
||||
/// **Any member answers, because the set is homogeneous** — but the walk is the snapshot's order
|
||||
/// rather than the set's iteration order, so the answer is deterministic even for a set that
|
||||
/// somehow was not. Members that name nothing are ignored, and a set of only such members reads
|
||||
/// as empty: a selection the next reload will drop must not decide what a click does now.
|
||||
///
|
||||
/// The membership rules are exactly the order lists': on the live side an item counts when its
|
||||
/// effective liveness is live, and on the trashed side only **rows** count — a card under a
|
||||
/// tombstoned lane has no row of its own (`TrashModel.entries`' absolute ancestor walk), so it
|
||||
/// is nobody's kind.
|
||||
public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? {
|
||||
guard !selection.isEmpty else { return nil }
|
||||
for lane in snapshot.lanes {
|
||||
if Liveness(isDeleted: lane.isDeleted) == selection.liveness, selection.ids.contains(lane.id) {
|
||||
return .lane
|
||||
}
|
||||
// A tombstoned lane subsumes its subtree on both sides: its cards render nowhere live
|
||||
// and have no trash row of their own.
|
||||
guard !lane.isDeleted else { continue }
|
||||
for card in lane.cards
|
||||
where Liveness(isDeleted: card.isDeleted) == selection.liveness && selection.ids.contains(card.id) {
|
||||
return .card
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The rubber band
|
||||
|
||||
/// One item the marquee can sweep: its identity, its level, its side, and where it is drawn.
|
||||
///
|
||||
/// The frame is in the board strip's coordinate space (`BoardView.stripSpace`) and is **registered
|
||||
/// by the view that draws it** (`MarqueeTargetRegistry`) rather than computed here: the masonry's
|
||||
/// geometry is the layout's own answer, and re-deriving it would be a second one.
|
||||
public struct MarqueeTarget: Sendable, Equatable {
|
||||
public var id: ItemID
|
||||
public var kind: SelectionKind
|
||||
public var side: Liveness
|
||||
public var frame: CGRect
|
||||
|
||||
public init(id: ItemID, kind: SelectionKind, side: Liveness, frame: CGRect) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.side = side
|
||||
self.frame = frame
|
||||
}
|
||||
}
|
||||
|
||||
/// What a rubber band selects, as a pure function of the band, the drawn frames, and the side the
|
||||
/// band started on (`SelectionGrammarTests`).
|
||||
///
|
||||
/// The two rules it exists to state, both 04-interactions.md's:
|
||||
///
|
||||
/// - **The band stays on the side of the boundary it started on** (▸ The trash), which is why `side`
|
||||
/// is a parameter rather than something derived from what the rect happens to touch: a band begun
|
||||
/// on the board and dragged over the trash column selects live cards and nothing else.
|
||||
/// - **The band never selects lanes** (§ Selection gives it to cards: "click-drag rubber-bands
|
||||
/// across lanes" — across them, not over them). Lanes are simply never registered as targets, and
|
||||
/// the live branch filters to cards anyway so the rule holds even if one were.
|
||||
public enum MarqueeMath {
|
||||
|
||||
/// The ids `rect` sweeps.
|
||||
///
|
||||
/// On the **trashed** side the band must additionally stay homogeneous by *kind*, because the
|
||||
/// trash's two row kinds interleave in one column. The rule is topmost-wins: the kind of the
|
||||
/// highest intersecting row decides, and rows of the other kind are dropped — so a band pulled
|
||||
/// down from a card row keeps collecting card rows and steps over the lane rows between them,
|
||||
/// exactly as a ⇧-range does.
|
||||
public static func selection(rect: CGRect, targets: [MarqueeTarget], side: Liveness) -> Set<ItemID> {
|
||||
let hits = targets.filter { $0.side == side && rect.intersects($0.frame) }
|
||||
guard !hits.isEmpty else { return [] }
|
||||
|
||||
switch side {
|
||||
case .live:
|
||||
return Set(hits.lazy.filter { $0.kind == .card }.map(\.id))
|
||||
case .trashed:
|
||||
guard let topmost = hits.min(by: isAbove) else { return [] }
|
||||
return Set(hits.lazy.filter { $0.kind == topmost.kind }.map(\.id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of two drawn rows is "higher" — top edge, then leading edge, then identity.
|
||||
///
|
||||
/// Total rather than merely correct-for-a-column: two rows sharing a top edge must still order
|
||||
/// the same way twice, or the topmost-kind rule would pick differently on identical input.
|
||||
private static func isAbove(_ lhs: MarqueeTarget, _ rhs: MarqueeTarget) -> Bool {
|
||||
if lhs.frame.minY != rhs.frame.minY { return lhs.frame.minY < rhs.frame.minY }
|
||||
if lhs.frame.minX != rhs.frame.minX { return lhs.frame.minX < rhs.frame.minX }
|
||||
return lhs.id.rawValue < rhs.id.rawValue
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user