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:
2026-07-30 17:04:30 -04:00
parent 8014bde7c6
commit f7c8088783
26 changed files with 1825 additions and 176 deletions
+25 -2
View File
@@ -626,8 +626,8 @@ public final class ClipboardStore {
///
/// **The order is `SelectionGrammar.order`'s**, which is already the right answer for every
/// (container, kind) pair: flatten order for board cards, left-to-right for lanes, and the
/// trash's own `order` for trash cards. Deriving it here would be a second definition of an order
/// the app already states once.
/// trash's own rank order for its cards and its lane rows alike. Deriving it here would be a
/// second definition of an order the app already states once.
///
/// **The index text comes from the snapshot, not from disk.** `FrontmatterDocument` edits by line
/// span, so `serialized()` on an untouched document returns the file's bytes exactly which makes
@@ -665,6 +665,29 @@ public final class ClipboardStore {
for card in snapshot.trash {
addCard(card, at: .trashCard(card.id))
}
// **A trashed lane row copies and cuts like any other lane** (04-interactions.md The
// trash: "a trashed lane pastes after the anchor lane (the lane-paste rule above,
// verbatim)"), which makes X here the keyboard-native restore at the lane level.
//
// **No `cards` in the entry, and that is the opaque unit showing through**: a trashed
// lane's subtree is deliberately not in the snapshot (`TrashedLane`), so there is nothing
// here to describe it with and nothing is lost by that, because the manifest's embedded
// text is identification metadata only and the *content* comes from the staged folder,
// which is copied whole, cards and all. An entry that guessed at a card list would be the
// one place in the app claiming to know what an opaque unit holds.
for lane in snapshot.trashedLanes {
subjects[lane.id] = Subject(
id: lane.id,
path: .trashLane(lane.id),
entry: ClipboardManifest.Entry(
id: lane.id.rawValue,
folder: lane.id.rawValue,
title: lane.title.value,
index: lane.document.serialized(),
attachmentCount: 0
)
)
}
case .board:
for lane in snapshot.lanes {
if kind == .lane {
+81 -9
View File
@@ -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.
+124 -2
View File
@@ -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").
///
+76 -7
View File
@@ -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)
}
}
+10 -1
View File
@@ -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
+53 -14
View File
@@ -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.
+28 -7
View File
@@ -104,11 +104,31 @@ enum AccessibilityPhrases {
/// Trash: one word, never "Hide Trash"-style state in the name).
static let trashLabel = "Trash"
/// The trash container's value: its card count, filtered exactly as the lane labels' are "the
/// The trash container's value: its counts, filtered exactly as the lane labels' are "the
/// shown trash's cards participate in the filter exactly like any other card" (03-board-ui.md §
/// Trash), so the column passes the collection its badge counts.
static func trashValue(cards count: Int) -> String {
cardCount(count)
/// Trash), so the column passes the collections its badge counts.
///
/// **Both kinds are named when both are there** (lanes rejoined the trash 2026-07-29): the
/// container's rows are cards *and* opaque lane units, and a value that spoke only the cards
/// would understate what the VoiceOver cursor is about to walk into. A trash holding no lane rows
/// overwhelmingly the common case reads exactly as it always did.
///
/// It stops at counting, deliberately: the *freight* phrasing ("2 lanes containing 9 more
/// cards") belongs to the purge confirmations, where the number is what the user is about to
/// lose (`TrashModel.subject(for:)`).
static func trashValue(cards: Int, lanes: Int = 0) -> String {
guard lanes > 0 else { return cardCount(cards) }
return "\(cardCount(cards)), \(laneCount(lanes))"
}
/// A trashed lane row **one flattened opaque element**, "title, deleted lane, N cards"
/// (10-accessibility.md Trash lane, lanes rejoined 2026-07-29).
///
/// "Deleted lane" and not "lane" is the whole of what the label has to carry beyond a live lane
/// container's: the row is in the trash, it is not a container, and its N is the freight it took
/// with it rather than a filtered count of children VoiceOver could enter (`TrashedLane`).
static func trashedLaneLabel(title: String?, cards count: Int) -> String {
"\(displayTitle(title)), deleted lane, \(cardCount(count))"
}
/// What View Show Trash announces "toggling visibility is announced"
@@ -125,10 +145,11 @@ enum AccessibilityPhrases {
// MARK: - Live board announcements
/// "3 lanes", "1 lane" `cardCount`'s twin, and the second half of the digest's plural folding.
/// Spelled here rather than borrowed from `TrashModel.phrase` because that one is a cards-only
/// container's phrase by design ("Lanes are never trashed").
/// Borrowed from `TrashModel.lanePhrase` for `cardCount`'s reason: since lanes rejoined the trash
/// (2026-07-29) the purge confirmations count lanes too, and one folding means a spoken count and
/// a confirmed one cannot drift.
static func laneCount(_ count: Int) -> String {
"\(count) lane\(count == 1 ? "" : "s")"
TrashModel.lanePhrase(count)
}
/// The digest's opening and, on its own, the whole sentence for a change no bucket counts.
+54 -16
View File
@@ -163,9 +163,11 @@ struct BoardDropContext {
/// measured frame (03-board-ui.md § Motion).
///
/// **The terminal slot is clamped before the trash.** The column consumes one unit while
/// shown and is never a landing spot for anything (04-interactions.md The trash: "no move or
/// shown and is never a *position* on the strip (04-interactions.md The trash: "no move or
/// paste ever targets the trash"), so it is absent from the slot list by construction and the end
/// slot's uncapped reach past the last real lane lands *before* it.
/// slot's uncapped reach past the last real lane lands *before* it. The delete a lane drag can
/// propose over the column is not a slot at all it is the column's own target answering
/// (`retargetTrash`), and it names `.trash` rather than an index in this list.
func retargetLanes() {
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
@@ -508,9 +510,10 @@ struct BoardDropContext {
/// set's size (DRAG-REORDER.md § The drop commits).
///
/// **One of the containers is not a destination but a verb.** A proposal naming the trash commits
/// a delete the same write performs, through the same `BoardWriter.deleteCardToTrash` in the same
/// bracket (`BoardStore.deleteByDrag`), so a card deleted by drop is indistinguishable on disk
/// from one deleted by keystroke (04-interactions.md The trash, settled 2026-07-28).
/// a delete the same write performs, through the same `BoardWriter.deleteCardToTrash` /
/// `deleteLaneToTrash` in the same bracket (`BoardStore.deleteByDrag`, `deleteLanesByDrag`), so an
/// item deleted by drop is indistinguishable on disk from one deleted by keystroke
/// (04-interactions.md The trash, settled 2026-07-28; lanes extended 2026-07-29).
///
/// The write is the first half; the second is the **committed-overlay hold**
/// (`DragSession.commit`). The write is still in flight when this returns, so the session flips
@@ -539,18 +542,53 @@ struct BoardDropContext {
switch kind {
case .lanes:
// **A lane drag never targets the trash**, and never a masonry either a lane session
// proposes only lane slots (04-interactions.md The trash). True by construction, since
// `retargetLanes` is the only thing that proposes for one and the trash column is absent
// from its slot list; written down because a commit that trusted the container implicitly
// would be the one place the invariant could break silently.
if target.isTrash {
// **The pointer's delete gesture, 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"): release moves the lane's folder subtree
// intact into `.trash/`, exactly the delete (`BoardStore.deleteLanesByDrag`).
//
// The gate is re-asked here rather than trusted from the hover, the card branch's
// rule for its reason: the modifiers can change after the proposal stood, and no
// callback reports it. A refusal cancels the lane returns, nothing is written.
guard TrashDrop.accepts(
kind: kind,
container: session.container,
isWithinBoard: within,
operation: operation,
isTrashShown: store.transient.isTrashVisible,
acceptsMutations: store.acceptsBoardMutations
) else {
cancelDrop()
return false
}
store.deleteLanesByDrag(laneIDs: ids)
break
}
// **A lane drag never targets a masonry** a lane session proposes lane slots and the
// trash column, and nothing else (04-interactions.md The trash). True by construction,
// since `retargetLanes` and `retargetTrash` are the only things that propose for one;
// written down because a commit that trusted the container implicitly would be the one
// place the invariant could break silently.
guard target.container == .strip else {
cancelDrop()
return false
}
if within {
store.moveLanes(Set(ids), toIndex: target.index)
// **The trash side is the restore, and it is a different write** (04 The trash:
// "a trashed lane row [dropped] onto its own board's strip is an ordinary move to
// the drop position"): the folder comes *out* of `.trash/`, where `moveLanes`'
// rank arithmetic a permutation of the strip's own lanes has nothing to say
// about it. `restoreLanes` is that move, with the same one-bracket, one-step shape.
if session.container == .trash {
store.restoreLanes(Set(ids), toIndex: target.index)
} else {
store.moveLanes(Set(ids), toIndex: target.index)
}
} else {
// Cross-board, from either container: the ordinary arrival, copy by default and
// move under "Dropped on *another* board it follows the copy default -drag
// forces the true cross-board restore-move" (04 The trash).
store.receiveLanes(folders, operation: operation, at: target.index)
}
@@ -762,11 +800,11 @@ struct StripDropDelegate: DropDelegate {
/// gives the deepest region the session whether it wants it or not (see the note above), and it
/// routes them three ways:
///
/// - **card sessions** through `retargetTrash`, which proposes the topmost row for the ones the trash
/// takes and falls through to the strip's own answer for the rest;
/// - **lane sessions** the same way, and `TrashDrop.accepts` refuses them there, so what actually
/// runs is the strip's `retargetLanes` lane reordering keeps working across the column exactly as
/// it did when the column was a hole in the strip's target;
/// - **card and lane sessions alike** through `retargetTrash`, which proposes the topmost row for the
/// ones the trash takes a live item from this board, unmodified, either kind (lanes extended
/// 2026-07-29) and falls through to the strip's own answer for the rest, so a *trashed* row being
/// dragged out keeps reordering against the strip across the column exactly as it did when the
/// column was a hole in the strip's target;
/// - **Finder file sessions** by clearing the highlight outright: "Finder file drops (attachment
/// import) on trash cards are inert" ( The trash), and the column has nothing else to offer
/// them no lane, no card, nothing to attach to.
+30 -15
View File
@@ -496,15 +496,22 @@ struct BoardView: View {
store.transient.isTrashVisible
}
/// The trash's cards as the column is showing them the shown trash's cards "participate in
/// the filter exactly like any other card" (03-board-ui.md § Trash), and the arrows walk what is
/// on screen (`TrashLaneView` applies the identical predicate to the identical cards).
/// The trash's **rows** as the column is showing them, both kinds in rank order the shown
/// trash's contents "participate in the filter exactly like any other card" (03-board-ui.md §
/// Trash), and the arrows walk what is on screen (`TrashLaneView` applies the identical predicate
/// to the identical rows).
///
/// **Rows and not cards, because this is navigation** (04-interactions.md The trash: "inside,
/// plain arrows walk every row, card and lane row alike (navigation crosses kinds)", and
/// Grammar gives "the shown non-empty trash its first *entry*"). The kind-scoped lists are
/// the *ranging* grammar's (`SelectionGrammar.order`), which is what stops a -arrow at the kind
/// boundary while a plain one crosses it.
///
/// Read by the three keyboard destinations that reach into the column the arrow origin's
/// order list, /'s container, and 's jump so none of them can walk onto a card the
/// order list, /'s container, and 's jump so none of them can walk onto a row the
/// filter took away.
private var trashCards: [ItemID] {
SelectionGrammar.trashCards(in: store.snapshot, filter: store.searchFilter)
private var trashRows: [ItemID] {
SelectionGrammar.trashRows(in: store.snapshot, filter: store.searchFilter)
}
// MARK: - The drag
@@ -795,11 +802,15 @@ struct BoardView: View {
/// Select All and a foreign reload leave the arrows somewhere sensible without any of them
/// having to name a cursor.
///
/// The **trash's list is its cards**, top to bottom there are no lane entries to interleave
/// any more (03-board-ui.md § Trash: "Cards only").
/// The **trash's list is its rows**, top to bottom, both kinds interleaved by rank (lanes
/// rejoined 2026-07-29) and the trash side is **never the lane domain**, whatever the
/// selection's kind: the lane domain is the strip's grammar (/ walk lanes, descends into
/// cards, / move one), and a trashed lane row is a row in a column, not a lane on the board
/// ("The trash lane itself is never selectable *as a lane*" 04 The trash). Its arrows are the
/// spatial ones every row gets.
///
/// Both lists are the **filtered** board (04 § Search: "arrow nav read[s] it"), so the
/// fallback lands on the last *visible* member rather than on a card the query hid.
/// fallback lands on the last *visible* member rather than on a row the query hid.
private func arrowOrigin() -> (head: ItemID, container: ItemContainer, isLaneDomain: Bool)? {
let selection = store.selection
guard !selection.isEmpty else { return nil }
@@ -813,7 +824,7 @@ struct BoardView: View {
list = SelectionGrammar.order(of: kind, in: .board, snapshot: store.snapshot, filter: store.searchFilter)
case .trash:
isLaneDomain = false
list = trashCards
list = trashRows
}
if let head = store.transient.selectionHead, list.contains(head) {
@@ -915,13 +926,16 @@ struct BoardView: View {
return .handled
}
/// **/ jump to the current container's first/last card** the lane's, or the trash
/// trash column's when that is where the cursor is.
/// **/ jump to the current container's first/last row** the lane's cards, or the trash
/// column's rows when that is where the cursor is.
///
/// ** escalates into the lane domain** (04 Grammar, settled "the keyboard's one entry to
/// lane selection"): with the lane's first card already the sole selection, the next selects
/// the *lane* itself. The trash deliberately never escalates: it "is never selectable as a lane",
/// so a second there is simply inert.
///
/// **In the trash the jump crosses kinds**, like every other navigation there: the ends it names
/// are the column's, so from a card can land on a lane row sitting above it.
private func jumpWithinContainer(
_ direction: NavigationMath.Direction,
from head: ItemID,
@@ -931,7 +945,7 @@ struct BoardView: View {
var lane: ItemID?
switch container {
case .trash:
siblings = trashCards
siblings = trashRows
case .board:
guard let home = store.snapshot.lanes.first(where: { lane in
lane.cards.contains { $0.id == head }
@@ -956,11 +970,12 @@ struct BoardView: View {
/// first card, since is the one keyboard entry to lane selection.
///
/// ** reaches the shown trash** first (04 The trash: "the shown trash is the last container
/// for card navigation, and jumps to it"); an empty or hidden column is not a destination, so
/// for card navigation, and jumps to it" Grammar names the landing as "its first
/// **entry**", which is a row of either kind); an empty or hidden column is not a destination, so
/// the jump falls through to the last lane. Empty lanes are scanned past in both directions
/// a jump that landed nowhere because the end lane happens to be empty would be a dead key.
private func jumpToEndLane(_ direction: NavigationMath.Direction) -> KeyPress.Result {
if direction == .right, isTrashVisible, let first = trashCards.first {
if direction == .right, isTrashVisible, let first = trashRows.first {
replaceSelection(with: first, in: .trash)
return .handled
}
+25 -15
View File
@@ -25,8 +25,9 @@ struct DropTarget: Equatable, Sendable {
/// That lane's **masonry**: the index is a position in its logical card order
/// (DRAG-REORDER.md § The card masonry).
case lane(ItemID)
/// The **trash column**, which a board card drag proposes into to delete it
/// (04-interactions.md The trash, settled 2026-07-28). The index is always the topmost row.
/// The **trash column**, which a board card or lane drag proposes into to delete it
/// (04-interactions.md The trash, settled 2026-07-28, lanes extended 2026-07-29). The index
/// is always the topmost row.
case trash
}
@@ -47,9 +48,10 @@ struct DropTarget: Equatable, Sendable {
// MARK: - Dropping on the trash
/// **Drop-on-trash deletes** (04-interactions.md The trash, settled 2026-07-28: "the drag becomes
/// the pointer's delete gesture release moves the dragged card(s) into `.trash/`, exactly the delete"),
/// as the two pure facts the gesture is made of (`TrashDropTests`).
/// **Drop-on-trash deletes** (04-interactions.md The trash, settled 2026-07-28, lanes extended
/// 2026-07-29: "Dropping a live card or lane on the shown trash deletes it a lane drag over the
/// shown trash proposes the delete alongside its strip slots"), as the two pure facts the gesture is
/// made of (`TrashDropTests`).
///
/// Kept out of the drop context so the ruling is checkable without a window, and stated once so the
/// **hover** and the **release** cannot disagree about what the trash takes the hazard being a
@@ -69,16 +71,20 @@ enum TrashDrop {
/// Whether the shown trash takes this session the whole of the gate, and every clause is a
/// refusal 04 states in its own words:
///
/// - **Lanes are not deliverable this way** "a lane drag proposes only lane slots". (The strip's
/// slot list has never contained the trash column, so this is belt over braces; it is written down
/// because a guard that is only true by construction is one refactor from being false.)
/// - **A trash card is already there.** A `.trash` session's vocabulary is restore and copy-out;
/// dropping it back where it came from writes nothing.
/// - **Cross-board is refused.** "No move or paste ever targets the trash": a foreign card
/// - **Both kinds are deliverable** (lanes extended 2026-07-29, retiring "a lane drag proposes
/// only lane slots"): "a lane drag over the shown trash proposes the delete alongside its strip
/// slots", which is the same sentence the card gesture has always had one level down. A session
/// in flight is a session of one of the two kinds, so the clause is `kind != nil` there is no
/// third kind to admit by accident, and a file session never arms this object at all.
/// - **A trash item is already there.** A `.trash` session's vocabulary is restore and copy-out,
/// at either level; dropping it back where it came from writes nothing.
/// - **Cross-board is refused.** "No move or paste ever targets the trash": a foreign item
/// delivered *into* this board's trash would be a transfer-and-delete compound, an operation the
/// design gives no name and no undo story. The card stays where it is.
/// design gives no name and no undo story. It stays where it is.
/// - ** is refused.** Copying into the trash is not a thing the copy grammar promises the
/// original stays exactly where it was, and there is nothing to delete but the original.
/// original stays exactly where it was, and there is nothing to delete but the original. (A
/// within-board lane drag never resolves to `.copy` anyway: is ignored there,
/// `DragLocality.operation`. The clause is the card gesture's and covers the lane for free.)
/// - **Hidden, the trash is invisible to every gesture.** True by construction too (the column is
/// not rendered, so it has no drop region), and stated here so the claim is testable.
/// - **The mutating-gesture rule**, like every other write the pointer can start.
@@ -91,7 +97,7 @@ enum TrashDrop {
acceptsMutations: Bool
) -> Bool {
guard isTrashShown, acceptsMutations else { return false }
guard kind == .cards, container == .board, isWithinBoard else { return false }
guard kind != nil, container == .board, isWithinBoard else { return false }
return operation == .move
}
}
@@ -400,8 +406,12 @@ final class DragSession {
/// Always `TrashDrop.landingIndex`, and read through this accessor anyway so the column asks the
/// same "is it me?" question every other container asks, and gets the position from the same
/// place.
///
/// **Either kind proposes here** (lanes extended 2026-07-29), so there is no kind clause: what
/// makes the proposal legal is `TrashDrop.accepts`, asked at hover and again at release, and a
/// second copy of its answer written here could only ever disagree with it.
func trashProposal(onBoardRooted root: URL) -> Int? {
guard kind == .cards, let proposal, proposal.container == .trash,
guard isActive, let proposal, proposal.container == .trash,
DragLocality.isSameBoard(proposal.boardRoot, root)
else { return nil }
return proposal.index
+8 -3
View File
@@ -81,9 +81,14 @@ final class MarqueeSession {
/// is on screen. It is also what makes the band correct for free across a lane resize, a reorder in
/// flight, and a foreign reload the frames simply re-register.
///
/// **Lanes are never registered.** The band selects cards board cards or trash cards; a lane has
/// no entry here at all, which is 04-interactions.md § Selection's "click-drag rubber-bands across
/// lanes" made structural rather than filtered.
/// **A live lane is never registered.** The band selects cards, and a lane on the strip has no entry
/// here at all 04-interactions.md § Selection's "click-drag rubber-bands across lanes" made
/// structural rather than filtered.
///
/// **A trashed lane row is registered** (lanes rejoined the trash 2026-07-29), and not for the band:
/// this registry is also the arrows' geometry (`NavigationMath`, where "plain arrows walk every row,
/// card and lane row alike") and the begin guard's universe below. The band still never selects one
/// `MarqueeMath` filters by kind, which is where that rule lives for both containers.
///
/// It is also the **begin guard's** universe: a drag that starts inside a registered frame belongs
/// to that item's own gesture (a card drag, a drag out of the trash), never to the band. Deciding
+348
View File
@@ -0,0 +1,348 @@
import AppKit
import SwiftUI
// MARK: - The trashed lane's row
/// A **trashed lane**, as the one row the trash column gives it (03-board-ui.md § Trash, re-ruled
/// 2026-07-29 "Lanes trash too"):
///
/// > A trashed lane is an opaque unit: one distinct dimmed row showing its title and held-card count
/// > ("Doing 5 cards"), no styling accents, never expandable; its cards are invisible to search and
/// > not individually addressable it restores whole or purges whole.
///
/// ### Why it is not a `CardFaceView` role
///
/// The card face has one axis which container it is drawn in and the trash side of it draws *a
/// card*: its stripe, its icon tint, its attachments chip, its four-line title. A trashed lane has
/// none of that to draw and must not appear to: the design asks for a **distinct** dimmed row
/// precisely so the column never reads as "these are all cards", and 03's "no styling accents"
/// forbids the stripe the face exists to paint. A third `CardFaceRole` would be a role whose every
/// branch was an absence, over a `Card` this view does not have (`TrashedLane` is deliberately not a
/// `Lane` see its own note). So this is its own small view, and what it shares with the face the
/// selection stroke's vocabulary, the drag dim, the marquee registration, the container-scoped click
/// funnel it shares by calling the same store paths and the same modifiers.
///
/// ### What it does *not* have, and why each absence is a ruling
///
/// - **No expansion, no cards** "never expandable; its cards are not individually addressable".
/// The subtree is not in the snapshot at all, so there is nothing here that could be drawn even by
/// accident (`TrashedLane`).
/// - **No styling accents** no left stripe, no top band, no icon tint, whatever the lane's
/// `background`/`icon`/`icon-color` say. The bytes ride along untouched for the restore; the row
/// simply does not read them.
/// - **No rename, no Style, no width** "everything edit-shaped is disabled on trash selections
/// and lane width ops on lane rows" (04-interactions.md The trash). Absences rather than
/// disabled modifiers, `CardFaceRole`'s rule: the menu-bar items answer the same way through
/// `renameTarget`/`boardStyleTarget`/`LaneWidthCommands`, all of which require a `.board`
/// selection.
/// - **No Finder file drop** the column's own delegate clears the file highlight (`TrashDrop`).
struct TrashLaneRowView: View {
let store: BoardStore
let lane: TrashedLane
/// The window's purge-alert host this row's Delete is the **permanent** one, so it goes
/// through the same confirmation the menu bar's does (03-board-ui.md § Trash; `CardFaceView`
/// carries the same collaborator for the same reason).
let confirmations: TrashConfirmations
/// The board window's drop machinery: this row's drag is a **lane** session in the `.trash`
/// container the same payload a live lane header produces, which is what makes drag-restore
/// ordinary (04-interactions.md The trash Drag-to-restore).
let drops: BoardDropContext
/// The strip's rubber band. The row registers its frame like a card face does **not** so the
/// band can sweep it (it never selects lane rows) but because the same registry is the arrows'
/// geometry and the band's begin guard (`MarqueeTargetRegistry`).
let marquee: MarqueeControl
/// Increase Contrast, for the plate's borders `CardFaceView`'s rule, so a selected row and a
/// selected card wear the same ring at the same strength.
@Environment(\.colorSchemeContrast) private var contrast
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
/// The card plate's radius: the rows sit in one column and a row with a different corner would
/// read as a different *kind of surface* rather than as a different kind of row. What
/// distinguishes it is the dimming and the wording, per 03.
private var cornerRadius: CGFloat { BoardMetrics.cardCornerRadius(bodyPointSize: pointSize) }
/// This row's drawn width the drag replica's, measured for `CardFaceView`'s reason.
@State private var measuredWidth: CGFloat = 0
var body: some View {
row
.contextMenu { menu }
// The menu's rows as VoiceOver custom actions "its actions are the same Delete /
// Reveal in Finder" (10-accessibility.md Trash lane), each calling the same method its
// menu row does so the two surfaces cannot drift.
.accessibilityActions { actions }
}
/// Title and held-card count, dimmed "one distinct dimmed row showing its title and held-card
/// count" (03-board-ui.md § Trash).
///
/// The dimming is the *whole* row's, secondary throughout: this is a thing that has been thrown
/// away, and the column's cards beside it are the live-looking ones. **State is never
/// colour-alone** (10-accessibility.md): the row also says "deleted lane" in its accessibility
/// label and carries the lane glyph, so the distinction survives without the wash.
private var row: some View {
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
// The lane glyph, at the level default and **never the lane's own `icon`** "no styling
// accents" (03 § Trash). It says *lane*, which is the one thing the row must not be
// mistaken about.
Image(systemName: ItemSymbol.lane)
.foregroundStyle(.secondary)
.imageScale(.medium)
Text(lane.title.value ?? AccessibilityPhrases.untitled)
.font(.body)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
heldCount
}
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
.frame(maxWidth: .infinity, alignment: .leading)
// Quieter than a card's plate, which is what "dimmed" is here: the cards beside it keep the
// ordinary secondary background, and this row sits a step further back.
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.tertiary))
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(plateStroke, lineWidth: plateStrokeWidth)
)
// The deferred cut's dim "X on a trashed lane row + V after the anchor lane restores"
// (04-interactions.md The trash Clipboard), so a pending cut has to show here exactly as
// it does on a card.
.cutTreatment(of: lane.id, in: store)
// Dragged out, the row stays visible and dims: a restore is not a removal until the write
// lands (`TrashLaneView.renderedRows`, the card side's rule).
.opacity(drops.session.isDragging(lane.id) ? ClipboardTreatment.dimmedOpacity : 1)
.contentShape(Rectangle())
// **One flattened element, never a container** "A trashed lane is one flattened opaque
// element 'title, deleted lane, N cards' never a container: its cards are not in the
// tree" (10-accessibility.md Trash lane). `.ignore` unconditionally: nothing in this row is
// ever a control, because nothing here is editable.
.accessibilityElement(children: .ignore)
.accessibilityLabel(AccessibilityPhrases.trashedLaneLabel(title: lane.title.value, cards: lane.heldCards))
// The cut-pending phrase, on the row's value the card element's rule, minus the attachment
// count an opaque unit has no answer for.
.accessibilityValue(AccessibilityPhrases.cardValue(
attachments: 0,
isCutPending: store.transient.pendingCut.ids.contains(lane.id)
))
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
// VO-Space toggles, through the same `BoardStore.click` funnel the pointer uses the
// -click analogue, one uniform rule whatever the element's kind (10-accessibility.md).
.accessibilityAction { toggleSelection() }
// The click grammar, in the **trash** container at the **lane** level: "lane rows join by
// click grammar" (04-interactions.md The trash), and the kind travelling with the click is
// what makes a -click from a trash card degrade to a replace rather than mixing kinds.
//
// `togglesOnRepeat` is deliberately false: it is the *live* lane header's behaviour
// ("click again to unselect"), and this row is not a lane header it is a row in a column,
// and Finder does not deselect a row by clicking it twice.
.onTapGesture {
store.click(SelectionTarget(id: lane.id, kind: .lane, container: .trash), modifier: .current)
}
// **Double-click does nothing** "a trashed lane row never expands" (03 § Trash), which is
// the absence of a recogniser rather than a gesture that fires and refuses.
.onDrag(startDrag, preview: { dragReplica })
.onGeometryChange(for: CGSize.self) { $0.size } action: { measuredWidth = $0.width }
.marqueeTarget(lane.id, kind: .lane, container: .trash, in: marquee.registry)
}
/// "Doing 5 cards": the row's other half (03-board-ui.md § Trash), counted at load and never
/// derived from a walked subtree (`TrashedLane.heldCards`).
///
/// A plain caption rather than the lane header's capsule badge: the badge is a live lane's
/// furniture, and this row is deliberately not one.
private var heldCount: some View {
Text(AccessibilityPhrases.cardCount(lane.heldCards))
.font(.caption)
.monospacedDigit()
.foregroundStyle(.tertiary)
// Folded into the flattened element's label above, like the card face's chips.
.accessibilityHidden(true)
}
// MARK: - The drag out
/// The row's drag **the restore**, and deliberately the same session a live lane header starts
/// (04-interactions.md The trash: "Drag-to-restore follows the locality model: a trashed lane
/// row onto its own board's strip is an ordinary move to the drop position. Dropped on
/// *another* board it follows the copy default -drag forces the true cross-board
/// restore-move").
///
/// A `.lanes` session in the `.trash` container, carrying the same `DragPayload` shape
/// `LaneView.startLaneDrag` produces with the folder pointing into `.trash/`. Everything that
/// makes it behave which strip slot it proposes, which operation the badge shows, what the
/// release writes is then the ordinary lane machinery (`DragLocality.operation`,
/// `BoardDropContext.commitDrop`).
///
/// **Multi-drag carries the whole trash-side lane selection**, in the column's own order, and a
/// row outside the selection drags alone the card face's targeting rule at the other level.
///
/// Refused under the read-only lock and while an inline editor is focused, like every other
/// mutating gesture; a refusal is an item provider carrying nothing, so no session begins.
private func startDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let ids = draggedIDs
let rows = store.snapshot.trashedLanes.filter { ids.contains($0.id) }
guard !rows.isEmpty else { return NSItemProvider() }
let root = store.rootURL
let payload = DragPayload(
boardRoot: root,
kind: .lanes,
container: .trash,
items: rows.map {
DragPayload.Item(
id: $0.id.rawValue,
folder: ItemPath.trashLane($0.id).folder(under: root).path,
title: $0.title.value
)
}
)
drops.session.beginLanes(
rows.map(\.id),
folders: payload.folders,
// **One unit per row.** A lane's width is layout the *opaque unit does not carry*
// (`TrashedLane` models the row and nothing else), so the shadow spans the strip's
// standard width; the restored lane then draws at whatever `width` its untouched
// frontmatter still says, on the next reload.
units: rows.map { _ in 1 },
source: store
)
return payload.itemProvider()
}
/// What travels: the whole selection when this row is in it, else this row alone container-
/// and kind-scoped, since the selection is homogeneous on both axes.
private var draggedIDs: Set<ItemID> {
let selection = store.selection
guard selection.container == .trash,
selection.ids.contains(lane.id),
selection.ids.count > 1
else { return [lane.id] }
return selection.ids
}
/// The image under the cursor: this row at its drawn width, fanned when the whole selection
/// rides along `CardFaceView.dragReplica`'s treatment, so a restore looks like every other
/// drag on the board.
private var dragReplica: some View {
let count = store.selection.container == .trash && store.selection.ids.contains(lane.id)
? max(1, store.selection.ids.count)
: 1
return ZStack {
if count > 2 { replicaFace.offset(x: 10, y: 10).opacity(0.45) }
if count > 1 { replicaFace.offset(x: 5, y: 5).opacity(0.7) }
replicaFace
}
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
.padding(BoardMetrics.replicaPadding(bodyPointSize: pointSize))
}
/// A static rendition of the row no gestures, no geometry observer, and crucially no marquee
/// registration (`CardFaceView.replicaFace`'s note explains what one would steal).
private var replicaFace: some View {
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
Image(systemName: ItemSymbol.lane)
.foregroundStyle(.secondary)
.imageScale(.medium)
Text(lane.title.value ?? AccessibilityPhrases.untitled)
.font(.body)
.foregroundStyle(.secondary)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
Text(AccessibilityPhrases.cardCount(lane.heldCards))
.font(.caption)
.monospacedDigit()
.foregroundStyle(.tertiary)
}
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
.frame(
width: BoardMetrics.cardReplicaWidth(measured: measuredWidth, bodyPointSize: pointSize),
alignment: .leading
)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.tertiary))
}
// MARK: - The row's two rows of menu
/// Delete and Reveal in Finder "its actions are the same Delete / Reveal in Finder"
/// (10-accessibility.md Trash lane), which is the trash card's inventory exactly
/// (11-command-nexus.md Context menus). No Open, no Rename, no Style, no Width: the row is
/// opaque and nothing in the trash is edit-shaped.
@ViewBuilder
private var menu: some View {
Button("Delete") { requestPurge() }
.disabled(!store.acceptsBoardMutations)
Divider()
Button("Reveal in Finder") { revealInFinder() }
}
@ViewBuilder
private var actions: some View {
Button("Delete") { requestPurge() }
.disabled(!store.acceptsBoardMutations)
Button("Reveal in Finder") { revealInFinder() }
}
/// The **permanent** delete, through the window's confirmation host the alert names the
/// freight ("Permanently delete lane 'Doing' and its 5 cards?", `TrashModel.purgePrompt`), which
/// is the whole reason an opaque row's count is carried in the snapshot at all.
private func requestPurge() {
confirmations.requestTrashDelete(of: targetIDs, in: store)
}
private func revealInFinder() {
NSWorkspace.shared.activateFileViewerSelecting(
ItemPath.resolve(targetIDs, in: .trash, snapshot: store.snapshot)
.map { $0.folder(under: store.rootURL) }
)
}
/// VO-Space's landing: the -click funnel, on this row, in its own container and kind.
private func toggleSelection() {
store.click(
SelectionTarget(id: lane.id, kind: .lane, container: .trash),
modifier: .command
)
}
/// What this row's menu acts on: the whole selection when this row is part of it, else this row
/// alone standard macOS context-menu targeting, container-scoped like the card face's.
private var targetIDs: Set<ItemID> {
guard store.selection.container == .trash, store.selection.ids.contains(lane.id) else {
return [lane.id]
}
return store.selection.ids
}
// MARK: - Selection treatment
private var isSelected: Bool {
store.selection.container == .trash && store.selection.ids.contains(lane.id)
}
/// The accent ring when selected, a separator hairline under Increase Contrast, nothing
/// otherwise `CardFaceView.plateStroke`'s three-way branch, minus the file-drop hover the
/// trash never has.
private var plateStroke: AnyShapeStyle {
if isSelected {
AnyShapeStyle(Color.accentColor)
} else if Accommodations.drawsRestingBorder(contrast: contrast) {
AnyShapeStyle(.separator)
} else {
AnyShapeStyle(.clear)
}
}
private var plateStrokeWidth: CGFloat {
Accommodations.borderWidth(isSelected ? 1.5 : 1, contrast: contrast)
}
}
+98 -53
View File
@@ -4,12 +4,13 @@ import SwiftUI
// MARK: - TrashLaneView
/// The trash column: the trailing, visually distinct column the board's `.trash/` cards live in
/// (03-board-ui.md § Trash, resettled 2026-07-28 the materialized trash).
/// The trash column: the trailing, visually distinct column the board's `.trash/` entries live in
/// (03-board-ui.md § Trash, resettled 2026-07-28 the materialized trash; lanes rejoined
/// 2026-07-29).
///
/// ### Ordinary cards, in a column that says where they are
///
/// **Its contents are ordinary cards in a special place**, so there is nothing to derive and nothing
/// **Its cards are ordinary cards in a special place**, so there is nothing to derive and nothing
/// to draw differently: the column renders `store.snapshot.trash` which the loader parsed with the
/// same card parse the lanes use and sorted by `order` like any lane's children through the very
/// same `CardFaceView` a lane renders. "A trashed card is an ordinary card in a special place
@@ -18,8 +19,16 @@ import SwiftUI
/// approximately so: a trashed card keeps its icon, its icon tint, its left-edge accent stripe, its
/// attachments chip and its four-line title, because it is the same card and the same face.
///
/// Newest-first falls out of the ranks (every arrival mints one above the current top), so there is
/// no timestamp sort and no entry type here at all.
/// ### And its other kind, which is the opposite case
///
/// **A trashed lane is an opaque unit** "one distinct dimmed row showing its title and held-card
/// count no styling accents, never expandable" (03 § Trash) so it gets its own small view
/// (`TrashLaneRowView`) rather than a third card-face role: what a card face draws is exactly what
/// the design says this row must not. The two kinds **interleave purely by trash rank**, which is
/// `BoardModel.trashEntries`' merge and not a second one written here.
///
/// Newest-first falls out of the ranks (every arrival of either kind mints one above the current
/// top), so there is no timestamp sort and no entry type here at all.
///
/// ### What makes it a column and not a lane
///
@@ -39,18 +48,21 @@ import SwiftUI
///
/// ### The drop it takes, and the drag it starts
///
/// **"Dropping a live card on the shown trash deletes it"** (04-interactions.md The trash) the
/// drag becomes the pointer's delete gesture, and release moves the dragged card(s) into `.trash/`.
/// So the column declares an `onDrop` (`TrashDropDelegate`), and it is the narrowest one on the
/// board: a board card drag from **this** board, unmodified. It diverges from every other drop in one
/// way, and the ranks are what make the divergence honest: **the shadow always takes the topmost
/// row**, because every arrival mints a rank above the current top.
/// **"Dropping a live card or lane on the shown trash deletes it"** (04-interactions.md The
/// trash, lanes extended 2026-07-29) the drag becomes the pointer's delete gesture, and release
/// moves the dragged item(s) into `.trash/`. So the column declares an `onDrop`
/// (`TrashDropDelegate`), and it is the narrowest one on the board: a board drag from **this** board,
/// unmodified. It diverges from every other drop in one way, and the ranks are what make the
/// divergence honest: **the shadow always takes the topmost row**, because every arrival mints a rank
/// above the current top.
///
/// The drag *out* is the restore, and it is deliberately not special: a trash card's drag is an
/// ordinary `.cards` session in the `.trash` container (`CardFaceView.startTrashCardDrag`), and
/// `BoardDropContext.commitDrop` hands it to the same `moveCards`/`copyCards`/`receiveCards` every
/// board card uses. "Restoring is an ordinary move out there is no restore-specific machinery and
/// no Put Back" (03 § Trash).
/// The drag *out* is the restore, and it is deliberately not special at either level: a trash card's
/// drag is an ordinary `.cards` session in the `.trash` container
/// (`CardFaceView.startTrashCardDrag`) and a trashed lane row's is an ordinary `.lanes` session in it
/// (`TrashLaneRowView.startDrag`), which `BoardDropContext.commitDrop` hands to the same
/// `moveCards`/`copyCards`/`receiveCards` and `restoreLanes`/`receiveLanes` every board item uses.
/// "Restoring is an ordinary move out there is no restore-specific machinery and no Put Back"
/// (03 § Trash).
///
/// **Finder file drops stay inert** "Finder file drops on trash cards are inert" ( The trash)
/// and say so twice: the delegate clears the file highlight over the column, and the face's hover
@@ -66,8 +78,9 @@ import SwiftUI
///
/// "When shown, it is the last container, labeled as Trash with its count. Its cards are ordinary
/// card elements" (10-accessibility.md, resettled 2026-07-28). The container name and value are set
/// here; the elements inside are ordinary card faces because they *are* ordinary card faces. The full
/// element tree labels, values, traits, actions is the accessibility milestone's.
/// here; the card elements inside are ordinary card faces because they *are* ordinary card faces, and
/// a trashed lane is "one flattened opaque element never a container" ( Trash lane, lanes rejoined
/// 2026-07-29), which is `TrashLaneRowView`'s own doing.
struct TrashLaneView: View {
let store: BoardStore
@@ -133,36 +146,46 @@ struct TrashLaneView: View {
// VoiceOver to enter.
.accessibilityElement(children: .contain)
.accessibilityLabel(AccessibilityPhrases.trashLabel)
// The **rendered** count, like a lane's: the shown trash's cards participate in the filter,
// The **rendered** counts, like a lane's: the shown trash's rows participate in the filter,
// so a query narrows the spoken count exactly as it narrows the badge and the column itself.
.accessibilityValue(AccessibilityPhrases.trashValue(cards: renderedCards.count))
// Both kinds are named, because both are rows the VoiceOver cursor is about to walk into.
.accessibilityValue(AccessibilityPhrases.trashValue(
cards: renderedCards.count,
lanes: renderedRows.count - renderedCards.count
))
}
/// The cards the column shows.
/// The rows the column shows **both kinds, interleaved by rank** (03-board-ui.md § Trash:
/// "Lane rows and cards interleave in the one trash column purely by trash rank").
///
/// **Shown, the trash's cards "participate in the filter exactly like any other card"**
/// (03-board-ui.md § Trash "the point of the pivot"), so the search predicate narrows this
/// collection exactly as it narrows `LaneView.renderedCards`, and the count badge follows for
/// free because it reads this same value. Hidden, the column renders nothing and registers
/// nothing, so "hidden trash is invisible to search" needs no code at all.
/// **Shown, the trash's contents "participate in the filter exactly like any other card"**
/// (03 § Trash "the point of the pivot"), so the search predicate narrows this collection
/// exactly as it narrows `LaneView.renderedCards`; a lane row matches by title alone, which is
/// `SearchFilter`'s own rule for the opaque unit. Hidden, the column renders nothing and
/// registers nothing, so "hidden trash is invisible to search" needs no code at all.
///
/// **A card being dragged out renders here anyway**, unlike a lane's: the source stays visible in
/// the trash while the session is in flight, dimmed by the face's own treatment, because a
/// restore is not a removal until the write lands.
private var renderedCards: [Card] {
Self.rendered(store.snapshot.trash, filter: store.searchFilter)
/// **A row being dragged out renders here anyway**, unlike a lane's on the strip: the source
/// stays visible in the trash while the session is in flight, dimmed by the row's own treatment,
/// because a restore is not a removal until the write lands.
private var renderedRows: [TrashEntry] {
Self.rendered(store.snapshot, filter: store.searchFilter)
}
/// `renderedCards` as a pure function of its two inputs `LaneView.rendered`'s trash-side twin,
/// The card half of `renderedRows` what the badge and the spoken card count read.
private var renderedCards: [TrashEntry] {
renderedRows.filter { $0.kind == .card }
}
/// `renderedRows` as a pure function of its two inputs `LaneView.rendered`'s trash-side twin,
/// split out for the same reason: so the rule can be pinned without a view
/// (`SearchFilterTests`). The column, its count badge and its marquee registration all read the
/// property, which reads this.
///
/// Two of the lane's four inputs are absent, and each absence is a ruling: **no rename
/// exemption**, because nothing renames in the trash (04 The trash), and **no drag hiding**,
/// because a card dragged *out* of the trash stays visible in it until the write lands.
nonisolated static func rendered(_ trash: [Card], filter: SearchFilter) -> [Card] {
trash.filter { filter.matches($0) }
/// because a row dragged *out* of the trash stays visible in it until the write lands.
nonisolated static func rendered(_ snapshot: BoardModel, filter: SearchFilter) -> [TrashEntry] {
snapshot.trashEntries.filter { filter.matches($0) }
}
// MARK: - The delete gesture's landing
@@ -173,14 +196,14 @@ struct TrashLaneView: View {
drops.session.trashProposal(onBoardRooted: store.rootURL)
}
/// The slots the column lays out: the cards, with the delete gesture's shadow run opened at the
/// The slots the column lays out: the rows, with the delete gesture's shadow run opened at the
/// top.
///
/// The run stands until the echo reload brings the real cards the committed-overlay hold keeps
/// The run stands until the echo reload brings the real rows the committed-overlay hold keeps
/// the arrangement the release proposed on screen for that round trip, exactly as every other
/// container's does (`CommittedHold`).
private var slots: [TrashSlot] {
var result = renderedCards.map(TrashSlot.card)
var result = renderedRows.map(TrashSlot.entry)
guard let proposal else { return result }
let run = (0..<drops.session.shadowCount).map(TrashSlot.shadow)
result.insert(contentsOf: run, at: min(max(0, proposal), result.count))
@@ -254,10 +277,15 @@ struct TrashLaneView: View {
.accessibilityHidden(true)
}
/// The card count the same collection the body renders, so the badge cannot disagree with
/// The row count the same collection the body renders, so the badge cannot disagree with
/// what is on screen (`LaneView.countBadge`'s rule).
///
/// **Rows, not cards**: a lane row is a row in this column, and a badge reading `3` above four
/// drawn rows would break the one property this badge has. The *breakdown* is the spoken value's
/// (`AccessibilityPhrases.trashValue`) and the purge confirmations' (`TrashModel.Freight`), where
/// the difference between a card and a lane's freight actually matters.
private var countBadge: some View {
Text("\(renderedCards.count)")
Text("\(renderedRows.count)")
.font(.caption)
.monospacedDigit()
.foregroundStyle(.secondary)
@@ -268,16 +296,16 @@ struct TrashLaneView: View {
// MARK: - Cards
/// The cards, scrollable, with the navigation head kept in view.
/// The rows, scrollable, with the navigation head kept in view.
///
/// **"Selection scrolls into view"** (04-interactions.md Grammar), watching the head rather
/// than the whole selection so exactly one column responds to any one arrow `LaneView`'s rule,
/// on the trash side.
/// on the trash side. Either kind of row can be the head: plain arrows walk them all.
private var cards: some View {
ScrollViewReader { proxy in
scrollableCards
.onChange(of: store.transient.selectionHead) { _, head in
guard let head, renderedCards.contains(where: { $0.id == head }) else { return }
guard let head, renderedRows.contains(where: { $0.id == head }) else { return }
proxy.scrollTo(TrashSlot.identity(of: head))
}
}
@@ -299,7 +327,7 @@ struct TrashLaneView: View {
ForEach(Array(slots.enumerated()), id: \.element.id) { index, slot in
Group {
switch slot {
case let .card(card):
case let .entry(.card(card)):
CardFaceView(
store: store,
card: card,
@@ -307,6 +335,17 @@ struct TrashLaneView: View {
marquee: marquee,
drops: drops
)
case let .entry(.lane(lane)):
// The opaque unit's row its own view, because "no styling accents" and
// "never expandable" are exactly what a card face is not
// (`TrashLaneRowView`, 03-board-ui.md § Trash).
TrashLaneRowView(
store: store,
lane: lane,
confirmations: confirmations,
drops: drops,
marquee: marquee
)
case .shadow:
// The delete gesture's shadow, holding the topmost row open
// (04-interactions.md The trash). At the nominal card height: the cards
@@ -316,10 +355,12 @@ struct TrashLaneView: View {
.frame(height: LaneDropRegistry.nominalCardHeight)
}
}
// A slot is a card, so it arrives and leaves in the card's dialect a delete
// files one in, a restore or a purge takes one out, and both halves of that pair
// should read alike from either side of the strip. The transaction is the
// reload's, like the lanes' (`Motion.reloadAnimates`).
// A slot is a row in this column, so it arrives and leaves in the card's dialect
// whatever its kind a delete files one in, a restore or a purge takes one out,
// and both halves of that pair should read alike from either side of the strip.
// A lane row takes the same transition deliberately: what enters and leaves here
// is a *row*, and the strip's lane transition is about a column of the board.
// The transaction is the reload's, like the lanes' (`Motion.reloadAnimates`).
.transition(Motion.cardTransition(reduced: reduceMotion))
// `order`-keyed traversal, `LaneView`'s rule on the trash side. The column is
// one masonry column, so geometry and `order` agree here and the priority is
@@ -357,29 +398,33 @@ struct TrashLaneView: View {
// MARK: - What the column lays out
/// One slot of the trash column a card, or one slot of the delete gesture's run.
/// One slot of the trash column a row of either kind, or one slot of the delete gesture's run.
///
/// `LaneSlot`'s smaller sibling smaller by exactly one case, because nothing is ever created in the
/// trash so there is no placeholder to stand in for it and keyed on the same principle: a slot's id
/// decides whether the echo reload reads as a *swap* or as a removal and an insertion.
///
/// The two kinds share one case (`TrashEntry`) rather than taking one each, because the column's
/// ordering question is about *rows*: a card and a lane row are interchangeable to everything here
/// except the one `switch` that draws them.
private enum TrashSlot: Identifiable {
/// A card the snapshot's trash already holds.
case card(Card)
/// A row the snapshot's trash already holds a card, or a trashed lane's opaque unit.
case entry(TrashEntry)
/// One of the drag's N shadows, holding the topmost rows open (04-interactions.md The trash).
case shadow(index: Int)
var id: String {
switch self {
case let .card(card): Self.identity(of: card.id)
case let .entry(entry): Self.identity(of: entry.id)
// Constant per position in the run, so a run that grows or shrinks animates as slots rather
// than blinking (`LaneSlot`'s rule).
case let .shadow(index): "shadow:\(index)"
}
}
/// A card slot's id, spelled once so `scrollTo` and the slot cannot disagree about what the
/// A row slot's id, spelled once so `scrollTo` and the slot cannot disagree about what the
/// scroll reader is looking for (`LaneSlot.identity(of:)`).
static func identity(of item: ItemID) -> String { "trash:\(item.rawValue)" }
}