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, the anchor a subsequent ⇧-click would range /// from, and the head a subsequent arrow would step 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`). /// /// **The head is always the clicked item**, in every branch below — the ⇧-branch included, where /// the anchor deliberately stays put. That asymmetry is the definition of the two: /// `TransientBoardState.selectionHead` is where the *next* step starts, and a ⇧-click moves it /// exactly as a plain one does. public struct Outcome: Sendable, Equatable { public var selection: ItemReferenceSet public var anchor: ItemID? public var head: ItemID? public init(selection: ItemReferenceSet, anchor: ItemID?, head: ItemID? = nil) { self.selection = selection self.anchor = anchor self.head = head } /// Nothing selected, nothing to range from, nowhere to step from — the toggle-off outcomes. static let cleared = Outcome(selection: .empty, anchor: nil, head: 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, head: 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, head: 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 { guard let anchor, let span = range(from: anchor, to: target.id, kind: target.kind, on: target.side, in: snapshot) else { return plain(target, selection: selection, togglesOnRepeat: false) } return Outcome( selection: ItemReferenceSet(ids: span, liveness: target.side), anchor: anchor, head: target.id ) } /// The ids between two items in one order list, inclusive — **the span both extension gestures /// select**, ⇧-click and ⇧-arrow alike. /// /// It is a function rather than a branch inside `shift` because the keyboard needs the identical /// answer: "a ⇧-arrow extends" (04-interactions.md ▸ Grammar) means exactly the range a ⇧-click /// to the same item would produce, and two implementations of one span is two chances for the /// pointer and the keyboard to disagree about what a range is. /// /// **`nil` means the two do not share a list**, which folds the vanished endpoint, the nil /// anchor's caller-side absence, and every axis crossing into one test — a list is exactly one /// (side, kind) pair. The callers differ on what they do with that: a click degrades to a plain /// click (it names an unambiguous target), while a ⇧-arrow goes inert (its next step is /// ambiguous). public static func range( from: ItemID, to: ItemID, kind: SelectionKind, on side: Liveness, in snapshot: BoardModel ) -> Set? { let list = order(of: kind, on: side, in: snapshot) guard let start = list.firstIndex(of: from), let end = list.firstIndex(of: to) else { return nil } return Set(start <= end ? list[start...end] : list[end...start]) } // 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: - Successor on delete /// What ⌫ selects after tombstoning `ids` — 04-interactions.md ▸ The map's Finder-style /// successor sibling, as a pure function of the **pre-write** snapshot. /// /// > Selection moves to the deleted item's successor sibling, Finder-style (next card in the /// > lane, next lane on the board; the last sibling's predecessor otherwise; empty container = /// > nothing selected) — repeated ⌫ walks down a lane. /// /// Three decisions the wording implies and this states: /// /// - **The container is the *last* deleted item's**, in flatten order — the same "last member" /// the ⌘N target rule and paste anchoring already share. A selection spanning lanes therefore /// lands in the rightmost/bottom-most one, which is where the user was working. /// - **The survivor search is forward first, then backward**: the first surviving sibling *after* /// the last deleted position, else the last surviving sibling *before* the first deleted one. /// Forward is what makes repeated ⌫ walk down a lane rather than bouncing. /// - **`nil` is a legitimate answer** — an emptied container selects nothing, and the caller /// clears. /// /// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's /// reload-survival rule: "the selection just shrinks"), which is why this is called by /// `BoardStore.delete` and by nothing on the reload path. public static func successor(afterDeleting ids: Set, in snapshot: BoardModel) -> ItemID? { guard !ids.isEmpty else { return nil } let selection = ItemReferenceSet(ids: ids, liveness: .live) guard let kind = kind(of: selection, in: snapshot) else { return nil } let container: [ItemID] switch kind { case .lane: container = liveLanes(in: snapshot) case .card: // The last selected card in flatten order names the lane; its lane's rendered cards are // the container the successor is drawn from. guard let last = liveCards(in: snapshot).last(where: { ids.contains($0) }), let lane = snapshot.lanes.first(where: { lane in !lane.isDeleted && lane.cards.contains { $0.id == last && !$0.isDeleted } }) else { return nil } container = lane.cards.filter { !$0.isDeleted }.map(\.id) } let doomed = container.indices.filter { ids.contains(container[$0]) } guard let first = doomed.first, let last = doomed.last else { return nil } if let after = container[(last + 1)...].first(where: { !ids.contains($0) }) { return after } return container[.. 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. /// /// Shared with `NavigationMath`, which breaks its score ties with it for the same reason: two /// candidates that a metric cannot separate must still be separated the same way twice. 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 } }