diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index a6ee50c..276e1d1 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -1314,11 +1314,78 @@ public final class BoardStore { /// 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 /// noted here, and creation notes itself in `beginPlaceholder`. - public func select(_ ids: Set, liveness: Liveness) { - transient.select(ids, liveness: liveness) + public func select(_ ids: Set, liveness: Liveness, anchor: ItemID? = nil) { + transient.select(ids, liveness: liveness, anchor: anchor) 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, 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). /// /// The last-active lane deliberately survives: it is a high-water mark of where the user has diff --git a/Kanban/LiveStore/SelectionGrammar.swift b/Kanban/LiveStore/SelectionGrammar.swift new file mode 100644 index 0000000..5e7de41 --- /dev/null +++ b/Kanban/LiveStore/SelectionGrammar.swift @@ -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 { + 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 + } +} diff --git a/Kanban/LiveStore/TransientBoardState.swift b/Kanban/LiveStore/TransientBoardState.swift index e07eb6d..e63674e 100644 --- a/Kanban/LiveStore/TransientBoardState.swift +++ b/Kanban/LiveStore/TransientBoardState.swift @@ -278,6 +278,23 @@ public final class TransientBoardState { /// set wholesale by the gesture that owns them. 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" /// means here rather than a separate flag. /// @@ -374,19 +391,27 @@ public final class TransientBoardState { // 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 - /// with the board UI. 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, liveness: Liveness) { + /// The grammar itself is `SelectionGrammar`'s — pure, testable, and the one funnel every click + /// surface goes through (`BoardStore.click`). This is the storage half, and its only rule of its + /// own is the **anchor default**: `nil` with a sole member anchors on that member, `nil` with + /// any other count anchors on nothing. That makes the two callers that pass nothing behave + /// 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, liveness: Liveness, anchor: ItemID? = nil) { 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() { selection = .empty + selectionAnchor = nil } /// 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 /// 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, /// 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 @@ -566,6 +597,16 @@ public final class TransientBoardState { if let lane = lastActiveLaneID, !live.contains(lane) { 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. diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index 3174c9b..44bc530 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -21,7 +21,10 @@ import SwiftUI /// - **Lane reorder** — the whole title bar is the drag surface (`LaneReorderSession`, /// `LaneReorderMath`); the travelling lane rides above its siblings while they show the would-be /// order. -/// - **The keyboard's narrow slice** — Return's create/rename dispatch and Escape's step outward. +/// - **The rubber band** — a drag from any empty surface sweeps a selection (`MarqueeSession`, +/// `MarqueeMath`); the strip owns the session and the target registry, and hands both down. +/// - **The keyboard's narrow slice** — Return's create/rename dispatch, Escape's step outward, and +/// Select All. /// /// - **The trash quasi-lane** — trailing, one fixed unit, joining and leaving the width division as /// View ▸ Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash). @@ -29,9 +32,7 @@ import SwiftUI /// ### What is deliberately not here yet /// /// The toolbar, search, and drag & drop's real machinery (multi-drag, cross-board locality, the -/// shadow's hold rule) all belong to later milestone cards. The **selection grammar** here is -/// likewise minimal — a click replaces the selection, and that is all: ⌘-click toggling, ⇧-click -/// ranges, the rubber band and the cards-XOR-lanes homogeneity rule are m5's selection-model card. +/// shadow's hold rule) all belong to later milestone cards. struct BoardView: View { let store: BoardStore @@ -64,6 +65,13 @@ struct BoardView: View { /// One drag out of the trash at a time, per window — same lifetime again. @State private var trashDrag = TrashDragSession() + /// One rubber band at a time, per window (`MarqueeSession`). + @State private var marquee = MarqueeSession() + + /// Where every sweepable item is drawn, in strip coordinates. Owned here because the band is — + /// the cards and trash rows only *register* into it (`MarqueeTargetRegistry`). + @State private var marqueeTargets = MarqueeTargetRegistry() + /// The name of the strip's coordinate space, which is what a drop out of the trash is resolved /// in: `LaneLayoutMath.laneIndex` reads an x measured from the strip's leading edge, outer margin /// included, and no global or lane-local space is that. @@ -110,48 +118,22 @@ struct BoardView: View { // reflow to make room. A drag whose lane has vanished from the snapshot proposes nothing // and shows the plain order; its release then cancels ("an emptied drag cancels itself"). let shown = move.map { LaneReorderMath.reordered(lanes, from: $0.from, to: $0.to) } ?? lanes - HStack(alignment: .top, spacing: spacing) { - ForEach(Array(shown.enumerated()), id: \.element.id) { position, lane in - laneSlot(lane, at: position, among: shown, standard: standard) - // "Appear/disappear is scale + fade … lanes ~0.9" (03-board-ui.md § Motion). - // A create, a delete and a Put Back all reach the strip as a lane arriving in - // or leaving this `ForEach`; whether that *performs* is decided upstream, at - // the reload that carried it (`Motion.reloadAnimates`) — a transition with no - // animated transaction around it is simply an appearance. - .transition(Motion.laneTransition(reduced: reduceMotion)) - } - if isTrashVisible { - // Trailing, always — the quasi-lane has no position of its own to lose, which is - // also why it never appears in the reorder proposal's inputs (those are built - // from `liveLanes`). - TrashLaneView( - store: store, - confirmations: confirmations, - drag: TrashRowDrag { x in laneUnder(x: x, standard: standard) }, - dragSession: trashDrag - ) - .frame(width: LaneLayoutMath.slotWidth(units: 1, standard: standard, gap: spacing)) - .frame(maxHeight: .infinity, alignment: .top) - // It arrives and leaves like a lane, because that is what it looks like — the - // column scales and fades while every real lane compresses to make room for its - // unit (03-board-ui.md § Motion, § Trash's re-divide). The transaction is the - // menu toggle's (`ShowTrashCommand`). - .transition(Motion.laneTransition(reduced: reduceMotion)) - } + ZStack(alignment: .topLeading) { + backdrop + laneStrip(shown, standard: standard, move: move) } - // The drag's reflow-to-make-room, keyed on the **drop proposal** and nothing else - // (03-board-ui.md § Motion: transactions are keyed narrowly, "on the drag's drop - // proposal … never on broad state"). The travelling lane's own offset changes on every - // pointer sample and none of those samples touch this value, so the replica keeps - // tracking the cursor 1:1 — which is the same bullet's other half. At the instant the - // proposal ticks, the lane's slot and its offset move by equal and opposite amounts, so - // animating both under one curve is what keeps it pinned under the cursor. - .animation(Motion.dragReflow(reduced: reduceMotion), value: move?.to) .padding(spacing) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + // The band, drawn **outside the padding** so its offset is a strip coordinate directly — + // and outside every animated modifier above, because 03-board-ui.md § Motion puts the + // marquee in the animation-free-by-construction list ("1:1 cursor following — animating + // input echo would be lag"). + .overlay(alignment: .topLeading) { marqueeBand } // The space a drop out of the trash is resolved in — see `BoardView.stripSpace`. It goes // on the padded container so x = 0 is the strip's leading edge with the outer margin - // included, which is the origin `LaneLayoutMath`'s arithmetic assumes. + // included, which is the origin `LaneLayoutMath`'s arithmetic assumes. Every marquee + // coordinate — the band's own drag samples and each registered item frame — is measured + // here too, so nothing ever converts between spaces. .coordinateSpace(.named(Self.stripSpace)) } .background(boardBackground) @@ -176,6 +158,100 @@ struct BoardView: View { .onKeyPress(.return) { handleReturn() } .onKeyPress(.escape) { handleEscape() } .onKeyPress(keys: [.delete], phases: .down) { handleDelete($0) } + // **Select All** (04-interactions.md ▸ The map). Edit ▸ Select All is the standard menu + // item and it dispatches `selectAll:` down the responder chain, so the board answers it as a + // responder rather than growing a second menu item with the same title — which titles-are-API + // forbids outright (04 ▸ Configurable bindings). A focused text field consumes it first, so + // ⌘A inside an inline editor stays text selection with no guard needed here. + .onCommand(#selector(NSText.selectAll(_:))) { store.selectAll() } + } + + // MARK: - The strip's layers + + /// The empty surface behind the lanes: a plain click clears the selection, a drag rubber-bands. + /// + /// `Color.clear` with a `contentShape` rather than a real fill — the board's *painted* + /// background is `boardBackground`, outside the geometry reader, and this layer exists only to + /// be hit. Modified clicks are deliberately no-ops: ⌘ and ⇧ on the backdrop name no target, and + /// Finder's own desktop behaves the same way. + private var backdrop: some View { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + guard ClickModifier.current == .plain else { return } + store.clearSelection() + } + .simultaneousGesture(marqueeControl.gesture(side: .live)) + } + + /// The lanes themselves, plus the trash column when it is shown. + @ViewBuilder + private func laneStrip(_ shown: [Lane], standard: CGFloat, move: (from: Int, to: Int)?) -> some View { + HStack(alignment: .top, spacing: spacing) { + ForEach(Array(shown.enumerated()), id: \.element.id) { position, lane in + laneSlot(lane, at: position, among: shown, standard: standard) + // "Appear/disappear is scale + fade … lanes ~0.9" (03-board-ui.md § Motion). + // A create, a delete and a Put Back all reach the strip as a lane arriving in + // or leaving this `ForEach`; whether that *performs* is decided upstream, at + // the reload that carried it (`Motion.reloadAnimates`) — a transition with no + // animated transaction around it is simply an appearance. + .transition(Motion.laneTransition(reduced: reduceMotion)) + } + if isTrashVisible { + // Trailing, always — the quasi-lane has no position of its own to lose, which is + // also why it never appears in the reorder proposal's inputs (those are built + // from `liveLanes`). + TrashLaneView( + store: store, + confirmations: confirmations, + drag: TrashRowDrag { x in laneUnder(x: x, standard: standard) }, + dragSession: trashDrag, + marquee: marqueeControl + ) + .frame(width: LaneLayoutMath.slotWidth(units: 1, standard: standard, gap: spacing)) + .frame(maxHeight: .infinity, alignment: .top) + // It arrives and leaves like a lane, because that is what it looks like — the + // column scales and fades while every real lane compresses to make room for its + // unit (03-board-ui.md § Motion, § Trash's re-divide). The transaction is the + // menu toggle's (`ShowTrashCommand`). + .transition(Motion.laneTransition(reduced: reduceMotion)) + } + } + // The drag's reflow-to-make-room, keyed on the **drop proposal** and nothing else + // (03-board-ui.md § Motion: transactions are keyed narrowly, "on the drag's drop + // proposal … never on broad state"). The travelling lane's own offset changes on every + // pointer sample and none of those samples touch this value, so the replica keeps + // tracking the cursor 1:1 — which is the same bullet's other half. At the instant the + // proposal ticks, the lane's slot and its offset move by equal and opposite amounts, so + // animating both under one curve is what keeps it pinned under the cursor. + // + // It stays on the `HStack` rather than moving out to the `ZStack`, so the marquee band + // drawn beside it is never inside an animated transaction (03 § Motion again). + .animation(Motion.dragReflow(reduced: reduceMotion), value: move?.to) + } + + /// The rubber band itself: a translucent accent fill with a hairline border, in strip + /// coordinates and **never animated** (03-board-ui.md § Motion — the marquee "tracks the cursor + /// 1:1", and an eased band visibly lags the mouse). + /// + /// Hit-testing off, because the band is feedback: the drag that draws it is already recognised, + /// and a rectangle that swallowed clicks would eat the release. + @ViewBuilder + private var marqueeBand: some View { + if let rect = marquee.rect { + Rectangle() + .fill(Color.accentColor.opacity(0.12)) + .frame(width: rect.width, height: rect.height) + .overlay(Rectangle().strokeBorder(Color.accentColor.opacity(0.5), lineWidth: 1)) + .offset(x: rect.minX, y: rect.minY) + .allowsHitTesting(false) + } + } + + /// What the strip lends its empty surfaces and its sweepable items — the band's session, the + /// registry, and the store it selects into (`MarqueeControl`). + private var marqueeControl: MarqueeControl { + MarqueeControl(session: marquee, registry: marqueeTargets, store: store) } // MARK: - Styling @@ -245,6 +321,7 @@ struct BoardView: View { columns: units, reorder: reorder, headerDrag: headerDrag(at: position, among: shown, standard: standard), + marquee: marqueeControl, openCard: openCard ) .frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading) diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index cd01fa0..ecc4ee0 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -28,7 +28,8 @@ struct LaneHeaderDrag { /// Leading SF Symbol from `icon` — lenient, an unknown name renders the `square.stack` default /// (`ItemSymbol`) — then the title or its quiet "Untitled" placeholder, a quiet secondary /// card-count badge, and a trailing quiet new-card button. **The whole bar is the drag surface**: -/// a plain click selects the lane, movement past a small threshold begins a reorder +/// a click selects the lane — toggling off on a repeat, exactly as empty space does +/// (04-interactions.md § Selection, settled) — and movement past a small threshold begins a reorder /// (`LaneReorderSession`). The one thing carved out of the drag region is the button, which sits in /// an overlay outside the gesture so a click on it can never be read as the beginning of a drag. /// @@ -64,6 +65,10 @@ struct LaneView: View { let headerDrag: LaneHeaderDrag + /// The strip's rubber band: the lane's empty space is one of its three surfaces, and every card + /// face registers its frame into the same registry (`MarqueeControl`). + let marquee: MarqueeControl + /// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar, "commits and opens /// the card window"). Supplied by the strip, which is supplied by the host: a lane has no /// business knowing about `WindowGroup` keys. @@ -289,10 +294,16 @@ struct LaneView: View { if reorder.isDragging(lane.id) { headerDrag.commit() } else { - // A plain click on the header always selects — unlike lane empty space, it does - // not toggle off. 04 gives the click-again-to-unselect behaviour to empty space - // only, and a full lane has no empty space to reach for. - store.select([lane.id], liveness: .live) + // **The header toggles like empty space** (04-interactions.md § Selection, + // settled): "a click on the already-selected lane's header unselects, one + // lane-click behavior everywhere, so a full lane keeps a pointer path out of + // selection". Hence the same `togglesOnRepeat` the empty space passes — the two + // surfaces differ only in where they are. + store.click( + SelectionTarget(id: lane.id, kind: .lane, side: .live), + modifier: .current, + togglesOnRepeat: true + ) } } } @@ -312,7 +323,12 @@ struct LaneView: View { Group { switch slot { case let .card(card): - CardFaceView(store: store, card: card, openCard: openCard) + CardFaceView( + store: store, + card: card, + registry: marquee.registry, + openCard: openCard + ) case .placeholder: NewCardStubView(store: store, openCard: openCard) } @@ -334,7 +350,19 @@ struct LaneView: View { guard !store.isReadOnly, !store.isEditingInline else { return } store.transient.beginPlaceholder(inLane: lane.id) } - .onTapGesture { toggleLaneSelection() } + // "Single click selects the lane (click again to unselect)" — the toggle the header + // shares (04-interactions.md § Selection), and the modifier grammar on top of it. + .onTapGesture { + store.click( + SelectionTarget(id: lane.id, kind: .lane, side: .live), + modifier: .current, + togglesOnRepeat: true + ) + } + // The rubber band's first surface — "click-drag rubber-bands across lanes". Simultaneous + // so the taps above stay instant; the band's own begin guard is what keeps a drag that + // started on a card face out of it (`MarqueeControl`). + .simultaneousGesture(marquee.gesture(side: .live)) // The same menu the header carries — "one menu, invoked on the header or lane empty // space alike" (03-board-ui.md § Lane, settled). .contextMenu { laneMenu } @@ -375,21 +403,6 @@ struct LaneView: View { store.selection.liveness == .live && store.selection.ids.contains(lane.id) } - /// Click on empty space: select, or clear when this lane is already *the* selection. - /// - /// "Single click selects the lane (click again to unselect)". The toggle-off tests for a - /// sole-membership selection rather than mere containment, so a future ⌘-click multi-selection - /// of lanes is narrowed by a click rather than wiped by it — the modifier grammar itself - /// (⌘-click toggles, ⇧-click range-extends, rubber band, homogeneity enforcement) is **m5's - /// selection-model card**, and nothing here should pre-empt it. - private func toggleLaneSelection() { - if store.selection.liveness == .live, store.selection.ids == [lane.id] { - store.clearSelection() - } else { - store.select([lane.id], liveness: .live) - } - } - /// The selection treatment: a subtle whole-lane accent wash and stroke. Deliberately quiet — /// 03-board-ui.md gives lane *colour* to the top-edge accent band, so selection must not read as /// a fill that would compete with it once that lands. @@ -476,6 +489,11 @@ private struct CardFaceView: View { let store: BoardStore let card: Card + + /// Where the rubber band looks up what it is sweeping. The face registers its own drawn frame + /// here and takes it out again when it leaves — see `View.marqueeTarget`. + let registry: MarqueeTargetRegistry + let openCard: (ItemID) -> Void /// The app-wide quick-style recents — see `LaneView`'s own note. @@ -512,8 +530,25 @@ private struct CardFaceView: View { // **Clicking never edits** (04-interactions.md ▸ Selection, a pivot from the pathfinder's // two-stage Finder rename): one click selects and that is all it does — no timer, no // slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or - // Board ▸ Rename. - .onTapGesture { store.select([card.id], liveness: .live) } + // Board ▸ Rename. The modifier grammar — plain replaces, ⌘ toggles, ⇧ ranges — is + // `SelectionGrammar`'s, reached through the store's one funnel. + .onTapGesture { + store.click(SelectionTarget(id: card.id, kind: .card, side: .live), modifier: .current) + } + // "A fast double-click opens the card window (⌘↩'s pointer twin)" (04 ▸ Selection). + // + // `simultaneousGesture` rather than a second `onTapGesture(count: 2)`, deliberately: a + // second tap recogniser on the same view makes the single click *wait* to see whether a + // second one arrives, and selection must stay instant. Simultaneous means the first click + // of the pair selects and the second opens — Finder's own behaviour. + // + // **Plain only.** ⌘ and ⇧ double-clicks are selection gestures that happened twice; opening + // a window out from under a range the user is still building would be a surprise. + .simultaneousGesture(TapGesture(count: 2).onEnded { + guard ClickModifier.current == .plain else { return } + openCard(card.id) + }) + .marqueeTarget(card.id, kind: .card, side: .live, in: registry) .contextMenu { cardMenu } .popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) { StyleEditorPopover(store: store, recents: appModel.styleRecents) diff --git a/Kanban/UI/Board/MarqueeSession.swift b/Kanban/UI/Board/MarqueeSession.swift new file mode 100644 index 0000000..56d18c7 --- /dev/null +++ b/Kanban/UI/Board/MarqueeSession.swift @@ -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) } + } +} diff --git a/Kanban/UI/Board/SelectionClicks.swift b/Kanban/UI/Board/SelectionClicks.swift new file mode 100644 index 0000000..37b375e --- /dev/null +++ b/Kanban/UI/Board/SelectionClicks.swift @@ -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) } + } +} diff --git a/Kanban/UI/Board/TrashLaneView.swift b/Kanban/UI/Board/TrashLaneView.swift index b05aac6..ac0ffdb 100644 --- a/Kanban/UI/Board/TrashLaneView.swift +++ b/Kanban/UI/Board/TrashLaneView.swift @@ -93,10 +93,12 @@ final class TrashDragSession { /// /// ### What is still a later card's /// -/// The **search filter** ("shown, it participates in the filter like any lane") and the full -/// **keyboard grammar** — arrow walks into and out of the column, ⇧-ranges that stop at both the -/// liveness and the kind boundary, the rubber band, ⌘C copy-out — are m5's. So is the drag's replica: -/// what ships here is the drop, with the target lane highlighted and the source row dimmed in place. +/// The **search filter** ("shown, it participates in the filter like any lane") and the **keyboard** +/// grammar — arrow walks into and out of the column, ⇧-arrows that go inert at both the liveness and +/// the kind boundary, ⌘C copy-out — are still owed. The *pointer* grammar is here: a row's click +/// runs the same `SelectionGrammar` the board does, and the column's empty space rubber-bands on the +/// trashed side. So is the drop, with the target lane highlighted and the source row dimmed in +/// place; the drag's replica is not. struct TrashLaneView: View { let store: BoardStore @@ -114,6 +116,11 @@ struct TrashLaneView: View { /// session has. let dragSession: TrashDragSession + /// The strip's rubber band. The column's empty space is its third surface, on the **trashed** + /// side — "a rubber-band stays on the side of the boundary it started on" (04-interactions.md ▸ + /// The trash) — and every row registers its frame into the same registry. + let marquee: MarqueeControl + /// Reduce Motion, for the row transition below — 10-accessibility.md names the trash /// specifically ("and trash animations all get reduced variants"). @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -207,7 +214,8 @@ struct TrashLaneView: View { entry: entry, confirmations: confirmations, drag: drag, - dragSession: dragSession + dragSession: dragSession, + registry: marquee.registry ) // A row is a tombstoned item, so it arrives and leaves in the card's dialect — // a delete files one in, a Put Back or a purge takes one out, and both halves of @@ -218,6 +226,11 @@ struct TrashLaneView: View { } .frame(maxWidth: .infinity, alignment: .topLeading) .padding(6) + .contentShape(Rectangle()) + // The band's trash-side surface. It only ever arms from the column's empty space — a + // drag begun on a row is that row's drag-out — and the begin guard makes that geometric + // rather than a matter of gesture priority (`MarqueeControl`). + .simultaneousGesture(marquee.gesture(side: .trashed)) } } } @@ -262,6 +275,10 @@ private struct TrashEntryRow: View { let drag: TrashRowDrag let dragSession: TrashDragSession + /// Where the rubber band looks up what it is sweeping — the card face's rule, on the trashed + /// side (`View.marqueeTarget`). + let registry: MarqueeTargetRegistry + private let cornerRadius: CGFloat = 6 var body: some View { @@ -295,6 +312,7 @@ private struct TrashEntryRow: View { .opacity(dragSession.isDragging(entry.id) ? 0.45 : 1) .contentShape(Rectangle()) .gesture(rowGesture) + .marqueeTarget(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry) .contextMenu { menu } } @@ -308,17 +326,20 @@ private struct TrashEntryRow: View { store.selection.liveness == .trashed && store.selection.ids.contains(entry.id) } - /// A click replaces the selection with this one row, on the **trashed** side. + /// A click selects this row on the **trashed** side, through the same grammar the board's + /// surfaces use — plain replaces, ⌘ toggles, ⇧ ranges (`SelectionGrammar`). /// - /// Replace-only is what enforces both invariants at once here: a selection that is always exactly - /// one row can never mix live with tombstoned, nor card entries with lane entries - /// (04-interactions.md ▸ The trash). The extension grammar — ⌘-click, ⇧-ranges that go inert at - /// both boundaries, the rubber band that stays on the side it started on — is **m5's - /// selection-model card**, and nothing here should pre-empt it. + /// The row's kind travels with the click, and that is what keeps the trash's second homogeneity + /// axis true: a ⌘-click across the card/lane-entry boundary replaces rather than mixing, and a + /// ⇧-range walks only its own kind's rows (04-interactions.md ▸ The trash). No `togglesOnRepeat` + /// — click-again-to-unselect is the lane's behaviour, not a row's. /// /// **A double click is two of these and nothing more**: no editor, no card window, no timer. private func select() { - store.select([entry.id], liveness: .trashed) + store.click( + SelectionTarget(id: entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed), + modifier: .current + ) } // MARK: - Drag out diff --git a/KanbanTests/SelectionGrammarTests.swift b/KanbanTests/SelectionGrammarTests.swift new file mode 100644 index 0000000..e9d8dd7 --- /dev/null +++ b/KanbanTests/SelectionGrammarTests.swift @@ -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, _ 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) + } +}