Phase 2 swaps every consumer: Liveness and its ancestor walk are gone, replaced by ItemContainer — a UUID set plus the container side it lives on, presence the whole test, one selection boundary instead of the old liveness law. Deletion stages by place: board cards move to the trash at a store-minted head rank, trash-side delete is permanent behind its confirmation, Delete Immediately skips the trash from anywhere, lane delete captures the subtree and removes the folder. Restore has no method at all — moveCards resolves members in either container, so drag-out and cut-paste are the ordinary moves 13 calls them, registering ordinary Move steps. The delete inverse moves the card back to its captured lane and rank; redo replays the captured trash rank, a value the gesture actually wrote; lane undo recreates the subtree byte-faithfully in session. Purges register nothing — where 13's trash section contradicts its own Rules on that, Rules wins, filed for ruling. Staleness collapsed to present-or-absent: a container is a path, so a foreign restore fails the delete step's expectation structurally. Legacy tombstones migrate on the loose-file tail hook, cards oldest-first so minting above top reproduces the retired newest-first column, lanes returning live, one folded loss row naming both directions. Put Back, restoreByDrag, receiveRestoredCards, TrashEntry, and the kind machinery are deleted; the trash column renders the container correctly with its full face rework left to phase 3. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
487 lines
25 KiB
Swift
487 lines
25 KiB
Swift
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.
|
|
///
|
|
/// The **one** place a kind is written down is the clipboard manifest, which has no snapshot to
|
|
/// re-derive it from — "the cards-XOR-lanes selection rule means the clipboard holds cards or lanes,
|
|
/// never both" (04-interactions.md ▸ Clipboard). Hence `String`-backed and `Codable`: those raw
|
|
/// spellings are pasteboard API, decoded after a relaunch, and they are the case names so there is
|
|
/// no second vocabulary to keep in step.
|
|
public enum SelectionKind: String, Codable, Sendable, Equatable {
|
|
case card
|
|
case lane
|
|
}
|
|
|
|
/// What a pointer click names: an item, its level, and the container the surface it was clicked on
|
|
/// belongs to.
|
|
///
|
|
/// The **container is the surface's, not the item's** — a card face is always `.board` and a trash
|
|
/// row is always `.trash`, because that is what the user clicked. A click on a surface whose item
|
|
/// crossed containers 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 container: ItemContainer
|
|
|
|
public init(id: ItemID, kind: SelectionKind, container: ItemContainer) {
|
|
self.id = id
|
|
self.kind = kind
|
|
self.container = container
|
|
}
|
|
}
|
|
|
|
/// 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 **two** axes now — cards XOR lanes (§ Selection) and board XOR trash (§ The
|
|
/// trash's "single container rule replacing the old liveness law") — and the third, kind-inside-the
|
|
/// -trash, retired with the lane entries it separated: "Cards only. Lanes are never trashed".
|
|
/// Both surviving axes are 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 card) 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 container (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.
|
|
/// - filter: the live search filter (04 § Search). Only the ⇧-branch reads it — a range walks
|
|
/// what is *on the board*, which under a search is the survivors — because the other two
|
|
/// name their target outright and a click on something the user can see needs no permission
|
|
/// from the predicate.
|
|
public static func click(
|
|
_ target: SelectionTarget,
|
|
modifier: ClickModifier,
|
|
selection: ItemReferenceSet,
|
|
anchor: ItemID?,
|
|
snapshot: BoardModel,
|
|
togglesOnRepeat: Bool = false,
|
|
filter: SearchFilter = .inactive
|
|
) -> 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, filter: filter)
|
|
}
|
|
}
|
|
|
|
// 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.container == target.container, selection.ids == [target.id] {
|
|
return .cleared
|
|
}
|
|
return Outcome(
|
|
selection: ItemReferenceSet(ids: [target.id], container: target.container),
|
|
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 card clicked while board 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.container == target.container,
|
|
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, container: target.container),
|
|
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 (container, 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,
|
|
filter: SearchFilter
|
|
) -> Outcome {
|
|
guard let anchor,
|
|
let span = range(
|
|
from: anchor,
|
|
to: target.id,
|
|
kind: target.kind,
|
|
in: target.container,
|
|
snapshot: snapshot,
|
|
filter: filter
|
|
)
|
|
else {
|
|
return plain(target, selection: selection, togglesOnRepeat: false)
|
|
}
|
|
return Outcome(
|
|
selection: ItemReferenceSet(ids: span, container: target.container),
|
|
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
|
|
/// (container, 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).
|
|
///
|
|
/// **A filtered endpoint is a missing one**, which needs no rule of its own: a card the search
|
|
/// hid is absent from the list, so a range aimed at it answers `nil` and each caller degrades
|
|
/// exactly as it does for a card an agent deleted. The span between two *visible* endpoints
|
|
/// likewise collects only survivors — 04 § Search's "ranges … read [the filter]".
|
|
public static func range(
|
|
from: ItemID,
|
|
to: ItemID,
|
|
kind: SelectionKind,
|
|
in container: ItemContainer,
|
|
snapshot: BoardModel,
|
|
filter: SearchFilter = .inactive
|
|
) -> Set<ItemID>? {
|
|
let list = order(of: kind, in: container, snapshot: snapshot, filter: filter)
|
|
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 (container, kind) pair — **the single place a "what's on the
|
|
/// board, in what order" question is answered** for the pointer.
|
|
///
|
|
/// **The search filter threads in here and in `MarqueeTargetRegistry`'s membership, and nowhere
|
|
/// else** — the filter "is the single source of truth for what's on the board … ranges … all
|
|
/// read it" (04-interactions.md § Search), and every range, every Select All and every arrow
|
|
/// walk is stated in terms of these lists, so one parameter narrows all of them together.
|
|
///
|
|
/// It defaults to `.inactive` so the many callers with no query in hand (the drag's flatten
|
|
/// order, a lane-index lookup, the successor's container) read exactly as they did before the
|
|
/// filter existed; the callers that *are* the board's input grammar pass the store's query.
|
|
///
|
|
/// **The lane list takes no filter**, because a card query hides no lane — see `SearchFilter`.
|
|
/// **`(.trash, .lane)` is empty by construction**: "Cards only. Lanes are never trashed"
|
|
/// (03-board-ui.md § Trash), so there is no such list to walk rather than a rule saying not to.
|
|
public static func order(
|
|
of kind: SelectionKind,
|
|
in container: ItemContainer,
|
|
snapshot: BoardModel,
|
|
filter: SearchFilter = .inactive
|
|
) -> [ItemID] {
|
|
switch (container, kind) {
|
|
case (.board, .card): boardCards(in: snapshot, filter: filter)
|
|
case (.board, .lane): lanes(in: snapshot)
|
|
case (.trash, .card): trashCards(in: snapshot, filter: filter)
|
|
case (.trash, .lane): []
|
|
}
|
|
}
|
|
|
|
/// The board's 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.
|
|
///
|
|
/// **The filter narrows the walk in place**, which is what makes a search-time ⇧-range and
|
|
/// Select All read the same board the masonry drew: `LaneView.renderedCards` applies the same
|
|
/// predicate to the same cards, one lane at a time, and this is that collection flattened.
|
|
public static func boardCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
|
|
var ids: [ItemID] = []
|
|
for lane in snapshot.lanes {
|
|
for card in lane.cards where filter.matches(card) {
|
|
ids.append(card.id)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
/// The board's lanes, left to right.
|
|
///
|
|
/// **No search filter, deliberately**: 04 § Search filters *cards*, and a lane whose body the
|
|
/// query empties is still a lane on the board — the width division is layout, and the badge
|
|
/// showing `0` is the honest report. So the lane domain's ranges, arrows and moves are the one
|
|
/// part of the board grammar a search does not narrow.
|
|
public static func lanes(in snapshot: BoardModel) -> [ItemID] {
|
|
snapshot.lanes.map(\.id)
|
|
}
|
|
|
|
/// The trash's cards, top to bottom — `snapshot.trash` itself, which the loader already sorted
|
|
/// by `order` like any lane's children (03-board-ui.md § Trash: "the trash sorts by `order` like
|
|
/// any lane", newest-first falling out of the ranks rather than a timestamp sort).
|
|
///
|
|
/// **Filtered like any lane** (03-board-ui.md § Trash: "shown, its cards participate in the
|
|
/// filter exactly like any other card") — the same predicate `TrashLaneView` applies to the same
|
|
/// cards, so a trash-side range walks exactly what the column is showing.
|
|
public static func trashCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
|
|
snapshot.trash.filter { filter.matches($0) }.map(\.id)
|
|
}
|
|
|
|
// MARK: - The current selection's kind
|
|
|
|
/// Which level the selection holds, or `nil` when it holds nothing its container renders.
|
|
///
|
|
/// **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 trash answers `.card` or nothing**, because lanes are never trashed — which is why the
|
|
/// trash's old kind axis (card entries XOR lane entries) has no code left anywhere.
|
|
public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? {
|
|
guard !selection.isEmpty else { return nil }
|
|
switch selection.container {
|
|
case .trash:
|
|
return snapshot.trash.contains { selection.ids.contains($0.id) } ? .card : nil
|
|
case .board:
|
|
for lane in snapshot.lanes {
|
|
if selection.ids.contains(lane.id) { return .lane }
|
|
if lane.cards.contains(where: { selection.ids.contains($0.id) }) { return .card }
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Successor on delete
|
|
|
|
/// What ⌫ selects after deleting `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.
|
|
///
|
|
/// **Both stagings of Delete get one** (04, resettled 2026-07-28 — "one Delete vocabulary,
|
|
/// staged by place"): `container` says which side the gesture ran on, and the trash walks its own
|
|
/// ordered cards exactly as a lane walks its own. The permanent delete is as deliberate an act as
|
|
/// the move-to-trash, so it keeps the repeatable-keystroke property the rule exists for.
|
|
///
|
|
/// **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`'s delete paths and by nothing on the reload path.
|
|
///
|
|
/// **The container is what the surface is *showing*.** Under a search the successor must be a
|
|
/// card the user can see — picking a hidden neighbour would hand the selection straight back to
|
|
/// `constrainToSearch(in:)` to drop, which is a deselect wearing a successor's clothes. So the
|
|
/// filter narrows the container, and repeated ⌫ walks down the *filtered* lane.
|
|
public static func successor(
|
|
afterDeleting ids: Set<ItemID>,
|
|
in container: ItemContainer = .board,
|
|
snapshot: BoardModel,
|
|
filter: SearchFilter = .inactive
|
|
) -> ItemID? {
|
|
guard !ids.isEmpty else { return nil }
|
|
let selection = ItemReferenceSet(ids: ids, container: container)
|
|
guard let kind = kind(of: selection, in: snapshot) else { return nil }
|
|
|
|
let siblings: [ItemID]
|
|
switch (container, kind) {
|
|
case (.trash, _):
|
|
siblings = trashCards(in: snapshot, filter: filter)
|
|
case (.board, .lane):
|
|
siblings = lanes(in: snapshot)
|
|
case (.board, .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 = boardCards(in: snapshot, filter: filter).last(where: { ids.contains($0) }),
|
|
let lane = snapshot.lanes.first(where: { lane in
|
|
lane.cards.contains { $0.id == last }
|
|
})
|
|
else { return nil }
|
|
siblings = lane.cards.filter { filter.matches($0) }.map(\.id)
|
|
}
|
|
|
|
let doomed = siblings.indices.filter { ids.contains(siblings[$0]) }
|
|
guard let first = doomed.first, let last = doomed.last else { return nil }
|
|
if let after = siblings[(last + 1)...].first(where: { !ids.contains($0) }) { return after }
|
|
return siblings[..<first].last { !ids.contains($0) }
|
|
}
|
|
}
|
|
|
|
// MARK: - The rubber band
|
|
|
|
/// One item the marquee can sweep: its identity, its level, its container, 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 container: ItemContainer
|
|
public var frame: CGRect
|
|
|
|
public init(id: ItemID, kind: SelectionKind, container: ItemContainer, frame: CGRect) {
|
|
self.id = id
|
|
self.kind = kind
|
|
self.container = container
|
|
self.frame = frame
|
|
}
|
|
}
|
|
|
|
/// What a rubber band selects, as a pure function of the band, the drawn frames, and the container
|
|
/// the band started in (`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
|
|
/// `container` 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 board 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 filter below keeps the rule true even if one were.
|
|
///
|
|
/// **There is no kind rule any more.** Under the tombstone model the trash interleaved card rows and
|
|
/// lane rows in one column, so the band needed a topmost-wins tie-break to stay homogeneous by kind;
|
|
/// lanes are never trashed now, so both containers hold cards and the rule is one line for both.
|
|
public enum MarqueeMath {
|
|
|
|
/// The ids `rect` sweeps.
|
|
public static func selection(
|
|
rect: CGRect,
|
|
targets: [MarqueeTarget],
|
|
in container: ItemContainer
|
|
) -> Set<ItemID> {
|
|
Set(
|
|
targets.lazy
|
|
.filter { $0.container == container && $0.kind == .card && rect.intersects($0.frame) }
|
|
.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.
|
|
///
|
|
/// Used by `NavigationMath`, which breaks its score ties with it: 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
|
|
}
|
|
}
|