Materialize the trash — store, undo, and the container universe
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
This commit is contained in:
@@ -21,22 +21,22 @@ public enum SelectionKind: String, Codable, Sendable, Equatable {
|
||||
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.
|
||||
/// What a pointer click names: an item, its level, and the container the surface it was clicked on
|
||||
/// belongs to.
|
||||
///
|
||||
/// 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.
|
||||
/// 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 side: Liveness
|
||||
public var container: ItemContainer
|
||||
|
||||
public init(id: ItemID, kind: SelectionKind, side: Liveness) {
|
||||
public init(id: ItemID, kind: SelectionKind, container: ItemContainer) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.side = side
|
||||
self.container = container
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,15 +59,16 @@ public enum ClickModifier: Sendable, Equatable {
|
||||
/// (`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.
|
||||
/// 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 row) share one answer instead of four near-copies of it.
|
||||
/// 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
|
||||
@@ -100,7 +101,7 @@ public enum SelectionGrammar {
|
||||
/// The grammar, one call.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - target: what was clicked, with the surface's liveness side (see `SelectionTarget`).
|
||||
/// - 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`.
|
||||
@@ -145,18 +146,18 @@ public enum SelectionGrammar {
|
||||
selection: ItemReferenceSet,
|
||||
togglesOnRepeat: Bool
|
||||
) -> Outcome {
|
||||
if togglesOnRepeat, selection.liveness == target.side, selection.ids == [target.id] {
|
||||
if togglesOnRepeat, selection.container == target.container, selection.ids == [target.id] {
|
||||
return .cleared
|
||||
}
|
||||
return Outcome(
|
||||
selection: ItemReferenceSet(ids: [target.id], liveness: target.side),
|
||||
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 row clicked while live cards are) is not a mixed
|
||||
/// 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.
|
||||
///
|
||||
@@ -168,7 +169,7 @@ public enum SelectionGrammar {
|
||||
selection: ItemReferenceSet,
|
||||
snapshot: BoardModel
|
||||
) -> Outcome {
|
||||
guard selection.liveness == target.side,
|
||||
guard selection.container == target.container,
|
||||
let current = kind(of: selection, in: snapshot),
|
||||
current == target.kind
|
||||
else {
|
||||
@@ -183,7 +184,7 @@ public enum SelectionGrammar {
|
||||
return .cleared
|
||||
}
|
||||
return Outcome(
|
||||
selection: ItemReferenceSet(ids: ids, liveness: target.side),
|
||||
selection: ItemReferenceSet(ids: ids, container: target.container),
|
||||
anchor: target.id,
|
||||
head: target.id
|
||||
)
|
||||
@@ -195,7 +196,7 @@ public enum SelectionGrammar {
|
||||
///
|
||||
/// 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:
|
||||
/// 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(
|
||||
@@ -210,15 +211,15 @@ public enum SelectionGrammar {
|
||||
from: anchor,
|
||||
to: target.id,
|
||||
kind: target.kind,
|
||||
on: target.side,
|
||||
in: snapshot,
|
||||
in: target.container,
|
||||
snapshot: snapshot,
|
||||
filter: filter
|
||||
)
|
||||
else {
|
||||
return plain(target, selection: selection, togglesOnRepeat: false)
|
||||
}
|
||||
return Outcome(
|
||||
selection: ItemReferenceSet(ids: span, liveness: target.side),
|
||||
selection: ItemReferenceSet(ids: span, container: target.container),
|
||||
anchor: anchor,
|
||||
head: target.id
|
||||
)
|
||||
@@ -234,8 +235,8 @@ public enum SelectionGrammar {
|
||||
///
|
||||
/// **`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
|
||||
/// (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
|
||||
@@ -246,44 +247,47 @@ public enum SelectionGrammar {
|
||||
from: ItemID,
|
||||
to: ItemID,
|
||||
kind: SelectionKind,
|
||||
on side: Liveness,
|
||||
in snapshot: BoardModel,
|
||||
in container: ItemContainer,
|
||||
snapshot: BoardModel,
|
||||
filter: SearchFilter = .inactive
|
||||
) -> Set<ItemID>? {
|
||||
let list = order(of: kind, on: side, in: snapshot, filter: filter)
|
||||
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 (side, kind) pair — **the single place a "what's on the
|
||||
/// 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 four lists, so one parameter narrows all of them together.
|
||||
/// 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,
|
||||
on side: Liveness,
|
||||
in snapshot: BoardModel,
|
||||
in container: ItemContainer,
|
||||
snapshot: BoardModel,
|
||||
filter: SearchFilter = .inactive
|
||||
) -> [ItemID] {
|
||||
switch (side, kind) {
|
||||
case (.live, .card): liveCards(in: snapshot, filter: filter)
|
||||
case (.live, .lane): liveLanes(in: snapshot)
|
||||
case (.trashed, _): trashEntries(of: kind, in: snapshot, filter: filter)
|
||||
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): []
|
||||
}
|
||||
}
|
||||
|
||||
/// Live cards in **flatten order** — "lane `order` first, then card `order` (a cross-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).
|
||||
///
|
||||
@@ -293,85 +297,65 @@ public enum SelectionGrammar {
|
||||
/// **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 liveCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
|
||||
public static func boardCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
|
||||
var ids: [ItemID] = []
|
||||
for lane in snapshot.lanes where !lane.isDeleted {
|
||||
for card in lane.cards where !card.isDeleted && filter.matches(card) {
|
||||
for lane in snapshot.lanes {
|
||||
for card in lane.cards where filter.matches(card) {
|
||||
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.
|
||||
/// 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 liveLanes(in snapshot: BoardModel) -> [ItemID] {
|
||||
snapshot.lanes.filter { !$0.isDeleted }.map(\.id)
|
||||
public static func lanes(in snapshot: BoardModel) -> [ItemID] {
|
||||
snapshot.lanes.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").
|
||||
/// 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 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.
|
||||
///
|
||||
/// **Filtered like any lane** (03-board-ui.md § Trash) — the same predicate `TrashLaneView`
|
||||
/// applies to the same rows, so a trash-side range walks exactly what the column is showing.
|
||||
public static func trashEntries(
|
||||
of kind: SelectionKind,
|
||||
in snapshot: BoardModel,
|
||||
filter: SearchFilter = .inactive
|
||||
) -> [ItemID] {
|
||||
TrashModel.entries(of: snapshot)
|
||||
.filter { $0.isLaneEntry == (kind == .lane) && filter.matches($0) }
|
||||
.map(\.id)
|
||||
/// **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 the board renders on its own
|
||||
/// side.
|
||||
/// 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 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.
|
||||
/// **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 }
|
||||
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
|
||||
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
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Successor on delete
|
||||
|
||||
/// What ⌫ selects after tombstoning `ids` — 04-interactions.md ▸ The map's Finder-style
|
||||
/// 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
|
||||
@@ -389,49 +373,56 @@ public enum SelectionGrammar {
|
||||
/// - **`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.delete` and by nothing on the reload path.
|
||||
/// `BoardStore`'s delete paths and by nothing on the reload path.
|
||||
///
|
||||
/// **The container is what the lane is *showing*.** Under a search the successor must be a card
|
||||
/// the user can see — "nothing invisible stays selected" is the trash's phrasing of a rule the
|
||||
/// filter obeys too — and picking a hidden neighbour would hand the selection straight back to
|
||||
/// **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 snapshot: BoardModel,
|
||||
in container: ItemContainer = .board,
|
||||
snapshot: BoardModel,
|
||||
filter: SearchFilter = .inactive
|
||||
) -> ItemID? {
|
||||
guard !ids.isEmpty else { return nil }
|
||||
let selection = ItemReferenceSet(ids: ids, liveness: .live)
|
||||
let selection = ItemReferenceSet(ids: ids, container: container)
|
||||
guard let kind = kind(of: selection, in: snapshot) else { return nil }
|
||||
|
||||
let container: [ItemID]
|
||||
switch kind {
|
||||
case .lane:
|
||||
container = liveLanes(in: snapshot)
|
||||
case .card:
|
||||
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 = liveCards(in: snapshot, filter: filter).last(where: { ids.contains($0) }),
|
||||
guard let last = boardCards(in: snapshot, filter: filter).last(where: { ids.contains($0) }),
|
||||
let lane = snapshot.lanes.first(where: { lane in
|
||||
!lane.isDeleted && lane.cards.contains { $0.id == last && !$0.isDeleted }
|
||||
lane.cards.contains { $0.id == last }
|
||||
})
|
||||
else { return nil }
|
||||
container = lane.cards.filter { !$0.isDeleted && filter.matches($0) }.map(\.id)
|
||||
siblings = lane.cards.filter { filter.matches($0) }.map(\.id)
|
||||
}
|
||||
|
||||
let doomed = container.indices.filter { ids.contains(container[$0]) }
|
||||
let doomed = siblings.indices.filter { ids.contains(siblings[$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[..<first].last { !ids.contains($0) }
|
||||
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 side, and where it is drawn.
|
||||
/// 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
|
||||
@@ -439,57 +430,54 @@ public enum SelectionGrammar {
|
||||
public struct MarqueeTarget: Sendable, Equatable {
|
||||
public var id: ItemID
|
||||
public var kind: SelectionKind
|
||||
public var side: Liveness
|
||||
public var container: ItemContainer
|
||||
public var frame: CGRect
|
||||
|
||||
public init(id: ItemID, kind: SelectionKind, side: Liveness, frame: CGRect) {
|
||||
public init(id: ItemID, kind: SelectionKind, container: ItemContainer, frame: CGRect) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.side = side
|
||||
self.container = container
|
||||
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`).
|
||||
/// 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 `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 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 live branch filters to cards anyway so the rule holds even if one were.
|
||||
/// 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.
|
||||
///
|
||||
/// On the **trashed** side the band must additionally stay homogeneous by *kind*, because the
|
||||
/// trash's two row kinds interleave in one column. The rule is topmost-wins: the kind of the
|
||||
/// highest intersecting row decides, and rows of the other kind are dropped — so a band pulled
|
||||
/// down from a card row keeps collecting card rows and steps over the lane rows between them,
|
||||
/// exactly as a ⇧-range does.
|
||||
public static func selection(rect: CGRect, targets: [MarqueeTarget], side: Liveness) -> Set<ItemID> {
|
||||
let hits = targets.filter { $0.side == side && rect.intersects($0.frame) }
|
||||
guard !hits.isEmpty else { return [] }
|
||||
|
||||
switch side {
|
||||
case .live:
|
||||
return Set(hits.lazy.filter { $0.kind == .card }.map(\.id))
|
||||
case .trashed:
|
||||
guard let topmost = hits.min(by: isAbove) else { return [] }
|
||||
return Set(hits.lazy.filter { $0.kind == topmost.kind }.map(\.id))
|
||||
}
|
||||
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, or the topmost-kind rule would pick differently on identical input.
|
||||
/// the same way twice.
|
||||
///
|
||||
/// 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.
|
||||
/// 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 }
|
||||
|
||||
Reference in New Issue
Block a user