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:
@@ -1314,11 +1314,78 @@ public final class BoardStore {
|
|||||||
/// for "the lane that most recently held selection or a creation", and a *card* selection is
|
/// for "the lane that most recently held selection or a creation", and a *card* selection is
|
||||||
/// its lane holding selection just as much as the lane's own header click is — so both are
|
/// its lane holding selection just as much as the lane's own header click is — so both are
|
||||||
/// noted here, and creation notes itself in `beginPlaceholder`.
|
/// noted here, and creation notes itself in `beginPlaceholder`.
|
||||||
public func select(_ ids: Set<ItemID>, liveness: Liveness) {
|
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil) {
|
||||||
transient.select(ids, liveness: liveness)
|
transient.select(ids, liveness: liveness, anchor: anchor)
|
||||||
transient.noteActiveLane(Self.lane(holding: ids, in: snapshot))
|
transient.noteActiveLane(Self.lane(holding: ids, in: snapshot))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Every pointer click on a selectable surface goes through here** — card face, lane header,
|
||||||
|
/// lane empty space, trash row — so 04-interactions.md § Selection's grammar is stated once
|
||||||
|
/// (`SelectionGrammar`) rather than four times with three of them subtly different.
|
||||||
|
///
|
||||||
|
/// The store's whole contribution is supplying the three inputs the grammar cannot see (the
|
||||||
|
/// snapshot, the selection, the anchor) and storing the outcome. An emptied outcome clears
|
||||||
|
/// rather than storing an empty set on a side, because that is what "nothing selected" is
|
||||||
|
/// everywhere else in the app.
|
||||||
|
///
|
||||||
|
/// - Parameter togglesOnRepeat: the lane's click-again-to-unselect — see `SelectionGrammar`.
|
||||||
|
public func click(_ target: SelectionTarget, modifier: ClickModifier, togglesOnRepeat: Bool = false) {
|
||||||
|
let outcome = SelectionGrammar.click(
|
||||||
|
target,
|
||||||
|
modifier: modifier,
|
||||||
|
selection: selection,
|
||||||
|
anchor: transient.selectionAnchor,
|
||||||
|
snapshot: snapshot,
|
||||||
|
togglesOnRepeat: togglesOnRepeat
|
||||||
|
)
|
||||||
|
guard !outcome.selection.isEmpty else {
|
||||||
|
clearSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The anchor is passed through explicitly: `select`'s default would otherwise re-anchor a
|
||||||
|
// ⇧-range's single-member edge case on the target, and the grammar's answer is the one that
|
||||||
|
// knows whether this click was an origin or an extension.
|
||||||
|
select(outcome.selection.ids, liveness: outcome.selection.liveness, anchor: outcome.anchor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Select All** — "all visible cards on the board" (04-interactions.md ▸ The map), with the
|
||||||
|
/// trash's own reading of the same command when the trash side is the one in play.
|
||||||
|
///
|
||||||
|
/// Two branches, and the trash's is the narrow one: it fires only when the column is **shown**,
|
||||||
|
/// the selection is on the trashed side, and it still names a row — the exact conditions under
|
||||||
|
/// which "all" could mean anything but the board. It then selects every trash row **of the
|
||||||
|
/// selection's kind**, because 04 ▸ The trash's card-entries-XOR-lane-entries rule binds a
|
||||||
|
/// wholesale selection as tightly as it binds a click. A trashed selection naming nothing (a
|
||||||
|
/// foreign Put Back, a purge) falls through to the board rather than selecting the trash
|
||||||
|
/// wholesale on a guess.
|
||||||
|
///
|
||||||
|
/// The anchor **survives if it is still in the set** and is dropped otherwise: Select All is not
|
||||||
|
/// a click, so it names no new origin, but it has no business discarding one that is still
|
||||||
|
/// standing inside what it selected.
|
||||||
|
///
|
||||||
|
// m5-search: "filter-respecting, like every surface" (04 ▸ The map). The universe here is
|
||||||
|
// `SelectionGrammar`'s order lists, which is where the filter threads in — one change, and both
|
||||||
|
// this command and every ⇧-range narrow together.
|
||||||
|
public func selectAll() {
|
||||||
|
if transient.isTrashVisible, selection.liveness == .trashed, !selection.isEmpty,
|
||||||
|
let kind = SelectionGrammar.kind(of: selection, in: snapshot) {
|
||||||
|
apply(Set(SelectionGrammar.trashEntries(of: kind, in: snapshot)), on: .trashed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apply(Set(SelectionGrammar.liveCards(in: snapshot)), on: .live)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select All's storage half: an empty universe clears rather than storing an empty set, and the
|
||||||
|
/// anchor is kept only while it is still inside what was selected.
|
||||||
|
private func apply(_ ids: Set<ItemID>, on side: Liveness) {
|
||||||
|
guard !ids.isEmpty else {
|
||||||
|
clearSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let anchor = transient.selectionAnchor.flatMap { ids.contains($0) ? $0 : nil }
|
||||||
|
select(ids, liveness: side, anchor: anchor)
|
||||||
|
}
|
||||||
|
|
||||||
/// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar).
|
/// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar).
|
||||||
///
|
///
|
||||||
/// The last-active lane deliberately survives: it is a high-water mark of where the user has
|
/// The last-active lane deliberately survives: it is a high-water mark of where the user has
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -278,6 +278,23 @@ public final class TransientBoardState {
|
|||||||
/// set wholesale by the gesture that owns them.
|
/// set wholesale by the gesture that owns them.
|
||||||
public private(set) var selection: ItemReferenceSet = .empty
|
public private(set) var selection: ItemReferenceSet = .empty
|
||||||
|
|
||||||
|
/// **Where a ⇧-click ranges from** — the item the last plain or ⌘ click named
|
||||||
|
/// (04-interactions.md § Selection, "⇧-click range-extends").
|
||||||
|
///
|
||||||
|
/// A *memory of a gesture*, like `lastActiveLaneID` and for the same reason: it is not
|
||||||
|
/// derivable from the selection. A range replaces the whole set and deliberately leaves the
|
||||||
|
/// anchor put, so successive ⇧-clicks sweep out from one origin instead of walking it along —
|
||||||
|
/// which means nothing in the set marks it.
|
||||||
|
///
|
||||||
|
/// **A marquee and every wholesale selection pass no anchor deliberately.** There is no click
|
||||||
|
/// behind them to range from, so a ⇧-click afterwards behaves as a plain click — the same
|
||||||
|
/// degrade `SelectionGrammar` gives a vanished anchor, reached honestly rather than by inventing
|
||||||
|
/// an origin the user never named.
|
||||||
|
///
|
||||||
|
/// It lives on the selection's side by construction, so `resolve(against:)` re-grounds it with
|
||||||
|
/// the same rule every other item reference gets.
|
||||||
|
public private(set) var selectionAnchor: ItemID?
|
||||||
|
|
||||||
/// The items a drag is carrying — **empty when no drag is in flight**, which is what "no drag"
|
/// The items a drag is carrying — **empty when no drag is in flight**, which is what "no drag"
|
||||||
/// means here rather than a separate flag.
|
/// means here rather than a separate flag.
|
||||||
///
|
///
|
||||||
@@ -374,19 +391,27 @@ public final class TransientBoardState {
|
|||||||
|
|
||||||
// MARK: - Selection
|
// MARK: - Selection
|
||||||
|
|
||||||
/// Replaces the selection.
|
/// Replaces the selection, and sets the anchor a subsequent ⇧-click ranges from.
|
||||||
///
|
///
|
||||||
/// Minimal on purpose — the selection's real grammar (extend, range, successor-on-delete) lands
|
/// The grammar itself is `SelectionGrammar`'s — pure, testable, and the one funnel every click
|
||||||
/// with the board UI. Deliberately **not** filtered against the snapshot: a caller selects what
|
/// surface goes through (`BoardStore.click`). This is the storage half, and its only rule of its
|
||||||
/// it is rendering, and `resolve(against:)` on the next reload is what keeps the set honest over
|
/// own is the **anchor default**: `nil` with a sole member anchors on that member, `nil` with
|
||||||
/// time.
|
/// any other count anchors on nothing. That makes the two callers that pass nothing behave
|
||||||
public func select(_ ids: Set<ItemID>, liveness: Liveness) {
|
/// exactly as they should — a one-item selection made by any route is a legitimate range origin,
|
||||||
|
/// while a marquee or a Select All names no click and so leaves a ⇧-click acting plain.
|
||||||
|
///
|
||||||
|
/// Deliberately **not** filtered against the snapshot: a caller selects what it is rendering, and
|
||||||
|
/// `resolve(against:)` on the next reload is what keeps the set honest over time.
|
||||||
|
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil) {
|
||||||
selection = ItemReferenceSet(ids: ids, liveness: liveness)
|
selection = ItemReferenceSet(ids: ids, liveness: liveness)
|
||||||
|
selectionAnchor = anchor ?? (ids.count == 1 ? ids.first : nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar).
|
/// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar). The anchor goes
|
||||||
|
/// with it: an empty selection has no origin to range from.
|
||||||
public func clearSelection() {
|
public func clearSelection() {
|
||||||
selection = .empty
|
selection = .empty
|
||||||
|
selectionAnchor = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records that `laneID` is where the user is working — a lane selected, or created into.
|
/// Records that `laneID` is where the user is working — a lane selected, or created into.
|
||||||
@@ -540,6 +565,12 @@ public final class TransientBoardState {
|
|||||||
/// current universe does not have. It is not an `ItemReferenceSet` only because it is one
|
/// current universe does not have. It is not an `ItemReferenceSet` only because it is one
|
||||||
/// optional rather than a set on a side — the rule it obeys is the same one.
|
/// optional rather than a set on a side — the rule it obeys is the same one.
|
||||||
///
|
///
|
||||||
|
/// **`selectionAnchor` obeys it too**, on the *selection's* side: a range origin that vanished
|
||||||
|
/// or flipped liveness is gone, and the next ⇧-click acts as a plain click rather than ranging
|
||||||
|
/// from somewhere that renders nowhere. It deliberately does **not** have to stay *in* the
|
||||||
|
/// selection — a ⌘-click that toggles the anchor's neighbour out leaves the anchor selected and
|
||||||
|
/// a range from it is still exactly what the user asked for.
|
||||||
|
///
|
||||||
/// **The style editor tracks its target set live** (03-board-ui.md § Styling ▸ Controls,
|
/// **The style editor tracks its target set live** (03-board-ui.md § Styling ▸ Controls,
|
||||||
/// settled): a member that vanishes or flips liveness leaves the set — so the editor's
|
/// settled): a member that vanishes or flips liveness leaves the set — so the editor's
|
||||||
/// mixed-state display recomputes off the survivors — and a set emptied by a foreign reload
|
/// mixed-state display recomputes off the survivors — and a set emptied by a foreign reload
|
||||||
@@ -566,6 +597,16 @@ public final class TransientBoardState {
|
|||||||
if let lane = lastActiveLaneID, !live.contains(lane) {
|
if let lane = lastActiveLaneID, !live.contains(lane) {
|
||||||
lastActiveLaneID = nil
|
lastActiveLaneID = nil
|
||||||
}
|
}
|
||||||
|
if let anchor = selectionAnchor {
|
||||||
|
// The selection's side, because that is the side the anchor lives on by construction —
|
||||||
|
// every route that sets it sets the selection to the same side in the same call. A
|
||||||
|
// vanished or liveness-flipped anchor is gone, which is the rule every item reference
|
||||||
|
// here gets: "a flip is a vanish from its side of the boundary".
|
||||||
|
let universe = selection.liveness == .live
|
||||||
|
? live
|
||||||
|
: ItemReferenceSet.idUniverse(of: snapshot, on: selection.liveness)
|
||||||
|
if !universe.contains(anchor) { selectionAnchor = nil }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The placeholder's two discard rules, as a pure function of the placeholder and the snapshot.
|
/// The placeholder's two discard rules, as a pure function of the placeholder and the snapshot.
|
||||||
|
|||||||
+118
-41
@@ -21,7 +21,10 @@ import SwiftUI
|
|||||||
/// - **Lane reorder** — the whole title bar is the drag surface (`LaneReorderSession`,
|
/// - **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
|
/// `LaneReorderMath`); the travelling lane rides above its siblings while they show the would-be
|
||||||
/// order.
|
/// 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
|
/// - **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).
|
/// View ▸ Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash).
|
||||||
@@ -29,9 +32,7 @@ import SwiftUI
|
|||||||
/// ### What is deliberately not here yet
|
/// ### What is deliberately not here yet
|
||||||
///
|
///
|
||||||
/// The toolbar, search, and drag & drop's real machinery (multi-drag, cross-board locality, the
|
/// 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
|
/// shadow's hold rule) all belong to later milestone cards.
|
||||||
/// 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.
|
|
||||||
struct BoardView: View {
|
struct BoardView: View {
|
||||||
|
|
||||||
let store: BoardStore
|
let store: BoardStore
|
||||||
@@ -64,6 +65,13 @@ struct BoardView: View {
|
|||||||
/// One drag out of the trash at a time, per window — same lifetime again.
|
/// One drag out of the trash at a time, per window — same lifetime again.
|
||||||
@State private var trashDrag = TrashDragSession()
|
@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
|
/// 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
|
/// 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.
|
/// 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
|
// 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").
|
// 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
|
let shown = move.map { LaneReorderMath.reordered(lanes, from: $0.from, to: $0.to) } ?? lanes
|
||||||
HStack(alignment: .top, spacing: spacing) {
|
ZStack(alignment: .topLeading) {
|
||||||
ForEach(Array(shown.enumerated()), id: \.element.id) { position, lane in
|
backdrop
|
||||||
laneSlot(lane, at: position, among: shown, standard: standard)
|
laneStrip(shown, standard: standard, move: move)
|
||||||
// "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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 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)
|
.padding(spacing)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.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
|
// 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
|
// 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))
|
.coordinateSpace(.named(Self.stripSpace))
|
||||||
}
|
}
|
||||||
.background(boardBackground)
|
.background(boardBackground)
|
||||||
@@ -176,6 +158,100 @@ struct BoardView: View {
|
|||||||
.onKeyPress(.return) { handleReturn() }
|
.onKeyPress(.return) { handleReturn() }
|
||||||
.onKeyPress(.escape) { handleEscape() }
|
.onKeyPress(.escape) { handleEscape() }
|
||||||
.onKeyPress(keys: [.delete], phases: .down) { handleDelete($0) }
|
.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
|
// MARK: - Styling
|
||||||
@@ -245,6 +321,7 @@ struct BoardView: View {
|
|||||||
columns: units,
|
columns: units,
|
||||||
reorder: reorder,
|
reorder: reorder,
|
||||||
headerDrag: headerDrag(at: position, among: shown, standard: standard),
|
headerDrag: headerDrag(at: position, among: shown, standard: standard),
|
||||||
|
marquee: marqueeControl,
|
||||||
openCard: openCard
|
openCard: openCard
|
||||||
)
|
)
|
||||||
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
|
.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
|
/// 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
|
/// (`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**:
|
/// 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
|
/// (`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.
|
/// 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
|
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
|
/// 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
|
/// the card window"). Supplied by the strip, which is supplied by the host: a lane has no
|
||||||
/// business knowing about `WindowGroup` keys.
|
/// business knowing about `WindowGroup` keys.
|
||||||
@@ -289,10 +294,16 @@ struct LaneView: View {
|
|||||||
if reorder.isDragging(lane.id) {
|
if reorder.isDragging(lane.id) {
|
||||||
headerDrag.commit()
|
headerDrag.commit()
|
||||||
} else {
|
} else {
|
||||||
// A plain click on the header always selects — unlike lane empty space, it does
|
// **The header toggles like empty space** (04-interactions.md § Selection,
|
||||||
// not toggle off. 04 gives the click-again-to-unselect behaviour to empty space
|
// settled): "a click on the already-selected lane's header unselects, one
|
||||||
// only, and a full lane has no empty space to reach for.
|
// lane-click behavior everywhere, so a full lane keeps a pointer path out of
|
||||||
store.select([lane.id], liveness: .live)
|
// 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 {
|
Group {
|
||||||
switch slot {
|
switch slot {
|
||||||
case let .card(card):
|
case let .card(card):
|
||||||
CardFaceView(store: store, card: card, openCard: openCard)
|
CardFaceView(
|
||||||
|
store: store,
|
||||||
|
card: card,
|
||||||
|
registry: marquee.registry,
|
||||||
|
openCard: openCard
|
||||||
|
)
|
||||||
case .placeholder:
|
case .placeholder:
|
||||||
NewCardStubView(store: store, openCard: openCard)
|
NewCardStubView(store: store, openCard: openCard)
|
||||||
}
|
}
|
||||||
@@ -334,7 +350,19 @@ struct LaneView: View {
|
|||||||
guard !store.isReadOnly, !store.isEditingInline else { return }
|
guard !store.isReadOnly, !store.isEditingInline else { return }
|
||||||
store.transient.beginPlaceholder(inLane: lane.id)
|
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
|
// The same menu the header carries — "one menu, invoked on the header or lane empty
|
||||||
// space alike" (03-board-ui.md § Lane, settled).
|
// space alike" (03-board-ui.md § Lane, settled).
|
||||||
.contextMenu { laneMenu }
|
.contextMenu { laneMenu }
|
||||||
@@ -375,21 +403,6 @@ struct LaneView: View {
|
|||||||
store.selection.liveness == .live && store.selection.ids.contains(lane.id)
|
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 —
|
/// 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
|
/// 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.
|
/// a fill that would compete with it once that lands.
|
||||||
@@ -476,6 +489,11 @@ private struct CardFaceView: View {
|
|||||||
|
|
||||||
let store: BoardStore
|
let store: BoardStore
|
||||||
let card: Card
|
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
|
let openCard: (ItemID) -> Void
|
||||||
|
|
||||||
/// The app-wide quick-style recents — see `LaneView`'s own note.
|
/// 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
|
// **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
|
// 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
|
// slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
|
||||||
// Board ▸ Rename.
|
// Board ▸ Rename. The modifier grammar — plain replaces, ⌘ toggles, ⇧ ranges — is
|
||||||
.onTapGesture { store.select([card.id], liveness: .live) }
|
// `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 }
|
.contextMenu { cardMenu }
|
||||||
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
||||||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
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
|
/// ### What is still a later card's
|
||||||
///
|
///
|
||||||
/// The **search filter** ("shown, it participates in the filter like any lane") and the full
|
/// The **search filter** ("shown, it participates in the filter like any lane") and the **keyboard**
|
||||||
/// **keyboard grammar** — arrow walks into and out of the column, ⇧-ranges that stop at both the
|
/// grammar — arrow walks into and out of the column, ⇧-arrows that go inert at both the liveness and
|
||||||
/// liveness and the kind boundary, the rubber band, ⌘C copy-out — are m5's. So is the drag's replica:
|
/// the kind boundary, ⌘C copy-out — are still owed. The *pointer* grammar is here: a row's click
|
||||||
/// what ships here is the drop, with the target lane highlighted and the source row dimmed in place.
|
/// 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 {
|
struct TrashLaneView: View {
|
||||||
|
|
||||||
let store: BoardStore
|
let store: BoardStore
|
||||||
@@ -114,6 +116,11 @@ struct TrashLaneView: View {
|
|||||||
/// session has.
|
/// session has.
|
||||||
let dragSession: TrashDragSession
|
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
|
/// Reduce Motion, for the row transition below — 10-accessibility.md names the trash
|
||||||
/// specifically ("and trash animations all get reduced variants").
|
/// specifically ("and trash animations all get reduced variants").
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
@@ -207,7 +214,8 @@ struct TrashLaneView: View {
|
|||||||
entry: entry,
|
entry: entry,
|
||||||
confirmations: confirmations,
|
confirmations: confirmations,
|
||||||
drag: drag,
|
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 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
|
// 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)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
.padding(6)
|
.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 drag: TrashRowDrag
|
||||||
let dragSession: TrashDragSession
|
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
|
private let cornerRadius: CGFloat = 6
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -295,6 +312,7 @@ private struct TrashEntryRow: View {
|
|||||||
.opacity(dragSession.isDragging(entry.id) ? 0.45 : 1)
|
.opacity(dragSession.isDragging(entry.id) ? 0.45 : 1)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.gesture(rowGesture)
|
.gesture(rowGesture)
|
||||||
|
.marqueeTarget(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry)
|
||||||
.contextMenu { menu }
|
.contextMenu { menu }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,17 +326,20 @@ private struct TrashEntryRow: View {
|
|||||||
store.selection.liveness == .trashed && store.selection.ids.contains(entry.id)
|
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
|
/// The row's kind travels with the click, and that is what keeps the trash's second homogeneity
|
||||||
/// one row can never mix live with tombstoned, nor card entries with lane entries
|
/// axis true: a ⌘-click across the card/lane-entry boundary replaces rather than mixing, and a
|
||||||
/// (04-interactions.md ▸ The trash). The extension grammar — ⌘-click, ⇧-ranges that go inert at
|
/// ⇧-range walks only its own kind's rows (04-interactions.md ▸ The trash). No `togglesOnRepeat`
|
||||||
/// both boundaries, the rubber band that stays on the side it started on — is **m5's
|
/// — click-again-to-unselect is the lane's behaviour, not a row's.
|
||||||
/// selection-model card**, and nothing here should pre-empt it.
|
|
||||||
///
|
///
|
||||||
/// **A double click is two of these and nothing more**: no editor, no card window, no timer.
|
/// **A double click is two of these and nothing more**: no editor, no card window, no timer.
|
||||||
private func select() {
|
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
|
// MARK: - Drag out
|
||||||
|
|||||||
@@ -0,0 +1,614 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// 04-interactions.md § Selection's pointer grammar and § The trash's extensions to it, branch by
|
||||||
|
/// branch — plus the anchor that makes ⇧-click mean anything, and the rubber band's arithmetic.
|
||||||
|
///
|
||||||
|
/// The grammar is written as a pure function precisely so it can be tested like one: a click, a
|
||||||
|
/// selection, an anchor and a snapshot in, a selection and an anchor out — no window, no gesture, no
|
||||||
|
/// modifier flags. The boards underneath are **real loads off real temp trees**, because every rule
|
||||||
|
/// here reads `isDeleted`, card ordering, or `TrashModel`'s sort, and a hand-built `BoardModel`
|
||||||
|
/// would let all three drift from what the loader actually produces.
|
||||||
|
///
|
||||||
|
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
|
||||||
|
|
||||||
|
// MARK: - Fixtures
|
||||||
|
|
||||||
|
/// More literal identities than `Ident` offers: a three-lane range needs five cards, and the trash's
|
||||||
|
/// interleaving needs entries whose folder names are distinguishable in the sort's tie-break.
|
||||||
|
private enum More {
|
||||||
|
static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||||
|
static let laneX = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||||
|
static let laneY = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tombstoned(order: String, title: String, deleted: String = "2026-03-03T09:00:00Z") -> String {
|
||||||
|
"""
|
||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
title: \(title)
|
||||||
|
order: \(order)
|
||||||
|
deleted: \(deleted)
|
||||||
|
---
|
||||||
|
\(title) body.
|
||||||
|
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Three live lanes, five live cards and one tombstoned card — enough that a flatten-order range
|
||||||
|
/// crosses two lane boundaries and has something to *skip* on the way.
|
||||||
|
///
|
||||||
|
/// Flatten order of the live cards is `[card1, card2, card3, card5]`; `card4` carries its own
|
||||||
|
/// `deleted:` and is in none of it.
|
||||||
|
@MainActor
|
||||||
|
private func makeLiveBoard() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
||||||
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||||
|
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
|
||||||
|
try fixture.item("\(Ident.lane2)/\(Ident.card4)", tombstoned(order: "2048", title: "Fourth"))
|
||||||
|
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
|
||||||
|
try fixture.item("\(Ident.lane3)/\(More.card5)", Item.rich(order: "1024", title: "Fifth"))
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A trash whose two row kinds **interleave**, which is the only shape that can prove a range skips.
|
||||||
|
///
|
||||||
|
/// `TrashModel`'s sort is newest `deleted` first, so the entry order is
|
||||||
|
/// `[card1, laneX, card2, laneY, card3]` — a card range from `card1` to `card3` has two lane rows
|
||||||
|
/// sitting inside its span, and a lane range from `laneX` to `laneY` has a card row inside its own.
|
||||||
|
@MainActor
|
||||||
|
private func makeTrashBoard() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First", deleted: "2026-03-05T10:00:00Z"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second", deleted: "2026-03-05T08:00:00Z"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstoned(order: "3072", title: "Third", deleted: "2026-03-05T06:00:00Z"))
|
||||||
|
try fixture.item(More.laneX, tombstoned(order: "2048", title: "Archive", deleted: "2026-03-05T09:00:00Z"))
|
||||||
|
try fixture.item(More.laneY, tombstoned(order: "3072", title: "Old", deleted: "2026-03-05T07:00:00Z"))
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
private let lane1 = ItemID(rawValue: Ident.lane1)
|
||||||
|
private let lane2 = ItemID(rawValue: Ident.lane2)
|
||||||
|
private let lane3 = ItemID(rawValue: Ident.lane3)
|
||||||
|
private let card1 = ItemID(rawValue: Ident.card1)
|
||||||
|
private let card2 = ItemID(rawValue: Ident.card2)
|
||||||
|
private let card3 = ItemID(rawValue: Ident.card3)
|
||||||
|
private let card4 = ItemID(rawValue: Ident.card4)
|
||||||
|
private let card5 = ItemID(rawValue: More.card5)
|
||||||
|
private let laneX = ItemID(rawValue: More.laneX)
|
||||||
|
private let laneY = ItemID(rawValue: More.laneY)
|
||||||
|
|
||||||
|
private func load(_ fixture: WriterFixture) throws -> BoardModel {
|
||||||
|
try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
}
|
||||||
|
|
||||||
|
private func target(_ id: ItemID, _ kind: SelectionKind, _ side: Liveness = .live) -> SelectionTarget {
|
||||||
|
SelectionTarget(id: id, kind: kind, side: side)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func set(_ ids: Set<ItemID>, _ side: Liveness = .live) -> ItemReferenceSet {
|
||||||
|
ItemReferenceSet(ids: ids, liveness: side)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One click, with the grammar's own defaults filled in.
|
||||||
|
private func click(
|
||||||
|
_ target: SelectionTarget,
|
||||||
|
_ modifier: ClickModifier,
|
||||||
|
selection: ItemReferenceSet = .empty,
|
||||||
|
anchor: ItemID? = nil,
|
||||||
|
in snapshot: BoardModel,
|
||||||
|
togglesOnRepeat: Bool = false
|
||||||
|
) -> SelectionGrammar.Outcome {
|
||||||
|
SelectionGrammar.click(
|
||||||
|
target,
|
||||||
|
modifier: modifier,
|
||||||
|
selection: selection,
|
||||||
|
anchor: anchor,
|
||||||
|
snapshot: snapshot,
|
||||||
|
togglesOnRepeat: togglesOnRepeat
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The order lists
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("SelectionGrammar ▸ order")
|
||||||
|
struct SelectionOrderTests {
|
||||||
|
|
||||||
|
@Test("Live cards flatten lane order first, then card order — tombstones excluded")
|
||||||
|
func flattenOrder() throws {
|
||||||
|
let fixture = try makeLiveBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
// "Lane `order` first, then card `order` (a cross-lane selection flattens left-to-right,
|
||||||
|
// top-to-bottom)" — the multi-drag order (04-interactions.md ▸ Drag and drop).
|
||||||
|
#expect(SelectionGrammar.liveCards(in: snapshot) == [card1, card2, card3, card5])
|
||||||
|
#expect(SelectionGrammar.liveLanes(in: snapshot) == [lane1, lane2, lane3])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Trash order lists are one sort, filtered to one kind")
|
||||||
|
func trashOrderPerKind() throws {
|
||||||
|
let fixture = try makeTrashBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
// The single ordering the quasi-lane shows, newest first.
|
||||||
|
#expect(TrashModel.entries(of: snapshot).map(\.id) == [card1, laneX, card2, laneY, card3])
|
||||||
|
// Each kind's list is that ordering with the other kind's rows dropped — which is exactly
|
||||||
|
// what makes a ⇧-range step over them (04-interactions.md ▸ The trash).
|
||||||
|
#expect(SelectionGrammar.trashEntries(of: .card, in: snapshot) == [card1, card2, card3])
|
||||||
|
#expect(SelectionGrammar.trashEntries(of: .lane, in: snapshot) == [laneX, laneY])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A selection's kind is derived from the snapshot, and a ghost selection has none")
|
||||||
|
func kindDerivation() throws {
|
||||||
|
let fixture = try makeLiveBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
#expect(SelectionGrammar.kind(of: set([card1, card3]), in: snapshot) == .card)
|
||||||
|
#expect(SelectionGrammar.kind(of: set([lane2]), in: snapshot) == .lane)
|
||||||
|
#expect(SelectionGrammar.kind(of: .empty, in: snapshot) == nil)
|
||||||
|
// A tombstoned card is on neither side's live list, and the live side is what this set says.
|
||||||
|
#expect(SelectionGrammar.kind(of: set([card4]), in: snapshot) == nil)
|
||||||
|
// Members that name nothing are ignored; one that names something still answers.
|
||||||
|
#expect(SelectionGrammar.kind(of: set([card4, card1]), in: snapshot) == .card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Plain
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("SelectionGrammar ▸ plain click")
|
||||||
|
struct PlainClickTests {
|
||||||
|
|
||||||
|
@Test("A plain click replaces the selection and becomes the anchor")
|
||||||
|
func replacesAndAnchors() throws {
|
||||||
|
let fixture = try makeLiveBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
let outcome = click(target(card3, .card), .plain, selection: set([card1, card2]), anchor: card1, in: snapshot)
|
||||||
|
#expect(outcome.selection == set([card3]))
|
||||||
|
#expect(outcome.anchor == card3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Click again unselects — but only where the design gives that behaviour, and only on a sole selection")
|
||||||
|
func toggleOffIsSoleMembershipOnly() throws {
|
||||||
|
let fixture = try makeLiveBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
// "Single click selects the lane (click again to unselect)", and the header "toggles like
|
||||||
|
// empty space (settled)".
|
||||||
|
let off = click(target(lane1, .lane), .plain, selection: set([lane1]), anchor: lane1, in: snapshot, togglesOnRepeat: true)
|
||||||
|
#expect(off.selection.isEmpty)
|
||||||
|
#expect(off.anchor == nil)
|
||||||
|
|
||||||
|
// A multi-lane selection containing this lane is *narrowed*, not wiped: the toggle is about
|
||||||
|
// the lane the user already had alone, not about a set they built with ⌘.
|
||||||
|
let narrowed = click(target(lane1, .lane), .plain, selection: set([lane1, lane2]), anchor: lane2, in: snapshot, togglesOnRepeat: true)
|
||||||
|
#expect(narrowed.selection == set([lane1]))
|
||||||
|
#expect(narrowed.anchor == lane1)
|
||||||
|
|
||||||
|
// A card face never toggles off — Finder does not deselect a file by clicking it twice.
|
||||||
|
let card = click(target(card1, .card), .plain, selection: set([card1]), anchor: card1, in: snapshot)
|
||||||
|
#expect(card.selection == set([card1]))
|
||||||
|
#expect(card.anchor == card1)
|
||||||
|
|
||||||
|
// Nor does the toggle reach across the boundary: a live click on a trashed sole selection
|
||||||
|
// of the same id is a replace.
|
||||||
|
let crossed = click(target(lane1, .lane), .plain, selection: set([lane1], .trashed), anchor: lane1, in: snapshot, togglesOnRepeat: true)
|
||||||
|
#expect(crossed.selection == set([lane1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Command
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("SelectionGrammar ▸ ⌘-click")
|
||||||
|
struct CommandClickTests {
|
||||||
|
|
||||||
|
@Test("⌘-click toggles within one kind, and the click is the new anchor either way")
|
||||||
|
func togglesWithinKind() throws {
|
||||||
|
let fixture = try makeLiveBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
let added = click(target(card3, .card), .command, selection: set([card1]), anchor: card1, in: snapshot)
|
||||||
|
#expect(added.selection == set([card1, card3]))
|
||||||
|
#expect(added.anchor == card3)
|
||||||
|
|
||||||
|
let removed = click(target(card1, .card), .command, selection: set([card1, card3]), anchor: card3, in: snapshot)
|
||||||
|
#expect(removed.selection == set([card3]))
|
||||||
|
#expect(removed.anchor == card1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Toggling the last member out leaves nothing selected and nothing to range from")
|
||||||
|
func emptyingClearsTheAnchor() throws {
|
||||||
|
let fixture = try makeLiveBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
let outcome = click(target(card1, .card), .command, selection: set([card1]), anchor: card1, in: snapshot)
|
||||||
|
#expect(outcome.selection.isEmpty)
|
||||||
|
#expect(outcome.anchor == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("⌘-click across the kind boundary replaces — a selection is never mixed")
|
||||||
|
func acrossKindReplaces() throws {
|
||||||
|
let fixture = try makeLiveBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
// "Selection is homogeneous: cards XOR lanes" (04-interactions.md § Selection).
|
||||||
|
let ontoLane = click(target(lane2, .lane), .command, selection: set([card1, card2]), anchor: card2, in: snapshot)
|
||||||
|
#expect(ontoLane.selection == set([lane2]))
|
||||||
|
#expect(ontoLane.anchor == lane2)
|
||||||
|
|
||||||
|
let ontoCard = click(target(card1, .card), .command, selection: set([lane1, lane2]), anchor: lane2, in: snapshot)
|
||||||
|
#expect(ontoCard.selection == set([card1]))
|
||||||
|
#expect(ontoCard.anchor == card1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("⌘-click across the liveness boundary replaces too")
|
||||||
|
func acrossSideReplaces() throws {
|
||||||
|
let fixture = try makeTrashBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
// "Selection is homogeneous by liveness … a selection never mixes live and tombstoned"
|
||||||
|
// (04 ▸ The trash). The live lane1 is the only live thing on this board.
|
||||||
|
let intoTrash = click(target(card1, .card, .trashed), .command, selection: set([lane1]), anchor: lane1, in: snapshot)
|
||||||
|
#expect(intoTrash.selection == set([card1], .trashed))
|
||||||
|
|
||||||
|
let backOut = click(target(lane1, .lane), .command, selection: set([card1, card2], .trashed), anchor: card2, in: snapshot)
|
||||||
|
#expect(backOut.selection == set([lane1]))
|
||||||
|
|
||||||
|
// And within the trash, the second axis: card entries XOR lane entries.
|
||||||
|
let ontoLaneEntry = click(target(laneX, .lane, .trashed), .command, selection: set([card1, card2], .trashed), anchor: card2, in: snapshot)
|
||||||
|
#expect(ontoLaneEntry.selection == set([laneX], .trashed))
|
||||||
|
#expect(ontoLaneEntry.anchor == laneX)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("⌘-click with nothing — or nothing real — selected replaces")
|
||||||
|
func emptyOrGhostSelectionReplaces() throws {
|
||||||
|
let fixture = try makeLiveBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
let fromEmpty = click(target(card1, .card), .command, in: snapshot)
|
||||||
|
#expect(fromEmpty.selection == set([card1]))
|
||||||
|
#expect(fromEmpty.anchor == card1)
|
||||||
|
|
||||||
|
// A selection whose members all name nothing the board renders counts as empty: a ⌘-click
|
||||||
|
// after a foreign delete starts a fresh set rather than extending a ghost.
|
||||||
|
let fromGhost = click(target(card1, .card), .command, selection: set([card4]), anchor: card4, in: snapshot)
|
||||||
|
#expect(fromGhost.selection == set([card1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shift
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("SelectionGrammar ▸ ⇧-click")
|
||||||
|
struct ShiftClickTests {
|
||||||
|
|
||||||
|
@Test("A ⇧-range spans the flatten order across lanes, skipping tombstones, and leaves the anchor put")
|
||||||
|
func rangeAcrossLanes() throws {
|
||||||
|
let fixture = try makeLiveBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
let outcome = click(target(card5, .card), .shift, selection: set([card1]), anchor: card1, in: snapshot)
|
||||||
|
#expect(outcome.selection == set([card1, card2, card3, card5]))
|
||||||
|
// Finder-list style: the anchor is unchanged, so successive ⇧-clicks sweep from one origin.
|
||||||
|
#expect(outcome.anchor == card1)
|
||||||
|
// card4 is tombstoned and in no order list, so no range can pick it up.
|
||||||
|
#expect(!outcome.selection.ids.contains(card4))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Direction does not matter — the range is the span between anchor and target")
|
||||||
|
func rangeIsDirectionless() throws {
|
||||||
|
let fixture = try makeLiveBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
let backwards = click(target(card1, .card), .shift, selection: set([card5]), anchor: card5, in: snapshot)
|
||||||
|
#expect(backwards.selection == set([card1, card2, card3, card5]))
|
||||||
|
#expect(backwards.anchor == card5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Lanes range in their own order list")
|
||||||
|
func laneRange() throws {
|
||||||
|
let fixture = try makeLiveBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
let outcome = click(target(lane3, .lane), .shift, selection: set([lane1]), anchor: lane1, in: snapshot)
|
||||||
|
#expect(outcome.selection == set([lane1, lane2, lane3]))
|
||||||
|
#expect(outcome.anchor == lane1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An invalid anchor makes ⇧ a plain click — nil, vanished, or across a boundary")
|
||||||
|
func invalidAnchorActsPlain() throws {
|
||||||
|
let fixture = try makeLiveBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
// Nothing to range from.
|
||||||
|
let noAnchor = click(target(card3, .card), .shift, selection: set([card1]), in: snapshot)
|
||||||
|
#expect(noAnchor.selection == set([card3]))
|
||||||
|
#expect(noAnchor.anchor == card3)
|
||||||
|
|
||||||
|
// An anchor that names nothing the board renders (card4 is tombstoned).
|
||||||
|
let vanished = click(target(card3, .card), .shift, selection: set([card1]), anchor: card4, in: snapshot)
|
||||||
|
#expect(vanished.selection == set([card3]))
|
||||||
|
#expect(vanished.anchor == card3)
|
||||||
|
|
||||||
|
// An anchor in the *other* kind's list: the two lists are disjoint, so the anchor is not
|
||||||
|
// findable and the click degrades — never a mixed range.
|
||||||
|
let acrossKind = click(target(card3, .card), .shift, selection: set([lane1]), anchor: lane1, in: snapshot)
|
||||||
|
#expect(acrossKind.selection == set([card3]))
|
||||||
|
#expect(acrossKind.anchor == card3)
|
||||||
|
|
||||||
|
// Same for a side crossing: the live card list holds no trash row.
|
||||||
|
let acrossSide = click(target(card3, .card, .trashed), .shift, selection: set([card1]), anchor: card1, in: snapshot)
|
||||||
|
#expect(acrossSide.selection == set([card3], .trashed))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A trash card range steps over the lane entries inside its span")
|
||||||
|
func trashCardRangeSkipsLaneEntries() throws {
|
||||||
|
let fixture = try makeTrashBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
// Sorted order is [card1, laneX, card2, laneY, card3]; a card range collects card rows only.
|
||||||
|
let outcome = click(target(card3, .card, .trashed), .shift, selection: set([card1], .trashed), anchor: card1, in: snapshot)
|
||||||
|
#expect(outcome.selection == set([card1, card2, card3], .trashed))
|
||||||
|
#expect(outcome.anchor == card1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A trash lane range steps over the card entries inside its span")
|
||||||
|
func trashLaneRangeSkipsCardEntries() throws {
|
||||||
|
let fixture = try makeTrashBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
let outcome = click(target(laneY, .lane, .trashed), .shift, selection: set([laneX], .trashed), anchor: laneX, in: snapshot)
|
||||||
|
#expect(outcome.selection == set([laneX, laneY], .trashed))
|
||||||
|
#expect(outcome.anchor == laneX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The rubber band
|
||||||
|
|
||||||
|
@Suite("MarqueeMath")
|
||||||
|
struct MarqueeMathTests {
|
||||||
|
|
||||||
|
private static func card(_ id: ItemID, _ y: CGFloat, side: Liveness = .live) -> MarqueeTarget {
|
||||||
|
MarqueeTarget(id: id, kind: .card, side: side, frame: CGRect(x: 0, y: y, width: 100, height: 40))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func laneEntry(_ id: ItemID, _ y: CGFloat) -> MarqueeTarget {
|
||||||
|
MarqueeTarget(id: id, kind: .lane, side: .trashed, frame: CGRect(x: 0, y: y, width: 100, height: 40))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("On the live side the band takes intersecting cards, and only cards")
|
||||||
|
func liveSideTakesCards() {
|
||||||
|
let targets = [
|
||||||
|
Self.card(card1, 0),
|
||||||
|
Self.card(card2, 100),
|
||||||
|
// A lane registered by mistake is still never swept: "click-drag rubber-bands across
|
||||||
|
// lanes" (04-interactions.md § Selection) — across, not over.
|
||||||
|
MarqueeTarget(id: lane1, kind: .lane, side: .live, frame: CGRect(x: 0, y: 0, width: 200, height: 400)),
|
||||||
|
// A trash row cannot be reached by a band that began on the board.
|
||||||
|
Self.card(card3, 10, side: .trashed)
|
||||||
|
]
|
||||||
|
let ids = MarqueeMath.selection(rect: CGRect(x: 0, y: 0, width: 50, height: 120), targets: targets, side: .live)
|
||||||
|
#expect(ids == [card1, card2])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("On the trash side the topmost intersecting row's kind wins")
|
||||||
|
func trashSideIsHomogeneousByKind() {
|
||||||
|
// Interleaved rows, the trash's own shape: card, lane, card.
|
||||||
|
let targets = [
|
||||||
|
Self.card(card1, 0, side: .trashed),
|
||||||
|
Self.laneEntry(laneX, 50),
|
||||||
|
Self.card(card2, 100, side: .trashed)
|
||||||
|
]
|
||||||
|
let all = CGRect(x: 0, y: 0, width: 50, height: 200)
|
||||||
|
|
||||||
|
// Begun on a card row: the lane row between the two cards is stepped over, exactly as a
|
||||||
|
// ⇧-range does (04-interactions.md ▸ The trash).
|
||||||
|
#expect(MarqueeMath.selection(rect: all, targets: targets, side: .trashed) == [card1, card2])
|
||||||
|
|
||||||
|
// Begun below it, so the lane row is topmost: only lane entries come back.
|
||||||
|
let lower = CGRect(x: 0, y: 60, width: 50, height: 200)
|
||||||
|
#expect(MarqueeMath.selection(rect: lower, targets: targets, side: .trashed) == [laneX])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A band touching nothing selects nothing")
|
||||||
|
func emptyBand() {
|
||||||
|
let targets = [Self.card(card1, 0), Self.card(card2, 100)]
|
||||||
|
#expect(MarqueeMath.selection(rect: CGRect(x: 500, y: 500, width: 10, height: 10), targets: targets, side: .live).isEmpty)
|
||||||
|
#expect(MarqueeMath.selection(rect: .zero, targets: [], side: .trashed).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The anchor's storage and its reload rule
|
||||||
|
|
||||||
|
/// One foreign reload, start to settled — `TransientBoardStateTests`' helper, borrowed for the one
|
||||||
|
/// piece of transient state this card adds.
|
||||||
|
@MainActor
|
||||||
|
private func reload(_ store: BoardStore) async {
|
||||||
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||||
|
await store.awaitQuiescence()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("TransientBoardState ▸ the selection anchor")
|
||||||
|
struct SelectionAnchorTests {
|
||||||
|
|
||||||
|
@Test("A sole selection anchors itself; a wholesale one does not; clearing drops it")
|
||||||
|
func anchorDefaults() {
|
||||||
|
let state = TransientBoardState()
|
||||||
|
|
||||||
|
state.select([card1], liveness: .live)
|
||||||
|
#expect(state.selectionAnchor == card1)
|
||||||
|
|
||||||
|
// "A marquee and wholesale selections pass no anchor deliberately."
|
||||||
|
state.select([card1, card2], liveness: .live)
|
||||||
|
#expect(state.selectionAnchor == nil)
|
||||||
|
|
||||||
|
// An explicit anchor wins over the default in both directions.
|
||||||
|
state.select([card1, card2, card3], liveness: .live, anchor: card2)
|
||||||
|
#expect(state.selectionAnchor == card2)
|
||||||
|
|
||||||
|
state.clearSelection()
|
||||||
|
#expect(state.selectionAnchor == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A vanished anchor is dropped by the reload, and a surviving one is kept")
|
||||||
|
func vanishedAnchorIsDropped() async throws {
|
||||||
|
let fixture = try makeLiveBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.select([card1, card2], liveness: .live, anchor: card1)
|
||||||
|
// A survivor of the same reload proves the rule is about the anchor, not about reloading.
|
||||||
|
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
|
||||||
|
await reload(store)
|
||||||
|
#expect(store.selection.ids == [card1])
|
||||||
|
#expect(store.transient.selectionAnchor == card1)
|
||||||
|
|
||||||
|
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
||||||
|
await reload(store)
|
||||||
|
#expect(store.selection.isEmpty)
|
||||||
|
#expect(store.transient.selectionAnchor == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A liveness flip is a vanish for the anchor too")
|
||||||
|
func flippedAnchorIsDropped() async throws {
|
||||||
|
let fixture = try makeLiveBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.select([card1, card2], liveness: .live, anchor: card1)
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First"))
|
||||||
|
await reload(store)
|
||||||
|
|
||||||
|
// "A flip is a vanish from its side of the boundary" (02-architecture.md's reload rule).
|
||||||
|
#expect(store.selection.ids == [card2])
|
||||||
|
#expect(store.transient.selectionAnchor == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An anchor no longer in the selection still ranges — membership is not the rule")
|
||||||
|
func anchorNeedNotBeSelected() throws {
|
||||||
|
let fixture = try makeLiveBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
let state = TransientBoardState()
|
||||||
|
|
||||||
|
// A ⌘-click that toggled the anchor's own row out leaves the anchor standing.
|
||||||
|
state.select([card2, card3], liveness: .live, anchor: card1)
|
||||||
|
#expect(state.selectionAnchor == card1)
|
||||||
|
let outcome = click(target(card3, .card), .shift, selection: state.selection, anchor: state.selectionAnchor, in: snapshot)
|
||||||
|
#expect(outcome.selection == set([card1, card2, card3]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Select All
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("BoardStore ▸ Select All")
|
||||||
|
struct SelectAllTests {
|
||||||
|
|
||||||
|
@Test("Select All takes every rendered card — tombstones excluded, lanes never")
|
||||||
|
func liveBranch() throws {
|
||||||
|
let fixture = try makeLiveBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.select([lane2], liveness: .live)
|
||||||
|
store.selectAll()
|
||||||
|
|
||||||
|
// "All visible cards on the board" (04-interactions.md ▸ The map).
|
||||||
|
#expect(store.selection == set([card1, card2, card3, card5]))
|
||||||
|
// The lane the anchor named is not in the new set, so the anchor goes with it.
|
||||||
|
#expect(store.transient.selectionAnchor == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An anchor inside the new set survives Select All")
|
||||||
|
func anchorSurvivesWhenStillInside() throws {
|
||||||
|
let fixture = try makeLiveBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.select([card3], liveness: .live)
|
||||||
|
store.selectAll()
|
||||||
|
#expect(store.transient.selectionAnchor == card3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("On the trash side Select All stays within the selection's kind")
|
||||||
|
func trashBranchIsHomogeneousByKind() throws {
|
||||||
|
let fixture = try makeTrashBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
store.transient.isTrashVisible = true
|
||||||
|
|
||||||
|
store.select([card2], liveness: .trashed)
|
||||||
|
store.selectAll()
|
||||||
|
#expect(store.selection == set([card1, card2, card3], .trashed))
|
||||||
|
|
||||||
|
store.select([laneX], liveness: .trashed)
|
||||||
|
store.selectAll()
|
||||||
|
#expect(store.selection == set([laneX, laneY], .trashed))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The trash branch is narrow: hidden, live, empty, or ghost selections take the board")
|
||||||
|
func trashBranchFallsThrough() throws {
|
||||||
|
let fixture = try makeTrashBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
// Hidden — the column is invisible to every gesture (04 ▸ The trash).
|
||||||
|
store.select([card1], liveness: .trashed)
|
||||||
|
store.selectAll()
|
||||||
|
#expect(store.selection.liveness == .live)
|
||||||
|
|
||||||
|
// Shown, but nothing tombstoned is selected.
|
||||||
|
store.transient.isTrashVisible = true
|
||||||
|
store.clearSelection()
|
||||||
|
store.selectAll()
|
||||||
|
#expect(store.selection.liveness == .live)
|
||||||
|
|
||||||
|
// Shown, trashed side, but the ids name no row: a guess would be worse than the board.
|
||||||
|
store.select([card5], liveness: .trashed)
|
||||||
|
store.selectAll()
|
||||||
|
#expect(store.selection.liveness == .live)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Select All on a board with no rendered cards clears rather than selecting an empty set")
|
||||||
|
func emptyBoardClears() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.select([lane1], liveness: .live)
|
||||||
|
store.selectAll()
|
||||||
|
#expect(store.selection.isEmpty)
|
||||||
|
#expect(store.transient.selectionAnchor == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user