Lanes delete into the trash — rendering, grammar, drag, clipboard, a11y
Phase 2 completes the lanes-in-trash card. TrashEntry merges the trash's two kinds by rank in exactly ONE place (ItemPath.resolve's own merge deleted in favor of it — the three-merge-points finding shrinks instead of growing). TrashLaneRowView renders the opaque row — tertiary plate, level-default lane glyph never the lane's own icon, title + card count, no accents, no expansion; the column badge counts rendered rows. Selection grammar: kind-homogeneous trash selections — ranges skip the other kind, ⇧-extension stops at the kind boundary, plain arrows walk the merged order, marquee stays card-only (now load-bearing: rows register frames for arrows), Select All card-scoped; successor-on-purge crosses kinds like navigation as the interim for open Gap 7b5cbc90. Drag: TrashDrop accepts lane sessions (drop on shown trash deletes), restoreLanes routes a trash-sourced strip drop as an arrival-ranked within-board move with an undo step. Clipboard: ⌘X/⌘V lane restore via opaque lane subjects; fixed boardRoot(ofLaneFolder:) returning .trash as the root — a same-board restore looked like an import and would have reminted the lane it was restoring (pinned by test). A11y: row = one flattened "title, deleted lane, N cards" element with Delete/Reveal actions; BoardDiff crossings read lanes as deleted/restored, shown-trash churn digested at row level. Agent guide stays v7 — the literal already teaches lanes-trash-by-move and kind stamping; drift-guard pins those lines. README trash paragraph notes lanes. Both schemes 1893 tests / 322 suites green. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -43,9 +43,9 @@ import Foundation
|
||||
/// agent that re-files a card *and* retitles it made one change to the board, and two fragments
|
||||
/// counting the same card would read as two cards. Position wins because it is the change the
|
||||
/// board's shape shows.
|
||||
/// - **Crossing the trash boundary is a departure or an arrival, never a move** — see
|
||||
/// `between(_:_:includingTrash:)`, whose flag decides whether the trash is walked at all but never
|
||||
/// what an event that touches it is *called*.
|
||||
/// - **Crossing the trash boundary is a departure or an arrival, never a move** — at **both levels**
|
||||
/// (lanes rejoined the trash 2026-07-29) — see `between(_:_:includingTrash:)`, whose flag decides
|
||||
/// whether the trash is walked at all but never what an event that touches it is *called*.
|
||||
public struct BoardDiff: Sendable, Equatable {
|
||||
|
||||
/// One kind's four buckets. Disjoint by construction — see the type's note on precedence.
|
||||
@@ -129,7 +129,9 @@ public struct BoardDiff: Sendable, Equatable {
|
||||
/// - A **purge** and an **Empty Trash** are `deleted`, an outside writer dropping a folder
|
||||
/// straight into `.trash/` is `added`, and a trash card retitled or reordered in place is
|
||||
/// `edited` or `moved` — all of them silent while the column is hidden, because then the trash
|
||||
/// is not in the universe at all.
|
||||
/// is not in the universe at all. **Lane rows count in the lane buckets** the same way, so an
|
||||
/// Empty Trash over a trash holding two lanes says "2 lanes deleted" alongside its cards rather
|
||||
/// than understating what went.
|
||||
///
|
||||
/// The implied-events rule reaches the crossings too: a lane deleted by moving its cards into
|
||||
/// `.trash/` and then removing the folder is still one event, "1 lane deleted", not that plus
|
||||
@@ -142,8 +144,8 @@ public struct BoardDiff: Sendable, Equatable {
|
||||
var diff = BoardDiff()
|
||||
diff.boardChanged = boardSideDiffers(old, new, includingTrash: includingTrash)
|
||||
|
||||
let oldLanes = laneIndex(of: old)
|
||||
let newLanes = laneIndex(of: new)
|
||||
let oldLanes = laneIndex(of: old, includingTrash: includingTrash)
|
||||
let newLanes = laneIndex(of: new, includingTrash: includingTrash)
|
||||
|
||||
for id in newLanes.keys where oldLanes[id] == nil {
|
||||
diff.lanes.added.insert(id)
|
||||
@@ -153,7 +155,19 @@ public struct BoardDiff: Sendable, Equatable {
|
||||
}
|
||||
for (id, newLane) in newLanes {
|
||||
guard let oldLane = oldLanes[id] else { continue }
|
||||
if oldLane.order != newLane.order {
|
||||
// **The crossing rule, at the lane level** (lanes rejoined the trash 2026-07-29): a lane
|
||||
// that crossed into `.trash/` is a *delete* and one that came back out is a *restore*,
|
||||
// which is what the user watched happen — never "1 lane moved". It reads the same shown
|
||||
// or hidden, because a lane entering the trash leaves `lanes` either way; what the shown
|
||||
// column adds is the churn that never leaves the container — a purged row, an Empty
|
||||
// Trash, a foreign lane folder dropped straight into `.trash/`.
|
||||
if oldLane.container != newLane.container {
|
||||
if newLane.container == .trash {
|
||||
diff.lanes.deleted.insert(id)
|
||||
} else {
|
||||
diff.lanes.added.insert(id)
|
||||
}
|
||||
} else if oldLane.order != newLane.order {
|
||||
diff.lanes.moved.insert(id)
|
||||
} else if contentDiffers(oldLane, newLane) {
|
||||
diff.lanes.edited.insert(id)
|
||||
@@ -212,8 +226,44 @@ public struct BoardDiff: Sendable, Equatable {
|
||||
|
||||
// MARK: - Indices
|
||||
|
||||
private static func laneIndex(of snapshot: BoardModel) -> [ItemID: Lane] {
|
||||
Dictionary(uniqueKeysWithValues: snapshot.lanes.map { ($0.id, $0) })
|
||||
/// A lane the digest can see: on the strip, or — while the trash is being walked — as one of the
|
||||
/// column's opaque rows.
|
||||
///
|
||||
/// The sum type exists because the two are genuinely different values (`TrashedLane` is not a
|
||||
/// `Lane`, and deliberately so), while the three questions the comparison asks of a lane —
|
||||
/// which container, which rank, does its rendered content differ — have an answer for both.
|
||||
private enum LaneEntry {
|
||||
case live(Lane)
|
||||
case trashed(TrashedLane)
|
||||
|
||||
var container: ItemContainer {
|
||||
switch self {
|
||||
case .live: .board
|
||||
case .trashed: .trash
|
||||
}
|
||||
}
|
||||
|
||||
var order: Double {
|
||||
switch self {
|
||||
case let .live(lane): lane.order
|
||||
case let .trashed(lane): lane.order
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every lane the digest can see, with where it sits — `cardIndex`'s twin, and with the same
|
||||
/// flag: with `includingTrash` off the column's rows are simply not walked, which is what makes
|
||||
/// a purge in a hidden trash silent rather than something filtered back out downstream.
|
||||
private static func laneIndex(of snapshot: BoardModel, includingTrash: Bool) -> [ItemID: LaneEntry] {
|
||||
var index: [ItemID: LaneEntry] = [:]
|
||||
for lane in snapshot.lanes {
|
||||
index[lane.id] = .live(lane)
|
||||
}
|
||||
guard includingTrash else { return index }
|
||||
for lane in snapshot.trashedLanes {
|
||||
index[lane.id] = .trashed(lane)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/// Every card the digest can see, with **where it sits** — the lane it is in, or the trash.
|
||||
@@ -261,6 +311,28 @@ public struct BoardDiff: Sendable, Equatable {
|
||||
|| old.attachments != new.attachments
|
||||
}
|
||||
|
||||
/// Two lane entries' rendered content.
|
||||
///
|
||||
/// **A trashed row's rendered content is its title and nothing else** — the row draws a title and
|
||||
/// a held-card count and takes no styling accents (03-board-ui.md § Trash), so a `background` an
|
||||
/// agent wrote onto a folder sitting in the trash changes nothing anyone can see and must not
|
||||
/// announce. The count would be visible, but it is a fact about the subtree the loader counts and
|
||||
/// not a field on the row; a card added inside a trashed lane is churn in a container the design
|
||||
/// calls opaque, and the digest is deliberately as opaque about it.
|
||||
///
|
||||
/// A crossing never reaches here — it is decided one level up — so the mixed pair is unreachable
|
||||
/// and answers "nothing differs" rather than inventing a comparison between two kinds.
|
||||
private static func contentDiffers(_ old: LaneEntry, _ new: LaneEntry) -> Bool {
|
||||
switch (old, new) {
|
||||
case let (.live(old), .live(new)):
|
||||
contentDiffers(old, new)
|
||||
case let (.trashed(old), .trashed(new)):
|
||||
old.schema != new.schema || old.title != new.title
|
||||
case (.live, .trashed), (.trashed, .live):
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A lane's rendered content — the card's list, minus attachments (a lane has none) plus
|
||||
/// `width`, which is a lane's own visible property (03-board-ui.md § Lane). A lane's *cards*
|
||||
/// are diffed as cards, never folded in here.
|
||||
|
||||
@@ -2189,6 +2189,98 @@ public final class BoardStore: HealHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The lane restore: `.trash/` out to the strip, at a drop position** (03-board-ui.md § Trash:
|
||||
/// "Restoring is an ordinary move out … drag … a trashed lane row to a lane-strip slot";
|
||||
/// 04-interactions.md ▸ The trash: "dropping … a trashed lane row onto its own board's strip — is
|
||||
/// an ordinary move to the drop position").
|
||||
///
|
||||
/// **Its own method rather than a branch inside `moveLanes`**, because the two are different
|
||||
/// arithmetic wearing one word: a reorder permutes the strip's own lanes and has to drop the
|
||||
/// dragged lanes' rungs before consulting the neighbours, while a restore is an *arrival* — the
|
||||
/// lane is not on the ladder at all — which is exactly `receiveLanes`' shape. What it does not
|
||||
/// borrow from `receiveLanes` is that method's cross-board posture: this is a within-board move,
|
||||
/// so the identity travels untouched (no import boundary, no remint) and the gesture earns an
|
||||
/// undo step, which an arrival deliberately does not (13-native-undo.md's inverse inventory names
|
||||
/// "restore-by-move → move back in").
|
||||
///
|
||||
/// The cards ride along inside the folder, unread and unwritten, exactly as they did on the way
|
||||
/// in (`moveLanesToTrash`).
|
||||
public func restoreLanes(_ ids: Set<ItemID>, toIndex index: Int) {
|
||||
let members = snapshot.trashedLanes.filter { ids.contains($0.id) }
|
||||
guard !members.isEmpty else { return }
|
||||
|
||||
let root = rootURL
|
||||
let rendered = snapshot.lanes
|
||||
let target = min(max(0, index), rendered.count)
|
||||
|
||||
// What an undo puts back: the rank the row held in the trash, captured before the write.
|
||||
var arrivals: [(id: ItemID, order: Double, trashRank: Double, title: String?)] = []
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
// The shared two-step; the arriving lanes are not among the renumbered children.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: rendered.map(\.order),
|
||||
compacting: root,
|
||||
{ Ranks.insertionRanks(amongVisible: $0, at: target, count: members.count) }
|
||||
) else { return }
|
||||
let ranks = placed.placement
|
||||
|
||||
for (member, rank) in zip(members, ranks) {
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: ItemPath.trashLane(member.id).folder(under: root),
|
||||
toParent: root,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: rank
|
||||
)
|
||||
arrivals.append((
|
||||
id: member.id,
|
||||
order: rank,
|
||||
trashRank: member.order,
|
||||
title: member.title.value
|
||||
))
|
||||
}
|
||||
}
|
||||
guard landed != nil, !arrivals.isEmpty else { return }
|
||||
|
||||
// restore-by-move → **move back in** (13-native-undo.md ▸ Interaction with the trash), at the
|
||||
// trash rank the row was holding — the delete's step read from the other end.
|
||||
let trashFolder = Self.parentFolder(of: .trashLane(arrivals[0].id), under: root)
|
||||
let steps = arrivals.map { arrival in
|
||||
(
|
||||
restored: ItemPath.lane(arrival.id).folder(under: root),
|
||||
trashed: ItemPath.trashLane(arrival.id).folder(under: root),
|
||||
order: arrival.order,
|
||||
trashRank: arrival.trashRank
|
||||
)
|
||||
}
|
||||
registerStep(
|
||||
HistoryPhrase.name(.move, kind: .lane, count: steps.count),
|
||||
subject: arrivals.count == 1 ? arrivals[0].title : nil,
|
||||
undoExpects: steps.map { .present($0.restored, .order($0.order)) },
|
||||
redoExpects: steps.map { .present($0.trashed, .order($0.trashRank)) }
|
||||
) { _ in
|
||||
for step in steps {
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: step.restored,
|
||||
toParent: trashFolder,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: step.trashRank
|
||||
)
|
||||
}
|
||||
} redo: { _ in
|
||||
for step in steps {
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: step.trashed,
|
||||
toParent: root,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: step.order
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drag & drop commits
|
||||
|
||||
// The writes a released drag performs (04-interactions.md ▸ Drag and drop; the geometry that
|
||||
@@ -2484,9 +2576,24 @@ public final class BoardStore: HealHost {
|
||||
// fractal layout fixes the depth (`<root>/<lane>` and `<root>/<lane>/<card>`), so the URL
|
||||
// already carries it and a second parameter could only ever disagree with the first.
|
||||
|
||||
/// The board root a lane folder sits directly under.
|
||||
/// The board root a lane folder sits directly under — **or two levels under, when the lane is in
|
||||
/// a trash** (03-board-ui.md § Trash: `.trash/` is flat, so a trashed lane sits one level deeper
|
||||
/// than a live one).
|
||||
///
|
||||
/// The depth is a fact about the path and the fractal layout fixes both shapes, which is why this
|
||||
/// reads the parent's name rather than taking a second parameter that could disagree with the
|
||||
/// first. It matters at exactly one place and matters a lot there: the import boundary turns on
|
||||
/// source-root-versus-destination-root (`BoardWriter.moveItem`), so a trashed lane pasted back
|
||||
/// into its **own** board must answer `<root>` — answering `<root>/.trash` would make the restore
|
||||
/// an import, and the import would find the row's own identity in the destination (the trash
|
||||
/// counts in `identityOccurrences`) and remint the lane it was restoring.
|
||||
///
|
||||
/// The card twin needs no such clause: `<root>/.trash/<card>` is already two levels down, which
|
||||
/// is the depth a lane's card sits at.
|
||||
nonisolated static func boardRoot(ofLaneFolder folder: URL) -> URL {
|
||||
folder.deletingLastPathComponent()
|
||||
let parent = folder.deletingLastPathComponent()
|
||||
guard parent.lastPathComponent == IntegrityRules.trashFolderName else { return parent }
|
||||
return parent.deletingLastPathComponent()
|
||||
}
|
||||
|
||||
/// The board root a card folder sits two levels under.
|
||||
@@ -3207,6 +3314,21 @@ public final class BoardStore: HealHost {
|
||||
_ = moveToTrash(ItemPath.resolve(Set(cardIDs), in: .board, snapshot: snapshot).compactMap(Self.cardMove(of:)))
|
||||
}
|
||||
|
||||
/// **Drop-on-trash deletes, at the lane level** (04-interactions.md ▸ The trash, lanes extended
|
||||
/// 2026-07-29: "a lane drag over the shown trash proposes the delete alongside its strip slots").
|
||||
///
|
||||
/// `deleteByDrag`'s twin exactly, and for its reasons: the write is `moveLanesToTrash`, so a lane
|
||||
/// deleted by drop and one deleted by ⌫ are byte-indistinguishable afterwards, and it says
|
||||
/// **nothing about the selection** — a drag has no keystroke to keep repeatable and its run is not
|
||||
/// necessarily the selection at all.
|
||||
public func deleteLanesByDrag(laneIDs: [ItemID]) {
|
||||
let lanes = ItemPath.resolve(Set(laneIDs), in: .board, snapshot: snapshot).compactMap { path -> ItemID? in
|
||||
guard case let .lane(id) = path else { return nil }
|
||||
return id
|
||||
}
|
||||
_ = moveLanesToTrash(lanes)
|
||||
}
|
||||
|
||||
/// **The card window's Actions ▸ Delete** (05-card-window.md ▸ Actions: "Delete — moves the card
|
||||
/// to the trash … the window then dismisses itself").
|
||||
///
|
||||
|
||||
@@ -73,6 +73,78 @@ extension ItemContainer {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The trash's two kinds, in one order
|
||||
|
||||
/// One row of the trash column — a card, or a trashed lane's opaque unit (03-board-ui.md § Trash,
|
||||
/// re-ruled 2026-07-29: "Lane rows and cards interleave in the one trash column purely by trash
|
||||
/// rank").
|
||||
///
|
||||
/// **It exists so the interleave is written once.** The container's two kinds are two arrays on the
|
||||
/// snapshot, for the reason `BoardModel.trash` states — a trashed card is an ordinary card every
|
||||
/// card-shaped surface already reads, a trashed lane is an opaque row none of them may — but *rank
|
||||
/// order* is a question about the container as a whole, and it is asked by the column that draws the
|
||||
/// rows, by the grammar that ranges and navigates over them, and by the path resolver that batches
|
||||
/// them. Three merges would be three chances to disagree about what "the row below this one" is.
|
||||
///
|
||||
/// It carries the values rather than only the ids because the column needs them to draw; every other
|
||||
/// consumer reads the two derived facts, `id` and `kind`.
|
||||
public enum TrashEntry: Identifiable, Sendable, Equatable {
|
||||
|
||||
case card(Card)
|
||||
case lane(TrashedLane)
|
||||
|
||||
public var id: ItemID {
|
||||
switch self {
|
||||
case let .card(card): card.id
|
||||
case let .lane(lane): lane.id
|
||||
}
|
||||
}
|
||||
|
||||
/// The rank that decides where this row sits among the others — the one field both kinds carry
|
||||
/// for the same purpose.
|
||||
public var order: Double {
|
||||
switch self {
|
||||
case let .card(card): card.order
|
||||
case let .lane(lane): lane.order
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of the board's two selectable levels this row is — the kind axis, which reaches into
|
||||
/// the trash exactly as it governs the live board (04-interactions.md ▸ The trash: "a trash
|
||||
/// selection is either cards or lane rows, kind-homogeneous like the live board's own grammar").
|
||||
public var kind: SelectionKind {
|
||||
switch self {
|
||||
case .card: .card
|
||||
case .lane: .lane
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder this row names.
|
||||
public var path: ItemPath {
|
||||
switch self {
|
||||
case let .card(card): .trashCard(card.id)
|
||||
case let .lane(lane): .trashLane(lane.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BoardModel {
|
||||
|
||||
/// The trash's rows, top to bottom — **the container's one order**, both kinds interleaved by
|
||||
/// rank (03-board-ui.md § Trash).
|
||||
///
|
||||
/// The tie-break is the folder name's, `Ranks.sortedForDisplay`'s own, which is what the loader
|
||||
/// already applied within each kind: two rows minted the same rank by two writers order the same
|
||||
/// way twice.
|
||||
public var trashEntries: [TrashEntry] {
|
||||
Ranks.sortedForDisplay(
|
||||
trash.map(TrashEntry.card) + trashedLanes.map(TrashEntry.lane),
|
||||
order: \.order,
|
||||
name: { $0.id.rawValue }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ItemPath
|
||||
|
||||
/// Where an item's folder sits under a board root, as identity components rather than as a URL.
|
||||
@@ -159,9 +231,9 @@ extension ItemPath {
|
||||
/// for the same reason).
|
||||
///
|
||||
/// **The trash's order interleaves its two kinds by rank** (03-board-ui.md § Trash: "lane rows
|
||||
/// and cards interleave in the one trash column purely by trash rank"), which is why the two
|
||||
/// arrays are merged here rather than concatenated: the column's order is the batch's order, and
|
||||
/// a selection is kind-homogeneous anyway, so the merge costs nothing the one time it matters.
|
||||
/// and cards interleave in the one trash column purely by trash rank"), which is why the walk is
|
||||
/// `trashEntries` rather than the two arrays concatenated: the column's order is the batch's
|
||||
/// order, and it is stated in exactly one place.
|
||||
///
|
||||
/// Ids the container does not hold are simply absent, which is every caller's standing posture:
|
||||
/// a selection the next reload will drop writes nothing rather than being refused.
|
||||
@@ -181,10 +253,7 @@ extension ItemPath {
|
||||
}
|
||||
}
|
||||
case .trash:
|
||||
let entries = snapshot.trash.map { (order: $0.order, path: ItemPath.trashCard($0.id)) }
|
||||
+ snapshot.trashedLanes.map { (order: $0.order, path: ItemPath.trashLane($0.id)) }
|
||||
for entry in Ranks.sortedForDisplay(entries, order: \.order, name: { $0.path.id.rawValue })
|
||||
where ids.contains(entry.path.id) {
|
||||
for entry in snapshot.trashEntries where ids.contains(entry.id) {
|
||||
result.append(entry.path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import Foundation
|
||||
/// "The filter is the single source of truth for 'what's on the board': layout, drop zones, marquee,
|
||||
/// ranges, arrow nav, and lane count badges all read it." They read it *here* — the masonry through
|
||||
/// `LaneView.renderedCards`, the ranges and Select All through `SelectionGrammar`'s order lists, the
|
||||
/// trash through `SelectionGrammar.trashCards`, and the selection through
|
||||
/// trash through `SelectionGrammar`'s `trashCards`/`trashLanes`/`trashRows`, and the selection through
|
||||
/// `TransientBoardState.constrainToSearch(in:)`. There is deliberately no second spelling of "does
|
||||
/// this card match" anywhere, and no stored result set to go stale (`TransientBoardState`, kind 2).
|
||||
///
|
||||
@@ -91,6 +91,15 @@ public struct SearchFilter: Sendable, Equatable {
|
||||
matches(title: lane.title.value, body: "")
|
||||
}
|
||||
|
||||
/// A trash row of either kind — the column's own predicate, dispatching to the two above so the
|
||||
/// merged order (`BoardModel.trashEntries`) filters exactly as its two halves do.
|
||||
public func matches(_ entry: TrashEntry) -> Bool {
|
||||
switch entry {
|
||||
case let .card(card): matches(card)
|
||||
case let .lane(lane): matches(lane)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The visible universe
|
||||
|
||||
/// Every id the filter leaves visible in `container` — **the universe
|
||||
|
||||
@@ -272,9 +272,14 @@ public enum SelectionGrammar {
|
||||
/// 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 *live* lane — see
|
||||
/// `SearchFilter`. **`(.trash, .lane)` is the trash's lane rows** (04-interactions.md ▸ The
|
||||
/// trash, lanes rejoined 2026-07-29): the list is empty until the rows are addressable by the
|
||||
/// grammar, which is where the pointer and keyboard vocabulary for them lands.
|
||||
/// `SearchFilter`. **The trash's lane list is filtered like its cards**, and that asymmetry is
|
||||
/// the opaque unit's own (03-board-ui.md § Trash: "The row matches the search filter by lane
|
||||
/// title only") — a trashed lane is a row in a column, not a container a query can empty.
|
||||
///
|
||||
/// **Both trash lists are kind-narrowed slices of one order** (`BoardModel.trashEntries`,
|
||||
/// 04-interactions.md ▸ The trash: "⇧-click ranges skip rows of the other kind"), which is what
|
||||
/// makes a range skip the other kind rather than needing a rule that says so: a list is exactly
|
||||
/// one (container, kind) pair, and the rows in between simply are not in it.
|
||||
public static func order(
|
||||
of kind: SelectionKind,
|
||||
in container: ItemContainer,
|
||||
@@ -285,7 +290,7 @@ public enum SelectionGrammar {
|
||||
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): []
|
||||
case (.trash, .lane): trashLanes(in: snapshot, filter: filter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +335,27 @@ public enum SelectionGrammar {
|
||||
snapshot.trash.filter { filter.matches($0) }.map(\.id)
|
||||
}
|
||||
|
||||
/// The trash's **lane rows**, top to bottom — the opaque units (03-board-ui.md § Trash, lanes
|
||||
/// rejoined 2026-07-29), filtered by title alone (`SearchFilter.matches(_ lane:)`).
|
||||
///
|
||||
/// Its own list rather than a kind flag on `trashCards` because that is what an order list *is*
|
||||
/// here: one (container, kind) pair, and the pair is what a ⇧-range walks. The rows' positions
|
||||
/// among the cards are `trashRows`' business.
|
||||
public static func trashLanes(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
|
||||
snapshot.trashedLanes.filter { filter.matches($0) }.map(\.id)
|
||||
}
|
||||
|
||||
/// **Every row the trash column shows, both kinds, in rank order** — what *navigation* walks
|
||||
/// (04-interactions.md ▸ The trash: "inside, plain arrows walk every row, card and lane row
|
||||
/// alike (navigation crosses kinds)").
|
||||
///
|
||||
/// The deliberate counterpart to the two lists above: extension and ranging are per-kind, so
|
||||
/// they stop at a kind boundary, while navigation is over the column as drawn and crosses it.
|
||||
/// One merge for both — `BoardModel.trashEntries`.
|
||||
public static func trashRows(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
|
||||
snapshot.trashEntries.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.
|
||||
@@ -339,14 +365,15 @@ public enum SelectionGrammar {
|
||||
/// 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** while its lane rows are not yet addressable — the
|
||||
/// kind axis reaches both containers (04-interactions.md ▸ The trash), and this is where the
|
||||
/// second container's answer to it lands.
|
||||
/// **The trash answers for both kinds** (04-interactions.md ▸ The trash, lanes rejoined
|
||||
/// 2026-07-29: "a trash selection is either cards or lane rows, kind-homogeneous like the live
|
||||
/// board's own grammar"), walked in the column's own rank order so a set that somehow held both
|
||||
/// answers by what is topmost rather than by array iteration order.
|
||||
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
|
||||
return snapshot.trashEntries.first { selection.ids.contains($0.id) }?.kind
|
||||
case .board:
|
||||
for lane in snapshot.lanes {
|
||||
if selection.ids.contains(lane.id) { return .lane }
|
||||
@@ -378,8 +405,9 @@ public enum SelectionGrammar {
|
||||
///
|
||||
/// **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.
|
||||
/// ordered rows exactly as a lane walks its own cards. The permanent delete is as deliberate an
|
||||
/// act as the move-to-trash, so it keeps the repeatable-keystroke property the rule exists for.
|
||||
/// **The trash's own successor crosses kinds** — an interim, and the branch below says why.
|
||||
///
|
||||
/// **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
|
||||
@@ -402,7 +430,16 @@ public enum SelectionGrammar {
|
||||
let siblings: [ItemID]
|
||||
switch (container, kind) {
|
||||
case (.trash, _):
|
||||
siblings = trashCards(in: snapshot, filter: filter)
|
||||
// **The siblings are every row, both kinds** — the interim answer to an open gap. 04
|
||||
// ▸ The trash settles navigation (plain arrows cross kinds) and extension (⇧-arrows stop
|
||||
// at the kind boundary) for the trash's two kinds, but says nothing about which row the
|
||||
// *successor* lands on after a lane row is purged. Until that is ruled, this follows
|
||||
// navigation rather than extension: the successor is the next row down the column
|
||||
// whatever its kind, so a purge never strands the selection with nothing selected while
|
||||
// rows the user can see sit right below it. The conservative direction — the alternative
|
||||
// (kind-scoped siblings) clears the selection whenever the purged row was its kind's
|
||||
// last, which is a deselect wearing a successor's clothes.
|
||||
siblings = trashRows(in: snapshot, filter: filter)
|
||||
case (.board, .lane):
|
||||
siblings = lanes(in: snapshot)
|
||||
case (.board, .card):
|
||||
@@ -453,13 +490,15 @@ public struct MarqueeTarget: Sendable, Equatable {
|
||||
/// `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.
|
||||
/// across lanes" — across them, not over them). A live lane is never registered as a target at
|
||||
/// all; a **trashed lane row is**, because the arrows navigate by the same frames
|
||||
/// (`NavigationMath`) and the band's begin guard reads them too — so the kind filter below is
|
||||
/// load-bearing rather than belt over braces.
|
||||
///
|
||||
/// **The kind filter is the whole of the rule, in both containers**: "the rubber band stays on the
|
||||
/// side it started on and selects cards only (as the board marquee does); lane rows join by click
|
||||
/// grammar" (04-interactions.md ▸ The trash, re-affirmed 2026-07-29). A band never has to break a
|
||||
/// tie between a card and a lane row, because it never sweeps a lane row at all.
|
||||
/// tie between a card and a lane row, because a lane row is not in the answer whatever it sweeps.
|
||||
public enum MarqueeMath {
|
||||
|
||||
/// The ids `rect` sweeps.
|
||||
|
||||
Reference in New Issue
Block a user