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:
@@ -626,8 +626,8 @@ public final class ClipboardStore {
|
|||||||
///
|
///
|
||||||
/// **The order is `SelectionGrammar.order`'s**, which is already the right answer for every
|
/// **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
|
/// (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
|
/// trash's own rank order for its cards and its lane rows alike. Deriving it here would be a
|
||||||
/// the app already states once.
|
/// second definition of an order the app already states once.
|
||||||
///
|
///
|
||||||
/// **The index text comes from the snapshot, not from disk.** `FrontmatterDocument` edits by line
|
/// **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
|
/// 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 {
|
for card in snapshot.trash {
|
||||||
addCard(card, at: .trashCard(card.id))
|
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:
|
case .board:
|
||||||
for lane in snapshot.lanes {
|
for lane in snapshot.lanes {
|
||||||
if kind == .lane {
|
if kind == .lane {
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ import Foundation
|
|||||||
/// agent that re-files a card *and* retitles it made one change to the board, and two fragments
|
/// 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
|
/// counting the same card would read as two cards. Position wins because it is the change the
|
||||||
/// board's shape shows.
|
/// board's shape shows.
|
||||||
/// - **Crossing the trash boundary is a departure or an arrival, never a move** — see
|
/// - **Crossing the trash boundary is a departure or an arrival, never a move** — at **both levels**
|
||||||
/// `between(_:_:includingTrash:)`, whose flag decides whether the trash is walked at all but never
|
/// (lanes rejoined the trash 2026-07-29) — see `between(_:_:includingTrash:)`, whose flag decides
|
||||||
/// what an event that touches it is *called*.
|
/// whether the trash is walked at all but never what an event that touches it is *called*.
|
||||||
public struct BoardDiff: Sendable, Equatable {
|
public struct BoardDiff: Sendable, Equatable {
|
||||||
|
|
||||||
/// One kind's four buckets. Disjoint by construction — see the type's note on precedence.
|
/// 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
|
/// - 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
|
/// 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
|
/// `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
|
/// 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
|
/// `.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()
|
var diff = BoardDiff()
|
||||||
diff.boardChanged = boardSideDiffers(old, new, includingTrash: includingTrash)
|
diff.boardChanged = boardSideDiffers(old, new, includingTrash: includingTrash)
|
||||||
|
|
||||||
let oldLanes = laneIndex(of: old)
|
let oldLanes = laneIndex(of: old, includingTrash: includingTrash)
|
||||||
let newLanes = laneIndex(of: new)
|
let newLanes = laneIndex(of: new, includingTrash: includingTrash)
|
||||||
|
|
||||||
for id in newLanes.keys where oldLanes[id] == nil {
|
for id in newLanes.keys where oldLanes[id] == nil {
|
||||||
diff.lanes.added.insert(id)
|
diff.lanes.added.insert(id)
|
||||||
@@ -153,7 +155,19 @@ public struct BoardDiff: Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
for (id, newLane) in newLanes {
|
for (id, newLane) in newLanes {
|
||||||
guard let oldLane = oldLanes[id] else { continue }
|
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)
|
diff.lanes.moved.insert(id)
|
||||||
} else if contentDiffers(oldLane, newLane) {
|
} else if contentDiffers(oldLane, newLane) {
|
||||||
diff.lanes.edited.insert(id)
|
diff.lanes.edited.insert(id)
|
||||||
@@ -212,8 +226,44 @@ public struct BoardDiff: Sendable, Equatable {
|
|||||||
|
|
||||||
// MARK: - Indices
|
// MARK: - Indices
|
||||||
|
|
||||||
private static func laneIndex(of snapshot: BoardModel) -> [ItemID: Lane] {
|
/// A lane the digest can see: on the strip, or — while the trash is being walked — as one of the
|
||||||
Dictionary(uniqueKeysWithValues: snapshot.lanes.map { ($0.id, $0) })
|
/// 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.
|
/// 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
|
|| 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
|
/// 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*
|
/// `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.
|
/// 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
|
// MARK: - Drag & drop commits
|
||||||
|
|
||||||
// The writes a released drag performs (04-interactions.md ▸ Drag and drop; the geometry that
|
// 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
|
// 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.
|
// 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 {
|
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.
|
/// 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:)))
|
_ = 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
|
/// **The card window's Actions ▸ Delete** (05-card-window.md ▸ Actions: "Delete — moves the card
|
||||||
/// to the trash … the window then dismisses itself").
|
/// 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
|
// MARK: - ItemPath
|
||||||
|
|
||||||
/// Where an item's folder sits under a board root, as identity components rather than as a URL.
|
/// 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).
|
/// for the same reason).
|
||||||
///
|
///
|
||||||
/// **The trash's order interleaves its two kinds by rank** (03-board-ui.md § Trash: "lane rows
|
/// **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
|
/// and cards interleave in the one trash column purely by trash rank"), which is why the walk is
|
||||||
/// arrays are merged here rather than concatenated: the column's order is the batch's order, and
|
/// `trashEntries` rather than the two arrays concatenated: the column's order is the batch's
|
||||||
/// a selection is kind-homogeneous anyway, so the merge costs nothing the one time it matters.
|
/// 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:
|
/// 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.
|
/// a selection the next reload will drop writes nothing rather than being refused.
|
||||||
@@ -181,10 +253,7 @@ extension ItemPath {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .trash:
|
case .trash:
|
||||||
let entries = snapshot.trash.map { (order: $0.order, path: ItemPath.trashCard($0.id)) }
|
for entry in snapshot.trashEntries where ids.contains(entry.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) {
|
|
||||||
result.append(entry.path)
|
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,
|
/// "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
|
/// 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
|
/// `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
|
/// `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).
|
/// 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: "")
|
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
|
// MARK: - The visible universe
|
||||||
|
|
||||||
/// Every id the filter leaves visible in `container` — **the 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.
|
/// 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
|
/// **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
|
/// `SearchFilter`. **The trash's lane list is filtered like its cards**, and that asymmetry is
|
||||||
/// trash, lanes rejoined 2026-07-29): the list is empty until the rows are addressable by the
|
/// the opaque unit's own (03-board-ui.md § Trash: "The row matches the search filter by lane
|
||||||
/// grammar, which is where the pointer and keyboard vocabulary for them lands.
|
/// 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(
|
public static func order(
|
||||||
of kind: SelectionKind,
|
of kind: SelectionKind,
|
||||||
in container: ItemContainer,
|
in container: ItemContainer,
|
||||||
@@ -285,7 +290,7 @@ public enum SelectionGrammar {
|
|||||||
case (.board, .card): boardCards(in: snapshot, filter: filter)
|
case (.board, .card): boardCards(in: snapshot, filter: filter)
|
||||||
case (.board, .lane): lanes(in: snapshot)
|
case (.board, .lane): lanes(in: snapshot)
|
||||||
case (.trash, .card): trashCards(in: snapshot, filter: filter)
|
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)
|
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
|
// MARK: - The current selection's kind
|
||||||
|
|
||||||
/// Which level the selection holds, or `nil` when it holds nothing its container renders.
|
/// 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
|
/// 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.
|
/// 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
|
/// **The trash answers for both kinds** (04-interactions.md ▸ The trash, lanes rejoined
|
||||||
/// kind axis reaches both containers (04-interactions.md ▸ The trash), and this is where the
|
/// 2026-07-29: "a trash selection is either cards or lane rows, kind-homogeneous like the live
|
||||||
/// second container's answer to it lands.
|
/// 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? {
|
public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? {
|
||||||
guard !selection.isEmpty else { return nil }
|
guard !selection.isEmpty else { return nil }
|
||||||
switch selection.container {
|
switch selection.container {
|
||||||
case .trash:
|
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:
|
case .board:
|
||||||
for lane in snapshot.lanes {
|
for lane in snapshot.lanes {
|
||||||
if selection.ids.contains(lane.id) { return .lane }
|
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,
|
/// **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
|
/// 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
|
/// ordered rows exactly as a lane walks its own cards. The permanent delete is as deliberate an
|
||||||
/// the move-to-trash, so it keeps the repeatable-keystroke property the rule exists for.
|
/// 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
|
/// **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
|
/// reload-survival rule: "the selection just shrinks"), which is why this is called by
|
||||||
@@ -402,7 +430,16 @@ public enum SelectionGrammar {
|
|||||||
let siblings: [ItemID]
|
let siblings: [ItemID]
|
||||||
switch (container, kind) {
|
switch (container, kind) {
|
||||||
case (.trash, _):
|
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):
|
case (.board, .lane):
|
||||||
siblings = lanes(in: snapshot)
|
siblings = lanes(in: snapshot)
|
||||||
case (.board, .card):
|
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
|
/// `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.
|
/// 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
|
/// - **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
|
/// across lanes" — across them, not over them). A live lane is never registered as a target at
|
||||||
/// the filter below keeps the rule true even if one were.
|
/// 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
|
/// **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
|
/// 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
|
/// 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 {
|
public enum MarqueeMath {
|
||||||
|
|
||||||
/// The ids `rect` sweeps.
|
/// The ids `rect` sweeps.
|
||||||
|
|||||||
@@ -104,11 +104,31 @@ enum AccessibilityPhrases {
|
|||||||
/// Trash: one word, never "Hide Trash"-style state in the name).
|
/// Trash: one word, never "Hide Trash"-style state in the name).
|
||||||
static let trashLabel = "Trash"
|
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 §
|
/// 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.
|
/// Trash), so the column passes the collections its badge counts.
|
||||||
static func trashValue(cards count: Int) -> String {
|
///
|
||||||
cardCount(count)
|
/// **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"
|
/// What View ▸ Show Trash announces — "toggling visibility is announced"
|
||||||
@@ -125,10 +145,11 @@ enum AccessibilityPhrases {
|
|||||||
// MARK: - Live board announcements
|
// MARK: - Live board announcements
|
||||||
|
|
||||||
/// "3 lanes", "1 lane" — `cardCount`'s twin, and the second half of the digest's plural folding.
|
/// "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
|
/// Borrowed from `TrashModel.lanePhrase` for `cardCount`'s reason: since lanes rejoined the trash
|
||||||
/// container's phrase by design ("Lanes are never trashed").
|
/// (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 {
|
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.
|
/// The digest's opening — and, on its own, the whole sentence for a change no bucket counts.
|
||||||
|
|||||||
@@ -163,9 +163,11 @@ struct BoardDropContext {
|
|||||||
/// measured frame (03-board-ui.md § Motion).
|
/// measured frame (03-board-ui.md § Motion).
|
||||||
///
|
///
|
||||||
/// **The terminal slot is clamped before the trash.** The column consumes one unit while
|
/// **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
|
/// 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() {
|
func retargetLanes() {
|
||||||
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
|
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
|
||||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||||
@@ -508,9 +510,10 @@ struct BoardDropContext {
|
|||||||
/// set's size (DRAG-REORDER.md § The drop commits).
|
/// 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
|
/// **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
|
/// a delete — the same write ⌫ performs, through the same `BoardWriter.deleteCardToTrash` /
|
||||||
/// bracket (`BoardStore.deleteByDrag`), so a card deleted by drop is indistinguishable on disk
|
/// `deleteLaneToTrash` in the same bracket (`BoardStore.deleteByDrag`, `deleteLanesByDrag`), so an
|
||||||
/// from one deleted by keystroke (04-interactions.md ▸ The trash, settled 2026-07-28).
|
/// 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**
|
/// 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
|
/// (`DragSession.commit`). The write is still in flight when this returns, so the session flips
|
||||||
@@ -539,18 +542,53 @@ struct BoardDropContext {
|
|||||||
|
|
||||||
switch kind {
|
switch kind {
|
||||||
case .lanes:
|
case .lanes:
|
||||||
// **A lane drag never targets the trash**, and never a masonry either — a lane session
|
if target.isTrash {
|
||||||
// proposes only lane slots (04-interactions.md ▸ The trash). True by construction, since
|
// **The pointer's delete gesture, at the lane level** (04-interactions.md ▸ The
|
||||||
// `retargetLanes` is the only thing that proposes for one and the trash column is absent
|
// trash, lanes extended 2026-07-29: "a lane drag over the shown trash proposes the
|
||||||
// from its slot list; written down because a commit that trusted the container implicitly
|
// delete alongside its strip slots"): release moves the lane's folder — subtree
|
||||||
// would be the one place the invariant could break silently.
|
// 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 {
|
guard target.container == .strip else {
|
||||||
cancelDrop()
|
cancelDrop()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if within {
|
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 {
|
} 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)
|
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
|
/// gives the deepest region the session whether it wants it or not (see the note above), and it
|
||||||
/// routes them three ways:
|
/// routes them three ways:
|
||||||
///
|
///
|
||||||
/// - **card sessions** through `retargetTrash`, which proposes the topmost row for the ones the trash
|
/// - **card and lane sessions alike** through `retargetTrash`, which proposes the topmost row for the
|
||||||
/// takes and falls through to the strip's own answer for the rest;
|
/// ones the trash takes — a live item from this board, unmodified, either kind (lanes extended
|
||||||
/// - **lane sessions** the same way, and `TrashDrop.accepts` refuses them there, so what actually
|
/// 2026-07-29) — and falls through to the strip's own answer for the rest, so a *trashed* row being
|
||||||
/// runs is the strip's `retargetLanes` — lane reordering keeps working across the column exactly as
|
/// dragged out keeps reordering against the strip across the column exactly as it did when the
|
||||||
/// it did when the column was a hole in the strip's target;
|
/// column was a hole in the strip's target;
|
||||||
/// - **Finder file sessions** by clearing the highlight outright: "Finder file drops (attachment
|
/// - **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
|
/// 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.
|
/// them — no lane, no card, nothing to attach to.
|
||||||
|
|||||||
@@ -496,15 +496,22 @@ struct BoardView: View {
|
|||||||
store.transient.isTrashVisible
|
store.transient.isTrashVisible
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The trash's cards as the column is showing them — the shown trash's cards "participate in
|
/// The trash's **rows** as the column is showing them, both kinds in rank order — the shown
|
||||||
/// the filter exactly like any other card" (03-board-ui.md § Trash), and the arrows walk what is
|
/// trash's contents "participate in the filter exactly like any other card" (03-board-ui.md §
|
||||||
/// on screen (`TrashLaneView` applies the identical predicate to the identical cards).
|
/// 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
|
/// 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.
|
/// filter took away.
|
||||||
private var trashCards: [ItemID] {
|
private var trashRows: [ItemID] {
|
||||||
SelectionGrammar.trashCards(in: store.snapshot, filter: store.searchFilter)
|
SelectionGrammar.trashRows(in: store.snapshot, filter: store.searchFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The drag
|
// 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
|
/// Select All and a foreign reload leave the arrows somewhere sensible without any of them
|
||||||
/// having to name a cursor.
|
/// having to name a cursor.
|
||||||
///
|
///
|
||||||
/// The **trash's list is its cards**, top to bottom — there are no lane entries to interleave
|
/// The **trash's list is its rows**, top to bottom, both kinds interleaved by rank (lanes
|
||||||
/// any more (03-board-ui.md § Trash: "Cards only").
|
/// 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
|
/// 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)? {
|
private func arrowOrigin() -> (head: ItemID, container: ItemContainer, isLaneDomain: Bool)? {
|
||||||
let selection = store.selection
|
let selection = store.selection
|
||||||
guard !selection.isEmpty else { return nil }
|
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)
|
list = SelectionGrammar.order(of: kind, in: .board, snapshot: store.snapshot, filter: store.searchFilter)
|
||||||
case .trash:
|
case .trash:
|
||||||
isLaneDomain = false
|
isLaneDomain = false
|
||||||
list = trashCards
|
list = trashRows
|
||||||
}
|
}
|
||||||
|
|
||||||
if let head = store.transient.selectionHead, list.contains(head) {
|
if let head = store.transient.selectionHead, list.contains(head) {
|
||||||
@@ -915,13 +926,16 @@ struct BoardView: View {
|
|||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **⌥↑/⌥↓ jump to the current container's first/last card** — the lane's, or the trash
|
/// **⌥↑/⌥↓ jump to the current container's first/last row** — the lane's cards, or the trash
|
||||||
/// trash column's when that is where the cursor is.
|
/// column's rows when that is where the cursor is.
|
||||||
///
|
///
|
||||||
/// **⌥↑ escalates into the lane domain** (04 ▸ Grammar, settled — "the keyboard's one entry to
|
/// **⌥↑ 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
|
/// 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",
|
/// the *lane* itself. The trash deliberately never escalates: it "is never selectable as a lane",
|
||||||
/// so a second ⌥↑ there is simply inert.
|
/// 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(
|
private func jumpWithinContainer(
|
||||||
_ direction: NavigationMath.Direction,
|
_ direction: NavigationMath.Direction,
|
||||||
from head: ItemID,
|
from head: ItemID,
|
||||||
@@ -931,7 +945,7 @@ struct BoardView: View {
|
|||||||
var lane: ItemID?
|
var lane: ItemID?
|
||||||
switch container {
|
switch container {
|
||||||
case .trash:
|
case .trash:
|
||||||
siblings = trashCards
|
siblings = trashRows
|
||||||
case .board:
|
case .board:
|
||||||
guard let home = store.snapshot.lanes.first(where: { lane in
|
guard let home = store.snapshot.lanes.first(where: { lane in
|
||||||
lane.cards.contains { $0.id == head }
|
lane.cards.contains { $0.id == head }
|
||||||
@@ -956,11 +970,12 @@ struct BoardView: View {
|
|||||||
/// first card, since ⌥↑ is the one keyboard entry to lane selection.
|
/// 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
|
/// **⌥→ 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 —
|
/// 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.
|
/// 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 {
|
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)
|
replaceSelection(with: first, in: .trash)
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ struct DropTarget: Equatable, Sendable {
|
|||||||
/// That lane's **masonry**: the index is a position in its logical card order
|
/// That lane's **masonry**: the index is a position in its logical card order
|
||||||
/// (DRAG-REORDER.md § The card masonry).
|
/// (DRAG-REORDER.md § The card masonry).
|
||||||
case lane(ItemID)
|
case lane(ItemID)
|
||||||
/// The **trash column**, which a board card drag proposes into to delete it
|
/// The **trash column**, which a board card — or lane — drag proposes into to delete it
|
||||||
/// (04-interactions.md ▸ The trash, settled 2026-07-28). The index is always the topmost row.
|
/// (04-interactions.md ▸ The trash, settled 2026-07-28, lanes extended 2026-07-29). The index
|
||||||
|
/// is always the topmost row.
|
||||||
case trash
|
case trash
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,9 +48,10 @@ struct DropTarget: Equatable, Sendable {
|
|||||||
|
|
||||||
// MARK: - Dropping on the trash
|
// MARK: - Dropping on the trash
|
||||||
|
|
||||||
/// **Drop-on-trash deletes** (04-interactions.md ▸ The trash, settled 2026-07-28: "the drag becomes
|
/// **Drop-on-trash deletes** (04-interactions.md ▸ The trash, settled 2026-07-28, lanes extended
|
||||||
/// the pointer's delete gesture — release moves the dragged card(s) into `.trash/`, exactly the ⌫ delete"),
|
/// 2026-07-29: "Dropping a live card — or lane — on the shown trash deletes it … a lane drag over the
|
||||||
/// as the two pure facts the gesture is made of (`TrashDropTests`).
|
/// 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
|
/// 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
|
/// **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
|
/// Whether the shown trash takes this session — the whole of the gate, and every clause is a
|
||||||
/// refusal 04 states in its own words:
|
/// refusal 04 states in its own words:
|
||||||
///
|
///
|
||||||
/// - **Lanes are not deliverable this way** — "a lane drag proposes only lane slots". (The strip's
|
/// - **Both kinds are deliverable** (lanes extended 2026-07-29, retiring "a lane drag proposes
|
||||||
/// slot list has never contained the trash column, so this is belt over braces; it is written down
|
/// only lane slots"): "a lane drag over the shown trash proposes the delete alongside its strip
|
||||||
/// because a guard that is only true by construction is one refactor from being false.)
|
/// slots", which is the same sentence the card gesture has always had one level down. A session
|
||||||
/// - **A trash card is already there.** A `.trash` session's vocabulary is restore and copy-out;
|
/// in flight is a session of one of the two kinds, so the clause is `kind != nil` — there is no
|
||||||
/// dropping it back where it came from writes nothing.
|
/// third kind to admit by accident, and a file session never arms this object at all.
|
||||||
/// - **Cross-board is refused.** "No move or paste ever targets the trash": a foreign card
|
/// - **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
|
/// 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
|
/// - **⌥ 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
|
/// - **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.
|
/// 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.
|
/// - **The mutating-gesture rule**, like every other write the pointer can start.
|
||||||
@@ -91,7 +97,7 @@ enum TrashDrop {
|
|||||||
acceptsMutations: Bool
|
acceptsMutations: Bool
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
guard isTrashShown, acceptsMutations else { return false }
|
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
|
return operation == .move
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,8 +406,12 @@ final class DragSession {
|
|||||||
/// Always `TrashDrop.landingIndex`, and read through this accessor anyway so the column asks the
|
/// 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
|
/// same "is it me?" question every other container asks, and gets the position from the same
|
||||||
/// place.
|
/// 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? {
|
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)
|
DragLocality.isSameBoard(proposal.boardRoot, root)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
return proposal.index
|
return proposal.index
|
||||||
|
|||||||
@@ -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
|
/// 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.
|
/// 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
|
/// **A live lane is never registered.** The band selects cards, and a lane on the strip has no entry
|
||||||
/// no entry here at all, which is 04-interactions.md § Selection's "click-drag rubber-bands across
|
/// here at all — 04-interactions.md § Selection's "click-drag rubber-bands across lanes" made
|
||||||
/// lanes" made structural rather than filtered.
|
/// 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
|
/// 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
|
/// to that item's own gesture (a card drag, a drag out of the trash), never to the band. Deciding
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,13 @@ import SwiftUI
|
|||||||
|
|
||||||
// MARK: - TrashLaneView
|
// MARK: - TrashLaneView
|
||||||
|
|
||||||
/// The trash column: the trailing, visually distinct column the board's `.trash/` cards live in
|
/// 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).
|
/// (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
|
/// ### 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
|
/// 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 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 —
|
/// 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
|
/// 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.
|
/// 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
|
/// ### And its other kind, which is the opposite case
|
||||||
/// no timestamp sort and no entry type here at all.
|
///
|
||||||
|
/// **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
|
/// ### What makes it a column and not a lane
|
||||||
///
|
///
|
||||||
@@ -39,18 +48,21 @@ import SwiftUI
|
|||||||
///
|
///
|
||||||
/// ### The drop it takes, and the drag it starts
|
/// ### 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
|
/// **"Dropping a live card — or lane — on the shown trash deletes it"** (04-interactions.md ▸ The
|
||||||
/// drag becomes the pointer's delete gesture, and release moves the dragged card(s) into `.trash/`.
|
/// trash, lanes extended 2026-07-29) — the drag becomes the pointer's delete gesture, and release
|
||||||
/// So the column declares an `onDrop` (`TrashDropDelegate`), and it is the narrowest one on the
|
/// moves the dragged item(s) into `.trash/`. So the column declares an `onDrop`
|
||||||
/// board: a board card drag from **this** board, unmodified. It diverges from every other drop in one
|
/// (`TrashDropDelegate`), and it is the narrowest one on the board: a board drag from **this** board,
|
||||||
/// way, and the ranks are what make the divergence honest: **the shadow always takes the topmost
|
/// unmodified. It diverges from every other drop in one way, and the ranks are what make the
|
||||||
/// row**, because every arrival mints a rank above the current top.
|
/// 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
|
/// The drag *out* is the restore, and it is deliberately not special at either level: a trash card's
|
||||||
/// ordinary `.cards` session in the `.trash` container (`CardFaceView.startTrashCardDrag`), and
|
/// drag is an ordinary `.cards` session in the `.trash` container
|
||||||
/// `BoardDropContext.commitDrop` hands it to the same `moveCards`/`copyCards`/`receiveCards` every
|
/// (`CardFaceView.startTrashCardDrag`) and a trashed lane row's is an ordinary `.lanes` session in it
|
||||||
/// board card uses. "Restoring is an ordinary move out … there is no restore-specific machinery and
|
/// (`TrashLaneRowView.startDrag`), which `BoardDropContext.commitDrop` hands to the same
|
||||||
/// no Put Back" (03 § Trash).
|
/// `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) —
|
/// **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
|
/// 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
|
/// "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
|
/// 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
|
/// here; the card elements inside are ordinary card faces because they *are* ordinary card faces, and
|
||||||
/// element tree — labels, values, traits, actions — is the accessibility milestone's.
|
/// 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 {
|
struct TrashLaneView: View {
|
||||||
|
|
||||||
let store: BoardStore
|
let store: BoardStore
|
||||||
@@ -133,36 +146,46 @@ struct TrashLaneView: View {
|
|||||||
// VoiceOver to enter.
|
// VoiceOver to enter.
|
||||||
.accessibilityElement(children: .contain)
|
.accessibilityElement(children: .contain)
|
||||||
.accessibilityLabel(AccessibilityPhrases.trashLabel)
|
.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.
|
// 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"**
|
/// **Shown, the trash's contents "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
|
/// (03 § Trash — "the point of the pivot"), so the search predicate narrows this collection
|
||||||
/// collection exactly as it narrows `LaneView.renderedCards`, and the count badge follows for
|
/// exactly as it narrows `LaneView.renderedCards`; a lane row matches by title alone, which is
|
||||||
/// free because it reads this same value. Hidden, the column renders nothing and registers
|
/// `SearchFilter`'s own rule for the opaque unit. Hidden, the column renders nothing and
|
||||||
/// nothing, so "hidden trash is invisible to search" needs no code at all.
|
/// 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
|
/// **A row being dragged out renders here anyway**, unlike a lane's on the strip: the source
|
||||||
/// the trash while the session is in flight, dimmed by the face's own treatment, because a
|
/// stays visible in the trash while the session is in flight, dimmed by the row's own treatment,
|
||||||
/// restore is not a removal until the write lands.
|
/// because a restore is not a removal until the write lands.
|
||||||
private var renderedCards: [Card] {
|
private var renderedRows: [TrashEntry] {
|
||||||
Self.rendered(store.snapshot.trash, filter: store.searchFilter)
|
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
|
/// 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
|
/// (`SearchFilterTests`). The column, its count badge and its marquee registration all read the
|
||||||
/// property, which reads this.
|
/// property, which reads this.
|
||||||
///
|
///
|
||||||
/// Two of the lane's four inputs are absent, and each absence is a ruling: **no rename
|
/// 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**,
|
/// 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.
|
/// because a row dragged *out* of the trash stays visible in it until the write lands.
|
||||||
nonisolated static func rendered(_ trash: [Card], filter: SearchFilter) -> [Card] {
|
nonisolated static func rendered(_ snapshot: BoardModel, filter: SearchFilter) -> [TrashEntry] {
|
||||||
trash.filter { filter.matches($0) }
|
snapshot.trashEntries.filter { filter.matches($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The delete gesture's landing
|
// MARK: - The delete gesture's landing
|
||||||
@@ -173,14 +196,14 @@ struct TrashLaneView: View {
|
|||||||
drops.session.trashProposal(onBoardRooted: store.rootURL)
|
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.
|
/// 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
|
/// the arrangement the release proposed on screen for that round trip, exactly as every other
|
||||||
/// container's does (`CommittedHold`).
|
/// container's does (`CommittedHold`).
|
||||||
private var slots: [TrashSlot] {
|
private var slots: [TrashSlot] {
|
||||||
var result = renderedCards.map(TrashSlot.card)
|
var result = renderedRows.map(TrashSlot.entry)
|
||||||
guard let proposal else { return result }
|
guard let proposal else { return result }
|
||||||
let run = (0..<drops.session.shadowCount).map(TrashSlot.shadow)
|
let run = (0..<drops.session.shadowCount).map(TrashSlot.shadow)
|
||||||
result.insert(contentsOf: run, at: min(max(0, proposal), result.count))
|
result.insert(contentsOf: run, at: min(max(0, proposal), result.count))
|
||||||
@@ -254,10 +277,15 @@ struct TrashLaneView: View {
|
|||||||
.accessibilityHidden(true)
|
.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).
|
/// 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 {
|
private var countBadge: some View {
|
||||||
Text("\(renderedCards.count)")
|
Text("\(renderedRows.count)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -268,16 +296,16 @@ struct TrashLaneView: View {
|
|||||||
|
|
||||||
// MARK: - Cards
|
// 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
|
/// **"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,
|
/// 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 {
|
private var cards: some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
scrollableCards
|
scrollableCards
|
||||||
.onChange(of: store.transient.selectionHead) { _, head in
|
.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))
|
proxy.scrollTo(TrashSlot.identity(of: head))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,7 +327,7 @@ struct TrashLaneView: View {
|
|||||||
ForEach(Array(slots.enumerated()), id: \.element.id) { index, slot in
|
ForEach(Array(slots.enumerated()), id: \.element.id) { index, slot in
|
||||||
Group {
|
Group {
|
||||||
switch slot {
|
switch slot {
|
||||||
case let .card(card):
|
case let .entry(.card(card)):
|
||||||
CardFaceView(
|
CardFaceView(
|
||||||
store: store,
|
store: store,
|
||||||
card: card,
|
card: card,
|
||||||
@@ -307,6 +335,17 @@ struct TrashLaneView: View {
|
|||||||
marquee: marquee,
|
marquee: marquee,
|
||||||
drops: drops
|
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:
|
case .shadow:
|
||||||
// The delete gesture's shadow, holding the topmost row open
|
// The delete gesture's shadow, holding the topmost row open
|
||||||
// (04-interactions.md ▸ The trash). At the nominal card height: the cards
|
// (04-interactions.md ▸ The trash). At the nominal card height: the cards
|
||||||
@@ -316,10 +355,12 @@ struct TrashLaneView: View {
|
|||||||
.frame(height: LaneDropRegistry.nominalCardHeight)
|
.frame(height: LaneDropRegistry.nominalCardHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A slot is a card, so it arrives and leaves in the card's dialect — a delete
|
// A slot is a row in this column, so it arrives and leaves in the card's dialect
|
||||||
// files one in, a restore or a purge takes one out, and both halves of that pair
|
// whatever its kind — a delete files one in, a restore or a purge takes one out,
|
||||||
// should read alike from either side of the strip. The transaction is the
|
// and both halves of that pair should read alike from either side of the strip.
|
||||||
// reload's, like the lanes' (`Motion.reloadAnimates`).
|
// 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))
|
.transition(Motion.cardTransition(reduced: reduceMotion))
|
||||||
// `order`-keyed traversal, `LaneView`'s rule on the trash side. The column is
|
// `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
|
// 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
|
// 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
|
/// `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
|
/// 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.
|
/// 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 {
|
private enum TrashSlot: Identifiable {
|
||||||
|
|
||||||
/// A card the snapshot's trash already holds.
|
/// A row the snapshot's trash already holds — a card, or a trashed lane's opaque unit.
|
||||||
case card(Card)
|
case entry(TrashEntry)
|
||||||
|
|
||||||
/// One of the drag's N shadows, holding the topmost rows open (04-interactions.md ▸ The trash).
|
/// One of the drag's N shadows, holding the topmost rows open (04-interactions.md ▸ The trash).
|
||||||
case shadow(index: Int)
|
case shadow(index: Int)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
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
|
// Constant per position in the run, so a run that grows or shrinks animates as slots rather
|
||||||
// than blinking (`LaneSlot`'s rule).
|
// than blinking (`LaneSlot`'s rule).
|
||||||
case let .shadow(index): "shadow:\(index)"
|
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:)`).
|
/// scroll reader is looking for (`LaneSlot.identity(of:)`).
|
||||||
static func identity(of item: ItemID) -> String { "trash:\(item.rawValue)" }
|
static func identity(of item: ItemID) -> String { "trash:\(item.rawValue)" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,28 @@ struct AccessibilityPhrasesTests {
|
|||||||
#expect(AccessibilityPhrases.trashValue(cards: 0) == "0 cards")
|
#expect(AccessibilityPhrases.trashValue(cards: 0) == "0 cards")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// With lane rows in the column the value names both kinds — the rows are what the VoiceOver
|
||||||
|
/// cursor is about to walk into, and a card-only count would understate the container.
|
||||||
|
@Test("The trash's value names its lane rows when it has any")
|
||||||
|
func trashValueCountsRows() {
|
||||||
|
#expect(AccessibilityPhrases.trashValue(cards: 3, lanes: 1) == "3 cards, 1 lane")
|
||||||
|
#expect(AccessibilityPhrases.trashValue(cards: 0, lanes: 2) == "0 cards, 2 lanes")
|
||||||
|
// A trash with no rows of the other kind reads exactly as it always did.
|
||||||
|
#expect(AccessibilityPhrases.trashValue(cards: 3, lanes: 0) == "3 cards")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "A trashed **lane** is one flattened opaque element — '⟨title⟩, deleted lane, N cards' — never
|
||||||
|
/// a container" (10-accessibility.md ▸ Trash lane), the design's own phrase.
|
||||||
|
@Test("A trashed lane row says what it is and what it is holding")
|
||||||
|
func trashedLaneRow() {
|
||||||
|
#expect(AccessibilityPhrases.trashedLaneLabel(title: "Doing", cards: 5) == "Doing, deleted lane, 5 cards")
|
||||||
|
#expect(AccessibilityPhrases.trashedLaneLabel(title: "Doing", cards: 1) == "Doing, deleted lane, 1 card")
|
||||||
|
#expect(AccessibilityPhrases.trashedLaneLabel(title: nil, cards: 0) == "Untitled, deleted lane, 0 cards")
|
||||||
|
// The untitled placeholder is the one the face draws, not a second spelling.
|
||||||
|
#expect(AccessibilityPhrases.trashedLaneLabel(title: "", cards: 0)
|
||||||
|
== AccessibilityPhrases.trashedLaneLabel(title: nil, cards: 0))
|
||||||
|
}
|
||||||
|
|
||||||
/// The announcement states the resulting state rather than the action, so a user who mis-hit the
|
/// The announcement states the resulting state rather than the action, so a user who mis-hit the
|
||||||
/// toggle learns where the board ended up.
|
/// toggle learns where the board ended up.
|
||||||
@Test("Toggling trash visibility announces the resulting state")
|
@Test("Toggling trash visibility announces the resulting state")
|
||||||
|
|||||||
@@ -666,6 +666,27 @@ struct AgentGuideContentTests {
|
|||||||
#expect(content.contains("*file* called `attachments` in a card"))
|
#expect(content.contains("*file* called `attachments` in a card"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Lanes delete into the trash too** (08 ▸ The agent guide's present-tense list; 03-board-ui.md
|
||||||
|
/// § Trash, re-ruled 2026-07-29): the guide has to teach the *move*, the `kind` value that tells a
|
||||||
|
/// trashed lane from a card in the flat container, and the stamp on the way in. The shipped v7
|
||||||
|
/// literal already says all three — this is the guard that keeps a wording pass from dropping one
|
||||||
|
/// silently, which is the failure class the per-version changelog bullets were retired over.
|
||||||
|
@Test("The guide teaches lanes-in-trash: the move, the kind value, and the stamp")
|
||||||
|
func lanesInTrashVocabularyIsPresent() {
|
||||||
|
let content = AgentGuide.content
|
||||||
|
// The delete itself is one sentence covering both levels.
|
||||||
|
#expect(content.contains("Delete a card or a lane = move its folder into `<board>/.trash/`"))
|
||||||
|
#expect(content.contains("for a whole lane (create `.trash/` if missing)"))
|
||||||
|
#expect(content.contains("travels with its cards inside it"))
|
||||||
|
// `kind` at creation, always — depth says what an item is on the board, but `.trash/` is flat.
|
||||||
|
#expect(content.contains("**Always write `kind`** at creation"))
|
||||||
|
#expect(content.contains("tells a trashed lane from a card"))
|
||||||
|
// And stamped on the way in when it is missing.
|
||||||
|
#expect(content.contains("**Stamp `kind: lane` when you trash a lane that lacks it.**"))
|
||||||
|
// The one place permanence is named, and it names both kinds.
|
||||||
|
#expect(content.contains("the trash is the recoverable path for both"))
|
||||||
|
}
|
||||||
|
|
||||||
/// The pathfinder's guide taught `media/` and tombstone deletes; both are retired
|
/// The pathfinder's guide taught `media/` and tombstone deletes; both are retired
|
||||||
/// (01-storage-format.md ▸ Changes from the pathfinder schema; ▸ Deletion). The one legitimate
|
/// (01-storage-format.md ▸ Changes from the pathfinder schema; ▸ Deletion). The one legitimate
|
||||||
/// mention of `deleted:` is the warning never to write it.
|
/// mention of `deleted:` is the warning never to write it.
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ private let card1 = Ident.card1
|
|||||||
private let card2 = Ident.card2
|
private let card2 = Ident.card2
|
||||||
private let card3 = Ident.card3
|
private let card3 = Ident.card3
|
||||||
private let card4 = Ident.card4
|
private let card4 = Ident.card4
|
||||||
|
/// A fourth lane identity, used only by the trash's own rows — a row must never share an id with a
|
||||||
|
/// live lane, which on a real board it cannot (board-wide uniqueness spans both containers).
|
||||||
|
private let trashedLaneID = Ident.lane4
|
||||||
|
|
||||||
/// Three lanes; two cards in the first, one in the second, none in the third.
|
/// Three lanes; two cards in the first, one in the second, none in the third.
|
||||||
private func makeBoard() throws -> WriterFixture {
|
private func makeBoard() throws -> WriterFixture {
|
||||||
@@ -445,4 +448,119 @@ struct ShownTrashDiffTests {
|
|||||||
#expect(diff.lanes.deleted == [ItemID(rawValue: lane1)])
|
#expect(diff.lanes.deleted == [ItemID(rawValue: lane1)])
|
||||||
#expect(diff.cards.deleted.isEmpty, "the two cards left with their lane — that is the lane's event")
|
#expect(diff.cards.deleted.isEmpty, "the two cards left with their lane — that is the lane's event")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - The column's other kind
|
||||||
|
|
||||||
|
/// The crossing rule at the lane level (lanes rejoined the trash 2026-07-29): a lane moved into
|
||||||
|
/// `.trash/` is a **delete**, and it reads the same shown or hidden, because the lane left
|
||||||
|
/// `lanes` either way — which is exactly the "1 lane deleted" event the user watched happen.
|
||||||
|
@Test("A lane moved into the trash is deleted, shown or hidden, and never a move")
|
||||||
|
func laneIntoTheTrashIsADeletion() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let before = try fixture.snapshot()
|
||||||
|
|
||||||
|
try fixture.moveFolder(lane1, to: ".trash/\(lane1)")
|
||||||
|
let after = try fixture.snapshot()
|
||||||
|
|
||||||
|
for shown in [true, false] {
|
||||||
|
let diff = BoardDiff.between(before, after, includingTrash: shown)
|
||||||
|
#expect(diff.lanes.deleted == [ItemID(rawValue: lane1)])
|
||||||
|
#expect(diff.lanes.moved.isEmpty)
|
||||||
|
#expect(diff.cards.deleted.isEmpty, "its cards went with it — that is the lane's event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// And back out again: a restored lane is an **arrival**, not a move.
|
||||||
|
@Test("A lane restored out of the trash is an addition")
|
||||||
|
func laneOutOfTheTrashIsAnArrival() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.moveFolder(lane1, to: ".trash/\(lane1)")
|
||||||
|
let before = try fixture.snapshot()
|
||||||
|
|
||||||
|
try fixture.moveFolder(".trash/\(lane1)", to: lane1)
|
||||||
|
let after = try fixture.snapshot()
|
||||||
|
|
||||||
|
for shown in [true, false] {
|
||||||
|
let diff = BoardDiff.between(before, after, includingTrash: shown)
|
||||||
|
#expect(diff.lanes.added == [ItemID(rawValue: lane1)])
|
||||||
|
#expect(diff.lanes.moved.isEmpty)
|
||||||
|
#expect(diff.cards.added.isEmpty, "its cards arrived with it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The news the ruling actually adds: churn that never leaves the container. A purged **row** is
|
||||||
|
/// counted while the column is shown and silent while it is hidden, exactly as a purged card is.
|
||||||
|
@Test("A purged lane row is a lane deletion while the trash is shown, and silence while hidden")
|
||||||
|
func purgedLaneRowCountsWhileShown() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.trashedLane(trashedLaneID, order: "1024", title: "Done")
|
||||||
|
let before = try fixture.snapshot()
|
||||||
|
|
||||||
|
try FileManager.default.removeItem(at: fixture.url(".trash/\(trashedLaneID)"))
|
||||||
|
let after = try fixture.snapshot()
|
||||||
|
|
||||||
|
#expect(BoardDiff.between(before, after, includingTrash: true).lanes.deleted == [ItemID(rawValue: trashedLaneID)])
|
||||||
|
#expect(BoardDiff.between(before, after).isSilent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "… 41 cards and 2 lanes containing 9 more cards" is the confirmation's sentence; this is the
|
||||||
|
/// digest's: an Empty Trash over a mixed container must not understate what went.
|
||||||
|
@Test("Empty Trash counts the lane rows alongside the cards")
|
||||||
|
func emptyTrashCountsBothKinds() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.trashCard(card4, order: "1024", title: "Old")
|
||||||
|
try fixture.trashedLane(trashedLaneID, order: "512", title: "Done")
|
||||||
|
let before = try fixture.snapshot()
|
||||||
|
|
||||||
|
try FileManager.default.removeItem(at: fixture.url(".trash/\(card4)"))
|
||||||
|
try FileManager.default.removeItem(at: fixture.url(".trash/\(trashedLaneID)"))
|
||||||
|
let diff = BoardDiff.between(before, try fixture.snapshot(), includingTrash: true)
|
||||||
|
|
||||||
|
#expect(diff.cards.deleted == [ItemID(rawValue: card4)])
|
||||||
|
#expect(diff.lanes.deleted == [ItemID(rawValue: trashedLaneID)])
|
||||||
|
#expect(AccessibilityPhrases.boardChanged(diff) == "Board changed: 1 card deleted, 1 lane deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The row's *rendered* content is its title, and only that: it takes no styling accents, so a
|
||||||
|
/// colour an agent wrote onto a trashed folder changes nothing anyone can see.
|
||||||
|
@Test("A retitled row is an edit while shown; a restyled one is not an edit at all")
|
||||||
|
func rowContentIsItsTitle() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.trashedLane(trashedLaneID, order: "1024", title: "Done")
|
||||||
|
let before = try fixture.snapshot()
|
||||||
|
|
||||||
|
try fixture.item(".trash/\(trashedLaneID)", "---\nschema: 1\ntitle: Shipped\norder: 1024\nkind: lane\n---\n\n")
|
||||||
|
#expect(BoardDiff.between(before, try fixture.snapshot(), includingTrash: true).lanes.edited
|
||||||
|
== [ItemID(rawValue: trashedLaneID)])
|
||||||
|
|
||||||
|
let retitled = try fixture.snapshot()
|
||||||
|
try fixture.item(
|
||||||
|
".trash/\(trashedLaneID)",
|
||||||
|
"---\nschema: 1\ntitle: Shipped\norder: 1024\nkind: lane\nbackground: blue\n---\n\n"
|
||||||
|
)
|
||||||
|
let styled = BoardDiff.between(retitled, try fixture.snapshot(), includingTrash: true)
|
||||||
|
#expect(styled.lanes.isEmpty, "no accent is rendered, so nothing visible changed")
|
||||||
|
#expect(styled.boardChanged, "the backstop still catches it — the bytes did change")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row that moved rank in the column is a move while shown, like a trash card reordered in
|
||||||
|
/// place — the position axis is the same axis whatever the kind.
|
||||||
|
@Test("A reordered lane row is a move while the trash is shown")
|
||||||
|
func reorderedRowIsAMove() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.trashedLane(trashedLaneID, order: "1024", title: "Done")
|
||||||
|
let before = try fixture.snapshot()
|
||||||
|
|
||||||
|
try fixture.trashedLane(trashedLaneID, order: "256", title: "Done")
|
||||||
|
let after = try fixture.snapshot()
|
||||||
|
|
||||||
|
#expect(BoardDiff.between(before, after, includingTrash: true).lanes.moved == [ItemID(rawValue: trashedLaneID)])
|
||||||
|
#expect(BoardDiff.between(before, after).isSilent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ let clipboardCard2 = ItemID(rawValue: Ident.card2)
|
|||||||
let clipboardCard3 = ItemID(rawValue: Ident.card3)
|
let clipboardCard3 = ItemID(rawValue: Ident.card3)
|
||||||
let clipboardCard4 = ItemID(rawValue: Ident.card4)
|
let clipboardCard4 = ItemID(rawValue: Ident.card4)
|
||||||
|
|
||||||
|
/// The trash's **lane row** in the harness below — the opaque unit ⌘X restores (03-board-ui.md §
|
||||||
|
/// Trash, lanes rejoined 2026-07-29).
|
||||||
|
let clipboardTrashedLane = ItemID(rawValue: Ident.lane3)
|
||||||
|
|
||||||
/// An ordinary card body, for the trash's resident.
|
/// An ordinary card body, for the trash's resident.
|
||||||
func trashResidentItem(order: String, title: String) -> String {
|
func trashResidentItem(order: String, title: String) -> String {
|
||||||
"""
|
"""
|
||||||
@@ -126,6 +130,21 @@ func makeClipboardHarness() throws -> ClipboardHarness {
|
|||||||
try ClipboardHarness(fixture: try makeClipboardBoard())
|
try ClipboardHarness(fixture: try makeClipboardBoard())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same board with a **lane row in its trash**, carrying one card — the clipboard's other trash
|
||||||
|
/// subject (04-interactions.md ▸ The trash: "⌘X works … a trashed lane pastes after the anchor
|
||||||
|
/// lane"). Its own fixture rather than a line in `makeClipboardBoard`, so every suite that counts the
|
||||||
|
/// trash's cards keeps counting exactly what it did.
|
||||||
|
@MainActor
|
||||||
|
func makeTrashedLaneHarness() throws -> ClipboardHarness {
|
||||||
|
let fixture = try makeClipboardBoard()
|
||||||
|
try fixture.item(
|
||||||
|
".trash/\(Ident.lane3)",
|
||||||
|
"---\nschema: 1\ntitle: Done\norder: 512\nkind: lane\nproject: lanework\n---\nDone body.\n"
|
||||||
|
)
|
||||||
|
try fixture.item("\(".trash/\(Ident.lane3)")/\(Ident.indexless)", Item.rich(order: "1024", title: "Freight"))
|
||||||
|
return try ClipboardHarness(fixture: fixture)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - The manifest
|
// MARK: - The manifest
|
||||||
|
|
||||||
@Suite("ClipboardManifest")
|
@Suite("ClipboardManifest")
|
||||||
@@ -627,6 +646,31 @@ struct ClipboardAvailabilityTests {
|
|||||||
#expect(harness.clipboard.canCut(from: harness.store))
|
#expect(harness.clipboard.canCut(from: harness.store))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The row is a lane on the clipboard's own axis: "the payload kinds never mix because the
|
||||||
|
/// selection never does" (04-interactions.md ▸ The trash), so a cut row writes a **lane** payload
|
||||||
|
/// recorded in the **trash** container.
|
||||||
|
@Test("A trashed lane row copies and cuts, and its payload is a lane in the trash")
|
||||||
|
func trashedLaneRowTakesCopyAndCut() throws {
|
||||||
|
let harness = try makeTrashedLaneHarness()
|
||||||
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
|
harness.store.transient.isTrashVisible = true
|
||||||
|
harness.store.select([clipboardTrashedLane], in: .trash)
|
||||||
|
#expect(harness.clipboard.canCopy(from: harness.store))
|
||||||
|
#expect(harness.clipboard.canCut(from: harness.store))
|
||||||
|
|
||||||
|
harness.clipboard.cut(from: harness.store)
|
||||||
|
let manifest = try #require(harness.clipboard.payload)
|
||||||
|
#expect(manifest.kind == .lane)
|
||||||
|
#expect(manifest.container == .trash)
|
||||||
|
#expect(manifest.entries.map(\.title) == ["Done"])
|
||||||
|
// The opaque unit describes itself and not its subtree: the *content* travels through the
|
||||||
|
// staged folder, which is copied whole.
|
||||||
|
#expect(manifest.entries.first?.cards.isEmpty == true)
|
||||||
|
#expect(harness.store.transient.pendingCut
|
||||||
|
== ItemReferenceSet(ids: [clipboardTrashedLane], container: .trash))
|
||||||
|
}
|
||||||
|
|
||||||
@Test("The read-only lock blocks cut but never copy")
|
@Test("The read-only lock blocks cut but never copy")
|
||||||
func lockBlocksCutOnly() throws {
|
func lockBlocksCutOnly() throws {
|
||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
|
|||||||
@@ -188,10 +188,19 @@ struct TrashDropTests {
|
|||||||
#expect(accepts(operation: .move))
|
#expect(accepts(operation: .move))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "Lanes are not deliverable this way (a lane drag proposes only lane slots)."
|
/// "Dropping a live card — **or lane** — on the shown trash deletes it … a lane drag over the
|
||||||
@Test("A lane drag never proposes into the trash")
|
/// shown trash proposes the delete alongside its strip slots" (04-interactions.md ▸ The trash,
|
||||||
func lanesAreNotDeliverable() {
|
/// lanes extended 2026-07-29, retiring "a lane drag proposes only lane slots").
|
||||||
#expect(!accepts(kind: .lanes))
|
@Test("A live lane drag proposes the delete too — and no session proposes nothing")
|
||||||
|
func lanesAreDeliverable() {
|
||||||
|
#expect(accepts(kind: .lanes))
|
||||||
|
// Every other clause binds the lane exactly as it binds the card: a trashed row dragged out
|
||||||
|
// is not deletable back into the place it already is, a foreign board's lane is refused, and
|
||||||
|
// a hidden column takes nothing.
|
||||||
|
#expect(!accepts(kind: .lanes, container: .trash))
|
||||||
|
#expect(!accepts(kind: .lanes, isWithinBoard: false))
|
||||||
|
#expect(!accepts(kind: .lanes, isTrashShown: false))
|
||||||
|
#expect(!accepts(kind: .lanes, acceptsMutations: false))
|
||||||
// And no session at all is no proposal either — the column is inert between drags.
|
// And no session at all is no proposal either — the column is inert between drags.
|
||||||
#expect(!accepts(kind: nil))
|
#expect(!accepts(kind: nil))
|
||||||
}
|
}
|
||||||
@@ -451,8 +460,11 @@ struct DropSettleTests {
|
|||||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2])
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A lane session never proposes into the trash")
|
/// The column draws the delete gesture's shadow for a **lane** session too (lanes extended
|
||||||
func laneSessionsHaveNoTrashLanding() throws {
|
/// 2026-07-29): the accessor asks "is this proposal mine", and the kind question belongs to
|
||||||
|
/// `TrashDrop.accepts`, which is asked at hover and again at release.
|
||||||
|
@Test("A lane session's trash proposal reaches the column, and only on its own board")
|
||||||
|
func laneSessionsProposeIntoTheTrash() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
@@ -465,7 +477,10 @@ struct DropSettleTests {
|
|||||||
)
|
)
|
||||||
session.propose(DropTarget(boardRoot: store.rootURL, container: .trash, index: 0))
|
session.propose(DropTarget(boardRoot: store.rootURL, container: .trash, index: 0))
|
||||||
|
|
||||||
#expect(session.trashProposal(onBoardRooted: store.rootURL) == nil)
|
#expect(session.trashProposal(onBoardRooted: store.rootURL) == 0)
|
||||||
|
#expect(session.shadowCount == 1)
|
||||||
|
// Another board's column draws nothing, like every other proposal accessor.
|
||||||
|
#expect(session.trashProposal(onBoardRooted: URL(filePath: "/tmp/other-board")) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: The hand-off
|
// MARK: The hand-off
|
||||||
|
|||||||
@@ -765,3 +765,174 @@ struct CrossBoardRestoreTests {
|
|||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Restoring a trashed lane
|
||||||
|
|
||||||
|
/// A board whose trash holds a **lane row** — the opaque unit, subtree intact (03-board-ui.md §
|
||||||
|
/// Trash) — beside an ordinary trashed card, so the restore's rank arithmetic has a strip to land
|
||||||
|
/// on and the container has more than one kind in it.
|
||||||
|
@MainActor
|
||||||
|
private func makeTrashedLaneBoard() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||||
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||||
|
try fixture.item(
|
||||||
|
".trash/\(Ident.lane3)",
|
||||||
|
"---\nschema: 1\ntitle: Done\norder: 512\nkind: lane\nproject: lanework\n---\nDone body.\n"
|
||||||
|
)
|
||||||
|
try fixture.item(".trash/\(Ident.lane3)/\(Ident.card2)", Item.rich(order: "1024", title: "Freight"))
|
||||||
|
try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Trashed"))
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Drag-to-restore at the lane level** (04-interactions.md ▸ The trash: "dropping … a trashed lane
|
||||||
|
/// row onto its own board's strip — is an ordinary move to the drop position"), which is
|
||||||
|
/// `BoardStore.restoreLanes` — an arrival's rank arithmetic with a within-board move's identity
|
||||||
|
/// posture and an undo step, since 13-native-undo.md's inverse inventory names "restore-by-move →
|
||||||
|
/// move back in".
|
||||||
|
@MainActor
|
||||||
|
@Suite("BoardStore ▸ restoring a trashed lane")
|
||||||
|
struct RestoreLaneTests {
|
||||||
|
|
||||||
|
@Test("The drop slot sets the restored lane's order, and its cards ride along")
|
||||||
|
func dropSlotSetsTheOrder() throws {
|
||||||
|
let fixture = try makeTrashedLaneBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
// Slot 1: between the board's two lanes.
|
||||||
|
store.restoreLanes([lane3], toIndex: 1)
|
||||||
|
|
||||||
|
let model = try loaded(fixture)
|
||||||
|
#expect(model.lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane3, Ident.lane2])
|
||||||
|
#expect(model.trashedLanes.isEmpty)
|
||||||
|
#expect(!fixture.exists(".trash/\(Ident.lane3)"), "the folder physically left the trash")
|
||||||
|
#expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)"), "the freight came back inside it")
|
||||||
|
#expect(model.trash.map(\.id.rawValue) == [Ident.card3], "the column's cards are untouched")
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The identity travels — a within-board restore is a move, never an import")
|
||||||
|
func identityTravels() throws {
|
||||||
|
let fixture = try makeTrashedLaneBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.restoreLanes([lane3], toIndex: 99)
|
||||||
|
|
||||||
|
// The lane keeps its UUID and so does its card: the import boundary's remint is for
|
||||||
|
// *arrivals from another board*, and a row coming out of this board's own trash is not one.
|
||||||
|
#expect(try loaded(fixture).lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, Ident.lane3])
|
||||||
|
#expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)"))
|
||||||
|
#expect(try fixture.indexText(Ident.lane3).contains("project: lanework"), "unknown keys ride along")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Its undo is the ordinary move back in, at the trash rank the row was holding")
|
||||||
|
func undoMovesItBackIn() throws {
|
||||||
|
let fixture = try makeTrashedLaneBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let history = NativeHistoryProvider()
|
||||||
|
store.history = history
|
||||||
|
|
||||||
|
store.restoreLanes([lane3], toIndex: 0)
|
||||||
|
#expect(history.undoActionName == "Move Lane")
|
||||||
|
|
||||||
|
history.undo()
|
||||||
|
#expect(fixture.exists(".trash/\(Ident.lane3)/\(Ident.card2)"), "back in, subtree intact")
|
||||||
|
#expect(!fixture.exists(Ident.lane3))
|
||||||
|
#expect(try order(fixture, ".trash/\(Ident.lane3)") == .valid(512), "at the rank it was holding")
|
||||||
|
|
||||||
|
history.redo()
|
||||||
|
#expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)"))
|
||||||
|
#expect(try loaded(fixture).trashedLanes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A row that is not in the trash, and an empty set, write nothing")
|
||||||
|
func skipsWhatIsNotThere() throws {
|
||||||
|
let fixture = try makeTrashedLaneBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let untouched = try stat(fixture, Ident.lane1)
|
||||||
|
|
||||||
|
store.restoreLanes([lane1], toIndex: 0)
|
||||||
|
store.restoreLanes([], toIndex: 0)
|
||||||
|
|
||||||
|
#expect(try loaded(fixture).lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2])
|
||||||
|
#expect(try stat(fixture, Ident.lane1) == untouched)
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keyboard restore's write (04-interactions.md ▸ The trash: "⌘X in the trash, ⌘V … a trashed
|
||||||
|
/// lane pastes after the anchor lane"), whose armed-cut path lands in `receiveLanes` with a
|
||||||
|
/// source folder inside this board's own `.trash/`.
|
||||||
|
///
|
||||||
|
/// **The identity has to survive that**, which is what the trash-aware source-root derivation
|
||||||
|
/// buys: `.trash/` counts in the destination's identity scan, so a restore mistaken for an import
|
||||||
|
/// would find the row's own UUID resident and remint the very lane it was restoring.
|
||||||
|
@Test("A within-board paste out of the trash keeps the lane's identity")
|
||||||
|
func pasteRestoreKeepsTheIdentity() throws {
|
||||||
|
let fixture = try makeTrashedLaneBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.receiveLanes(
|
||||||
|
[.folder(fixture.url(".trash/\(Ident.lane3)"))],
|
||||||
|
operation: .move,
|
||||||
|
at: 2,
|
||||||
|
normalizingLooseFiles: true
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(try loaded(fixture).lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, Ident.lane3])
|
||||||
|
#expect(fixture.exists("\(Ident.lane3)/\(Ident.card2)"))
|
||||||
|
#expect(try loaded(fixture).trashedLanes.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cross-board half — the ordinary arrival, exactly as for a live lane: "Dropped on *another*
|
||||||
|
/// board it follows the copy default … ⌘-drag forces the true cross-board restore-move"
|
||||||
|
/// (04-interactions.md ▸ The trash).
|
||||||
|
@MainActor
|
||||||
|
@Suite("BoardStore ▸ cross-board lane restore")
|
||||||
|
struct CrossBoardLaneRestoreTests {
|
||||||
|
|
||||||
|
@Test("A cross-board copy out of the trash mints fresh identities and leaves the original")
|
||||||
|
func copyLeavesTheOriginal() throws {
|
||||||
|
let destination = try makeBoard()
|
||||||
|
defer { destination.tearDown() }
|
||||||
|
let source = try makeTrashedLaneBoard()
|
||||||
|
defer { source.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
|
store.receiveLanes([source.url(".trash/\(Ident.lane3)")], operation: .copy, at: 99)
|
||||||
|
|
||||||
|
let model = try loaded(destination)
|
||||||
|
let landed = try #require(model.lanes.last)
|
||||||
|
#expect(landed.title.value == "Done")
|
||||||
|
#expect(landed.id.rawValue != Ident.lane3, "a copy out of the trash is still a copy")
|
||||||
|
#expect(landed.cards.map(\.title.value) == ["Freight"])
|
||||||
|
#expect(source.exists(".trash/\(Ident.lane3)"), "the original stays in the source trash")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A ⌘-drag move carries the lane's identity and empties the source trash")
|
||||||
|
func moveCarriesTheIdentity() throws {
|
||||||
|
let destination = try makeBoard()
|
||||||
|
defer { destination.tearDown() }
|
||||||
|
let source = try makeTrashedLaneBoard()
|
||||||
|
defer { source.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
|
store.receiveLanes([source.url(".trash/\(Ident.lane3)")], operation: .move, at: 0)
|
||||||
|
|
||||||
|
let landed = try #require(loaded(destination).lanes.first)
|
||||||
|
#expect(landed.id.rawValue == Ident.lane3, "the lane's identity travels")
|
||||||
|
// Its card carried `Ident.card2`, which this destination already holds — so the import
|
||||||
|
// boundary remints that folder and only that folder, per-folder at the finest grain.
|
||||||
|
#expect(landed.cards.map(\.title.value) == ["Freight"])
|
||||||
|
#expect(landed.cards.map(\.id.rawValue) != [Ident.card2])
|
||||||
|
#expect(!source.exists(".trash/\(Ident.lane3)"))
|
||||||
|
#expect(try loaded(source).trashedLanes.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -157,6 +157,31 @@ struct NavigationMathTests {
|
|||||||
NavigationMath.nearest(from: originFrame, direction: .down, among: all, where: { $0.container == .board }) == card3
|
NavigationMath.nearest(from: originFrame, direction: .down, among: all, where: { $0.container == .board }) == card3
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Inside the trash, plain arrows walk every row and extension stops at the kind boundary**
|
||||||
|
/// (04-interactions.md ▸ The trash: "plain arrows walk across, extension stops"), which is the two
|
||||||
|
/// halves of the arrow handler expressed over one registry of drawn frames — a lane row registers
|
||||||
|
/// like a card face precisely so ↓ can reach it.
|
||||||
|
@Test("A trash lane row is a plain arrow's neighbour, and an extension's dead end")
|
||||||
|
func trashLaneRowsAreNavigableButNotExtendable() {
|
||||||
|
let origin = target(card1, x: 0, y: 0, container: .trash)
|
||||||
|
let row = target(lane2, x: 0, y: 120, kind: .lane, container: .trash)
|
||||||
|
let below = target(card2, x: 0, y: 240, container: .trash)
|
||||||
|
let all = [origin, row, below]
|
||||||
|
|
||||||
|
// Plain: the next row down, whatever its kind.
|
||||||
|
#expect(NavigationMath.nearest(from: origin.frame, direction: .down, among: all) == lane2)
|
||||||
|
|
||||||
|
// ⇧: the handler takes the *same* unrestricted neighbour and then tests it, so a crossing
|
||||||
|
// row makes the press inert rather than being stepped over in search of a legal one — the
|
||||||
|
// rule exists so a held range is never silently widened past what the user asked for.
|
||||||
|
let next = NavigationMath.nearest(from: origin.frame, direction: .down, among: all)
|
||||||
|
#expect(next == lane2)
|
||||||
|
#expect(row.kind != origin.kind, "so the extension stops here")
|
||||||
|
|
||||||
|
// From the row itself, ↓ reaches the card below it: navigation crosses back.
|
||||||
|
#expect(NavigationMath.nearest(from: row.frame, direction: .down, among: all) == card2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SortMath
|
// MARK: - SortMath
|
||||||
|
|||||||
@@ -301,6 +301,57 @@ struct PasteFromTrashTests {
|
|||||||
#expect(lane.cards.map(\.id).contains(clipboardCard3), "identity travels — it is a move")
|
#expect(lane.cards.map(\.id).contains(clipboardCard3), "identity travels — it is a move")
|
||||||
#expect(harness.store.transient.pendingCut.isEmpty, "the cut was consumed")
|
#expect(harness.store.transient.pendingCut.isEmpty, "the cut was consumed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The lane row's keyboard restore** (04-interactions.md ▸ The trash: "a card pastes into a
|
||||||
|
/// lane, a trashed lane pastes after the anchor lane (the lane-paste rule above, verbatim)").
|
||||||
|
///
|
||||||
|
/// The identity is the load-bearing assertion: the armed cut hands `receiveLanes` a folder inside
|
||||||
|
/// this board's own `.trash/`, and `.trash/` counts in the destination board's identity scan — so
|
||||||
|
/// a restore mistaken for an import would remint the very lane it was restoring.
|
||||||
|
@Test("Cut a trashed lane row and paste: it lands after the anchor lane, identity and freight intact")
|
||||||
|
func cutALaneRowAndPasteIsTheRestore() async throws {
|
||||||
|
let harness = try makeTrashedLaneHarness()
|
||||||
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
|
harness.store.transient.isTrashVisible = true
|
||||||
|
harness.store.select([clipboardTrashedLane], in: .trash)
|
||||||
|
harness.clipboard.cut(from: harness.store)
|
||||||
|
// The anchor: the paste lands *after* the selected lane, exactly as a live lane paste does.
|
||||||
|
harness.store.select([clipboardLane1], in: .board)
|
||||||
|
await harness.clipboard.paste(into: harness.store)?.value
|
||||||
|
|
||||||
|
let model = try pasted(harness.fixture)
|
||||||
|
#expect(model.trashedLanes.isEmpty, "the folder left the trash")
|
||||||
|
#expect(model.lanes.map(\.id) == [clipboardLane1, clipboardTrashedLane, clipboardLane2])
|
||||||
|
let restored = try #require(model.lanes.first { $0.id == clipboardTrashedLane })
|
||||||
|
#expect(restored.cards.map(\.title.value) == ["Freight"], "the subtree came back with it")
|
||||||
|
#expect(restored.cards.map(\.id.rawValue) == [Ident.indexless], "and kept its own identities")
|
||||||
|
#expect(harness.store.transient.pendingCut.isEmpty, "the cut was consumed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ⌘C on a row copies out like any lane copy: fresh GUIDs throughout, original left in the trash
|
||||||
|
/// (04 ▸ The trash's copy default, ▸ Clipboard's "a pasted *copy* takes fresh GUIDs throughout").
|
||||||
|
@Test("A trashed lane copies out as a fresh-GUID lane, carrying its cards")
|
||||||
|
func laneRowCopiesOut() async throws {
|
||||||
|
let harness = try makeTrashedLaneHarness()
|
||||||
|
defer { harness.tearDown() }
|
||||||
|
let destination = try makeDestination()
|
||||||
|
defer { destination.tearDown() }
|
||||||
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
|
harness.store.transient.isTrashVisible = true
|
||||||
|
harness.store.select([clipboardTrashedLane], in: .trash)
|
||||||
|
harness.clipboard.copy(from: harness.store)
|
||||||
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
|
let model = try pasted(destination)
|
||||||
|
let landed = try #require(model.lanes.last)
|
||||||
|
#expect(landed.title.value == "Done")
|
||||||
|
#expect(landed.id != clipboardTrashedLane, "a copy mints fresh identities at every level")
|
||||||
|
#expect(landed.cards.map(\.title.value) == ["Freight"])
|
||||||
|
#expect(try pasted(harness.fixture).trashedLanes.map(\.id) == [clipboardTrashedLane],
|
||||||
|
"the original stays in the source trash")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The destination's search, and the stale pasteboard
|
// MARK: - The destination's search, and the stale pasteboard
|
||||||
|
|||||||
@@ -96,16 +96,24 @@ private func makeBoard() throws -> WriterFixture {
|
|||||||
return fixture
|
return fixture
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A trash holding both row kinds, one of each matching `login` — the shape "participates in the
|
/// A trash holding **both row kinds**, interleaved by rank — the shape the column's own filter rule
|
||||||
/// filter like any lane" needs, since a lane entry is filtered by its *own* title and body.
|
/// needs (03-board-ui.md § Trash, lanes rejoined 2026-07-29: "The row matches the search filter by
|
||||||
|
/// lane title only").
|
||||||
///
|
///
|
||||||
/// `TrashModel`'s sort is newest first, so the row order is `[card1, laneX, card2]`.
|
/// `laneX`'s body says `login` and its title does not, which is what makes the title-only rule
|
||||||
|
/// checkable rather than merely stated: a query of `login` leaves `card1` alone.
|
||||||
|
///
|
||||||
|
/// The ranks put the lane row between the two cards, so the merged order is `[card1, laneX, card2]`.
|
||||||
@MainActor
|
@MainActor
|
||||||
private func makeTrashBoard() throws -> WriterFixture {
|
private func makeTrashBoard() throws -> WriterFixture {
|
||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
try fixture.item("", Item.board)
|
try fixture.item("", Item.board)
|
||||||
try fixture.item(Ident.lane1, item(order: "1024", title: "Todo", body: "Inbox lane."))
|
try fixture.item(Ident.lane1, item(order: "1024", title: "Todo", body: "Inbox lane."))
|
||||||
try fixture.item(".trash/\(Ident.card1)", item(order: "1024", title: "Fix login", body: "Auth."))
|
try fixture.item(".trash/\(Ident.card1)", item(order: "1024", title: "Fix login", body: "Auth."))
|
||||||
|
try fixture.item(
|
||||||
|
".trash/\(More.laneX)",
|
||||||
|
"---\nschema: 1\ntitle: Archive\norder: 1536\nkind: lane\n---\nOld login notes.\n"
|
||||||
|
)
|
||||||
try fixture.item(".trash/\(Ident.card2)", item(order: "2048", title: "Polish", body: "Wording."))
|
try fixture.item(".trash/\(Ident.card2)", item(order: "2048", title: "Polish", body: "Wording."))
|
||||||
return fixture
|
return fixture
|
||||||
}
|
}
|
||||||
@@ -322,18 +330,34 @@ struct SearchFilterOrderTests {
|
|||||||
|
|
||||||
#expect(SelectionGrammar.trashCards(in: model) == [card1, card2])
|
#expect(SelectionGrammar.trashCards(in: model) == [card1, card2])
|
||||||
#expect(SelectionGrammar.trashCards(in: model, filter: SearchFilter(query: "login")) == [card1])
|
#expect(SelectionGrammar.trashCards(in: model, filter: SearchFilter(query: "login")) == [card1])
|
||||||
// And there is no lane list in the trash at all — "Cards only. Lanes are never trashed".
|
|
||||||
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: model).isEmpty)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("The trash's visible universe is its matching cards")
|
/// "The row matches the search filter by lane title only" (03-board-ui.md § Trash) — the opaque
|
||||||
|
/// unit's own rule, and the one place a lane *is* filtered: `laneX`'s body carries the query and
|
||||||
|
/// the row still leaves the column, because its body is not on screen.
|
||||||
|
@Test("A trashed lane row filters by title alone, and its own list is kind-scoped")
|
||||||
|
func trashLaneRowsFilterByTitle() throws {
|
||||||
|
let fixture = try makeTrashBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let model = try load(fixture)
|
||||||
|
|
||||||
|
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: model) == [laneX])
|
||||||
|
#expect(SelectionGrammar.trashLanes(in: model, filter: SearchFilter(query: "archive")) == [laneX])
|
||||||
|
// The body says "login". The row does not.
|
||||||
|
#expect(SelectionGrammar.trashLanes(in: model, filter: SearchFilter(query: "login")).isEmpty)
|
||||||
|
// And the kind-scoped lists are disjoint slices of one rank order, which is what makes a
|
||||||
|
// ⇧-range skip the other kind (04-interactions.md ▸ The trash).
|
||||||
|
#expect(SelectionGrammar.trashRows(in: model) == [card1, laneX, card2])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The trash's visible universe is its matching rows, both kinds")
|
||||||
func trashUniverseNarrows() throws {
|
func trashUniverseNarrows() throws {
|
||||||
let fixture = try makeTrashBoard()
|
let fixture = try makeTrashBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let model = try load(fixture)
|
let model = try load(fixture)
|
||||||
|
|
||||||
#expect(SearchFilter(query: "login").visibleIDs(in: model, container: .trash) == [card1])
|
#expect(SearchFilter(query: "login").visibleIDs(in: model, container: .trash) == [card1])
|
||||||
#expect(SearchFilter.inactive.visibleIDs(in: model, container: .trash) == [card1, card2])
|
#expect(SearchFilter.inactive.visibleIDs(in: model, container: .trash) == [card1, card2, laneX])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The trash *column* narrows through the same predicate, which is the whole of "shown, its cards
|
/// The trash *column* narrows through the same predicate, which is the whole of "shown, its cards
|
||||||
@@ -347,15 +371,17 @@ struct SearchFilterOrderTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let model = try load(fixture)
|
let model = try load(fixture)
|
||||||
|
|
||||||
#expect(TrashLaneView.rendered(model.trash, filter: .inactive).map(\.id) == [card1, card2])
|
// Both kinds, interleaved purely by rank (03-board-ui.md § Trash).
|
||||||
#expect(TrashLaneView.rendered(model.trash, filter: SearchFilter(query: "login")).map(\.id) == [card1])
|
#expect(TrashLaneView.rendered(model, filter: .inactive).map(\.id) == [card1, laneX, card2])
|
||||||
|
#expect(TrashLaneView.rendered(model, filter: SearchFilter(query: "login")).map(\.id) == [card1])
|
||||||
// The column and the arrow grammar cannot disagree about what is on screen.
|
// The column and the arrow grammar cannot disagree about what is on screen.
|
||||||
#expect(TrashLaneView.rendered(model.trash, filter: SearchFilter(query: "login")).map(\.id)
|
#expect(TrashLaneView.rendered(model, filter: SearchFilter(query: "login")).map(\.id)
|
||||||
== SelectionGrammar.trashCards(in: model, filter: SearchFilter(query: "login")))
|
== SelectionGrammar.trashRows(in: model, filter: SearchFilter(query: "login")))
|
||||||
// A query nothing matches empties the column without emptying the container — which is why
|
// A query nothing matches empties the column without emptying the container — which is why
|
||||||
// Empty Trash's validation reads `.trash/` and not this list.
|
// Empty Trash's validation reads `.trash/` and not this list.
|
||||||
#expect(TrashLaneView.rendered(model.trash, filter: SearchFilter(query: "zzzz")).isEmpty)
|
#expect(TrashLaneView.rendered(model, filter: SearchFilter(query: "zzzz")).isEmpty)
|
||||||
#expect(!model.trash.isEmpty)
|
#expect(!model.trash.isEmpty)
|
||||||
|
#expect(!model.trashedLanes.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("The delete successor is drawn from what the lane is showing")
|
@Test("The delete successor is drawn from what the lane is showing")
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import Testing
|
|||||||
/// read card ordering and the trash container, and a hand-built `BoardModel` would let both drift
|
/// read card ordering and the trash container, and a hand-built `BoardModel` would let both drift
|
||||||
/// from what the loader actually produces.
|
/// from what the loader actually produces.
|
||||||
///
|
///
|
||||||
/// **Two homogeneity axes, not three** (resettled 2026-07-28): cards XOR lanes, and board XOR trash.
|
/// **Two homogeneity axes, and the kind one reaches into the trash** (resettled 2026-07-28; lanes
|
||||||
/// The third — card entries XOR lane entries *inside* the trash — retired with the lane entries it
|
/// rejoined 2026-07-29): cards XOR lanes, and board XOR trash. The trash's rows are cards *and*
|
||||||
/// separated, because lanes are never trashed.
|
/// opaque lane units, so "a trash selection is either cards or lane rows" is the board's own kind
|
||||||
|
/// rule in a second container rather than a third axis.
|
||||||
///
|
///
|
||||||
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
|
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
|
||||||
|
|
||||||
@@ -58,6 +59,23 @@ private func makeTrashBoard() throws -> WriterFixture {
|
|||||||
return fixture
|
return fixture
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same trash with **two lane rows interleaved among its cards** (03-board-ui.md § Trash, lanes
|
||||||
|
/// rejoined 2026-07-29): the rank order is `[card1, lane2, card2, lane3, card3]`, so every
|
||||||
|
/// kind-boundary claim below has a row of the other kind sitting inside the span it asks about.
|
||||||
|
@MainActor
|
||||||
|
private func makeMixedTrashBoard() throws -> WriterFixture {
|
||||||
|
let fixture = try makeTrashBoard()
|
||||||
|
try fixture.item(
|
||||||
|
".trash/\(Ident.lane2)",
|
||||||
|
"---\nschema: 1\ntitle: Doing\norder: 384\nkind: lane\n---\n"
|
||||||
|
)
|
||||||
|
try fixture.item(
|
||||||
|
".trash/\(Ident.lane3)",
|
||||||
|
"---\nschema: 1\ntitle: Done\norder: 768\nkind: lane\n---\n"
|
||||||
|
)
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
private let lane1 = ItemID(rawValue: Ident.lane1)
|
private let lane1 = ItemID(rawValue: Ident.lane1)
|
||||||
private let lane2 = ItemID(rawValue: Ident.lane2)
|
private let lane2 = ItemID(rawValue: Ident.lane2)
|
||||||
private let lane3 = ItemID(rawValue: Ident.lane3)
|
private let lane3 = ItemID(rawValue: Ident.lane3)
|
||||||
@@ -117,7 +135,7 @@ struct SelectionOrderTests {
|
|||||||
#expect(SelectionGrammar.lanes(in: snapshot) == [lane1, lane2, lane3])
|
#expect(SelectionGrammar.lanes(in: snapshot) == [lane1, lane2, lane3])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("The trash's list is its cards, in `order`; its lane list is empty by construction")
|
@Test("The trash's list is its cards, in `order`; a trash with no lane rows has no lane list")
|
||||||
func trashOrder() throws {
|
func trashOrder() throws {
|
||||||
let fixture = try makeTrashBoard()
|
let fixture = try makeTrashBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
@@ -125,11 +143,39 @@ struct SelectionOrderTests {
|
|||||||
|
|
||||||
#expect(SelectionGrammar.trashCards(in: snapshot) == [card1, card2, card3])
|
#expect(SelectionGrammar.trashCards(in: snapshot) == [card1, card2, card3])
|
||||||
#expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == [card1, card2, card3])
|
#expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == [card1, card2, card3])
|
||||||
// "Cards only. Lanes are never trashed" — so there is no list to walk rather than a rule
|
|
||||||
// saying not to (03-board-ui.md § Trash).
|
|
||||||
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot).isEmpty)
|
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot).isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The column's three lists** (03-board-ui.md § Trash; 04-interactions.md ▸ The trash): one
|
||||||
|
/// merged rank order for *navigation*, and two kind-scoped slices of it for *ranging*. The
|
||||||
|
/// slices are what make a ⇧-range skip the other kind without a rule that says so.
|
||||||
|
@Test("The trash's rows interleave by rank, and each kind's list is a slice of that order")
|
||||||
|
func trashRowsInterleave() throws {
|
||||||
|
let fixture = try makeMixedTrashBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
#expect(SelectionGrammar.trashRows(in: snapshot) == [card1, lane2, card2, lane3, card3])
|
||||||
|
#expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == [card1, card2, card3])
|
||||||
|
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot) == [lane2, lane3])
|
||||||
|
// One merge, one order: the path resolver batches in exactly the order the column draws.
|
||||||
|
#expect(ItemPath.resolve([card2, lane2, lane3], in: .trash, snapshot: snapshot)
|
||||||
|
== [.trashLane(lane2), .trashCard(card2), .trashLane(lane3)])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The trash's kind is derived from the snapshot for both kinds")
|
||||||
|
func trashKindDerivation() throws {
|
||||||
|
let fixture = try makeMixedTrashBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
#expect(SelectionGrammar.kind(of: set([card1, card2], .trash), in: snapshot) == .card)
|
||||||
|
#expect(SelectionGrammar.kind(of: set([lane2, lane3], .trash), in: snapshot) == .lane)
|
||||||
|
// A board id claimed on the trash side names nothing there — the container is the question.
|
||||||
|
#expect(SelectionGrammar.kind(of: set([card6], .trash), in: snapshot) == nil)
|
||||||
|
#expect(SelectionGrammar.kind(of: set([lane2]), in: snapshot) == nil, "and the reverse")
|
||||||
|
}
|
||||||
|
|
||||||
@Test("A selection's kind is derived from the snapshot, and a ghost selection has none")
|
@Test("A selection's kind is derived from the snapshot, and a ghost selection has none")
|
||||||
func kindDerivation() throws {
|
func kindDerivation() throws {
|
||||||
let fixture = try makeLiveBoard()
|
let fixture = try makeLiveBoard()
|
||||||
@@ -241,6 +287,26 @@ struct CommandClickTests {
|
|||||||
#expect(ontoCard.anchor == card1)
|
#expect(ontoCard.anchor == card1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The kind axis inside the trash: "a trash selection is either cards or lane rows,
|
||||||
|
/// kind-homogeneous like the live board's own grammar" (04-interactions.md ▸ The trash).
|
||||||
|
@Test("⌘-click across the kind boundary inside the trash replaces")
|
||||||
|
func acrossKindInTheTrashReplaces() throws {
|
||||||
|
let fixture = try makeMixedTrashBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
let ontoRow = click(target(lane2, .lane, .trash), .command, selection: set([card1], .trash), anchor: card1, in: snapshot)
|
||||||
|
#expect(ontoRow.selection == set([lane2], .trash))
|
||||||
|
|
||||||
|
let ontoCard = click(target(card1, .card, .trash), .command, selection: set([lane2, lane3], .trash), anchor: lane3, in: snapshot)
|
||||||
|
#expect(ontoCard.selection == set([card1], .trash))
|
||||||
|
|
||||||
|
// Within one kind it still toggles, which is what makes the branch above a rule rather than
|
||||||
|
// a refusal of ⌘ in the trash.
|
||||||
|
let added = click(target(lane3, .lane, .trash), .command, selection: set([lane2], .trash), anchor: lane2, in: snapshot)
|
||||||
|
#expect(added.selection == set([lane2, lane3], .trash))
|
||||||
|
}
|
||||||
|
|
||||||
@Test("⌘-click across the container boundary replaces too")
|
@Test("⌘-click across the container boundary replaces too")
|
||||||
func acrossContainerReplaces() throws {
|
func acrossContainerReplaces() throws {
|
||||||
let fixture = try makeTrashBoard()
|
let fixture = try makeTrashBoard()
|
||||||
@@ -355,6 +421,30 @@ struct ShiftClickTests {
|
|||||||
#expect(outcome.anchor == card1)
|
#expect(outcome.anchor == card1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "⇧-click ranges skip rows of the other kind (resurrecting the 2026-07-28 skip-by-kind ruling,
|
||||||
|
/// mooted when lanes left the trash and back with them)" — 04-interactions.md ▸ The trash.
|
||||||
|
@Test("A trash range skips rows of the other kind")
|
||||||
|
func trashRangeSkipsTheOtherKind() throws {
|
||||||
|
let fixture = try makeMixedTrashBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
// The column reads [card1, lane2, card2, lane3, card3]: a card range from the top to the
|
||||||
|
// bottom takes the three cards and steps over both lane rows.
|
||||||
|
let cards = click(target(card3, .card, .trash), .shift, selection: set([card1], .trash), anchor: card1, in: snapshot)
|
||||||
|
#expect(cards.selection == set([card1, card2, card3], .trash))
|
||||||
|
|
||||||
|
// And a lane-row range takes the rows, skipping the card sitting between them.
|
||||||
|
let rows = click(target(lane3, .lane, .trash), .shift, selection: set([lane2], .trash), anchor: lane2, in: snapshot)
|
||||||
|
#expect(rows.selection == set([lane2, lane3], .trash))
|
||||||
|
|
||||||
|
// A range aimed across the kinds has no list holding both endpoints, so it degrades to a
|
||||||
|
// plain click — never a mixed selection.
|
||||||
|
let crossed = click(target(lane3, .lane, .trash), .shift, selection: set([card1], .trash), anchor: card1, in: snapshot)
|
||||||
|
#expect(crossed.selection == set([lane3], .trash))
|
||||||
|
#expect(crossed.anchor == lane3)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("A range never crosses the container boundary")
|
@Test("A range never crosses the container boundary")
|
||||||
func trashRangeStopsAtTheBoundary() throws {
|
func trashRangeStopsAtTheBoundary() throws {
|
||||||
let fixture = try makeTrashBoard()
|
let fixture = try makeTrashBoard()
|
||||||
@@ -393,13 +483,16 @@ struct MarqueeMathTests {
|
|||||||
#expect(ids == [card1, card2])
|
#expect(ids == [card1, card2])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **There is no kind rule any more.** The tombstone model interleaved card rows and lane rows
|
/// **The kind filter is the whole rule, and in the trash it is load-bearing.** A trashed lane row
|
||||||
/// in one column, so the band needed a topmost-wins tie-break to stay homogeneous by kind; lanes
|
/// registers its frame like a card does — the arrows navigate by those frames — so the band
|
||||||
/// are never trashed now, so both containers hold cards and one line serves both.
|
/// genuinely sweeps over one and must still leave it out: "the rubber band … selects cards only
|
||||||
@Test("On the trash side the band takes the trash's cards, and stays on its own side")
|
/// (as the board marquee does); lane rows join by click grammar" (04-interactions.md ▸ The trash).
|
||||||
|
@Test("On the trash side the band takes cards only, and stays on its own side")
|
||||||
func trashSideTakesItsOwnCards() {
|
func trashSideTakesItsOwnCards() {
|
||||||
let targets = [
|
let targets = [
|
||||||
Self.card(card1, 0, container: .trash),
|
Self.card(card1, 0, container: .trash),
|
||||||
|
MarqueeTarget(id: lane2, kind: .lane, container: .trash,
|
||||||
|
frame: CGRect(x: 0, y: 50, width: 100, height: 30)),
|
||||||
Self.card(card2, 100, container: .trash),
|
Self.card(card2, 100, container: .trash),
|
||||||
Self.card(card3, 50)
|
Self.card(card3, 50)
|
||||||
]
|
]
|
||||||
@@ -550,6 +643,24 @@ struct SelectAllTests {
|
|||||||
#expect(store.selection == set([card1, card2, card3], .trash))
|
#expect(store.selection == set([card1, card2, card3], .trash))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Select All is card-scoped everywhere, never lane rows" (04-interactions.md ▸ The trash,
|
||||||
|
/// re-affirmed 2026-07-29): a lane-row selection is a *trash* selection, so the command reads the
|
||||||
|
/// column — and what it selects there is its cards.
|
||||||
|
@Test("A lane-row selection still selects the trash's cards, never the rows")
|
||||||
|
func trashBranchIsCardScopedWithLaneRows() throws {
|
||||||
|
let fixture = try makeMixedTrashBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
store.transient.isTrashVisible = true
|
||||||
|
|
||||||
|
store.select([lane2], in: .trash)
|
||||||
|
store.selectAll()
|
||||||
|
|
||||||
|
#expect(store.selection == set([card1, card2, card3], .trash))
|
||||||
|
#expect(!store.selection.ids.contains(lane2))
|
||||||
|
#expect(!store.selection.ids.contains(lane3))
|
||||||
|
}
|
||||||
|
|
||||||
@Test("The trash branch is narrow: hidden, live, empty, or ghost selections take the board")
|
@Test("The trash branch is narrow: hidden, live, empty, or ghost selections take the board")
|
||||||
func trashBranchFallsThrough() throws {
|
func trashBranchFallsThrough() throws {
|
||||||
let fixture = try makeTrashBoard()
|
let fixture = try makeTrashBoard()
|
||||||
|
|||||||
@@ -21,6 +21,22 @@ private enum More {
|
|||||||
static let newer = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
static let newer = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A lane already sitting in `<root>/.trash/` — the opaque unit, `kind: lane` being the only thing
|
||||||
|
/// that tells it from a card in the flat container (01-storage-format.md § Deletion).
|
||||||
|
private func trashedLane(order: String, title: String) -> String {
|
||||||
|
"""
|
||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
title: \(title)
|
||||||
|
order: \(order)
|
||||||
|
kind: lane
|
||||||
|
project: lanework # agent overlay
|
||||||
|
---
|
||||||
|
\(title) body.
|
||||||
|
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
/// A card already sitting in `<root>/.trash/` — an ordinary card in a special place, with an unknown
|
/// A card already sitting in `<root>/.trash/` — an ordinary card in a special place, with an unknown
|
||||||
/// key so the verbatim-preservation claims have something to preserve.
|
/// key so the verbatim-preservation claims have something to preserve.
|
||||||
private func trashResident(order: String, title: String) -> String {
|
private func trashResident(order: String, title: String) -> String {
|
||||||
@@ -346,6 +362,33 @@ struct DeleteLaneTests {
|
|||||||
#expect(store.selection.ids == [lane3])
|
#expect(store.selection.ids == [lane3])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Dropping a live card — **or lane** — on the shown trash deletes it" (04-interactions.md ▸ The
|
||||||
|
/// trash, lanes extended 2026-07-29): the drop writes exactly what ⌫ writes, and — like the card
|
||||||
|
/// gesture — says nothing about the selection, because a drag has no keystroke to keep repeatable
|
||||||
|
/// and its run is not necessarily the selection at all.
|
||||||
|
@Test("Drop-on-trash writes exactly what ⌫ writes at the lane level, and leaves the selection alone")
|
||||||
|
func laneDropOnTrashMatchesTheKeystroke() throws {
|
||||||
|
let byKey = try makeBoard()
|
||||||
|
defer { byKey.tearDown() }
|
||||||
|
let byDrag = try makeBoard()
|
||||||
|
defer { byDrag.tearDown() }
|
||||||
|
let keyStore = try BoardStore(rootURL: byKey.root)
|
||||||
|
let dragStore = try BoardStore(rootURL: byDrag.root)
|
||||||
|
dragStore.select([card1], in: .board)
|
||||||
|
|
||||||
|
keyStore.delete([lane3])
|
||||||
|
dragStore.deleteLanesByDrag(laneIDs: [lane3])
|
||||||
|
|
||||||
|
// Same folder, same subtree, same rank — `moveLanesToTrash` is the one write both take.
|
||||||
|
#expect(byDrag.exists(".trash/\(Ident.lane3)/\(Ident.card4)"))
|
||||||
|
#expect(try untouchedLines(byDrag.indexText(".trash/\(Ident.lane3)"))
|
||||||
|
== untouchedLines(byKey.indexText(".trash/\(Ident.lane3)")))
|
||||||
|
#expect(try loaded(byDrag).trashedLanes.map(\.order) == loaded(byKey).trashedLanes.map(\.order))
|
||||||
|
// ⌫ moved the selection to the successor lane; the drag left it exactly where it was.
|
||||||
|
#expect(dragStore.selection.ids == [card1])
|
||||||
|
#expect(dragStore.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("A set naming both a lane and a card acts on the lane — the selection is cards XOR lanes")
|
@Test("A set naming both a lane and a card acts on the lane — the selection is cards XOR lanes")
|
||||||
func lanesWinAMixedSet() throws {
|
func lanesWinAMixedSet() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
@@ -488,6 +531,185 @@ struct PurgeTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - The trash's other kind of row
|
||||||
|
|
||||||
|
/// A board whose trash holds **both kinds, interleaved by rank**: a card on top, then a lane row
|
||||||
|
/// carrying two cards, then a second card (03-board-ui.md § Trash, lanes rejoined 2026-07-29).
|
||||||
|
///
|
||||||
|
/// The lane row sits between the two cards deliberately — every claim below about crossing kinds
|
||||||
|
/// needs a row of the other kind on both sides of it.
|
||||||
|
@MainActor
|
||||||
|
private func makeTrashedLaneBoard() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||||
|
try fixture.item(".trash/\(More.newer)", trashResident(order: "512", title: "Newer"))
|
||||||
|
try fixture.item(".trash/\(Ident.lane2)", trashedLane(order: "768", title: "Doing"))
|
||||||
|
try fixture.item(".trash/\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
|
||||||
|
try fixture.item(".trash/\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Fourth"))
|
||||||
|
try fixture.item(".trash/\(Ident.indexless)", trashResident(order: "1024", title: "Trashed"))
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The permanent delete, the confirmation that stands in front of it, and the successor it leaves —
|
||||||
|
/// on a **trashed lane row** (03-board-ui.md § Trash: "it restores whole or purges whole").
|
||||||
|
@MainActor
|
||||||
|
@Suite("BoardStore ▸ purging a trashed lane row")
|
||||||
|
struct PurgeTrashedLaneTests {
|
||||||
|
|
||||||
|
@Test("The row purges whole — its subtree with it — and registers no undo step")
|
||||||
|
func rowPurgesWhole() throws {
|
||||||
|
let fixture = try makeTrashedLaneBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let history = NativeHistoryProvider()
|
||||||
|
store.history = history
|
||||||
|
|
||||||
|
store.deleteTrashEntries([lane2])
|
||||||
|
|
||||||
|
#expect(!fixture.exists(".trash/\(Ident.lane2)"))
|
||||||
|
#expect(!fixture.exists(".trash/\(Ident.lane2)/\(Ident.card3)"), "the freight went with it")
|
||||||
|
let model = try loaded(fixture)
|
||||||
|
#expect(model.trashedLanes.isEmpty)
|
||||||
|
#expect(model.trash.map(\.id) == [newer, trashed], "the column's cards are untouched")
|
||||||
|
// 13-native-undo.md ▸ Rules: "lanes and their freight included" — the confirm is the safety.
|
||||||
|
#expect(!history.canUndo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Confirms name the freight honestly — a trashed lane's alert counts its cards" (03 § Trash).
|
||||||
|
@Test("The row's confirmation names the lane and counts its cards")
|
||||||
|
func rowConfirmationCountsTheFreight() throws {
|
||||||
|
let fixture = try makeTrashedLaneBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let confirmations = TrashConfirmations()
|
||||||
|
store.select([lane2], in: .trash)
|
||||||
|
|
||||||
|
confirmations.requestDelete(in: store)
|
||||||
|
|
||||||
|
let pending = try #require(confirmations.pending)
|
||||||
|
#expect(pending.prompt.title == "Permanently delete lane \u{201C}Doing\u{201D} and its 2 cards?")
|
||||||
|
#expect(pending.action == .deleteTrashEntries([lane2]))
|
||||||
|
|
||||||
|
confirmations.confirm(in: store)
|
||||||
|
#expect(!fixture.exists(".trash/\(Ident.lane2)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty Trash's own sentence, with lanes in the container: "… 41 cards and 2 lanes containing 9
|
||||||
|
/// more cards" (03 § Trash) — and the walk really removes the subtrees.
|
||||||
|
@Test("Empty Trash counts both kinds and walks the lane subtrees")
|
||||||
|
func emptyTrashCountsBothKinds() throws {
|
||||||
|
let fixture = try makeTrashedLaneBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
store.transient.isTrashVisible = true
|
||||||
|
let confirmations = TrashConfirmations()
|
||||||
|
|
||||||
|
#expect(store.canEmptyTrash)
|
||||||
|
confirmations.requestEmptyTrash(in: store)
|
||||||
|
let pending = try #require(confirmations.pending)
|
||||||
|
#expect(pending.prompt.title == "Permanently delete 2 cards and 1 lane containing 2 more cards?")
|
||||||
|
|
||||||
|
confirmations.confirm(in: store)
|
||||||
|
let model = try loaded(fixture)
|
||||||
|
#expect(model.trash.isEmpty)
|
||||||
|
#expect(model.trashedLanes.isEmpty)
|
||||||
|
#expect(!fixture.exists(".trash/\(Ident.lane2)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The interim successor** (`SelectionGrammar.successor`'s trash branch): 04 settles navigation
|
||||||
|
/// and extension for the column's two kinds but not the successor, so it follows *navigation* —
|
||||||
|
/// the next row down whatever its kind — rather than stranding the selection. Gap card
|
||||||
|
/// 7b5cbc90 tracks the ruling; this test is the interim's marker as much as its cover.
|
||||||
|
@Test("The successor after purging a lane row is the next row down, kind notwithstanding")
|
||||||
|
func successorCrossesKinds() throws {
|
||||||
|
let fixture = try makeTrashedLaneBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
store.select([lane2], in: .trash)
|
||||||
|
|
||||||
|
store.deleteTrashEntries([lane2])
|
||||||
|
|
||||||
|
// The column reads [newer, lane2, trashed]; forward-first lands on the card below.
|
||||||
|
#expect(store.selection == ItemReferenceSet(ids: [trashed], container: .trash))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A row purged from the bottom falls back to its predecessor, and an emptied column clears")
|
||||||
|
func successorFallsBackAndClears() async throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item(".trash/\(More.newer)", trashResident(order: "512", title: "Newer"))
|
||||||
|
try fixture.item(".trash/\(Ident.lane2)", trashedLane(order: "1024", title: "Doing"))
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.select([lane2], in: .trash)
|
||||||
|
store.deleteTrashEntries([lane2])
|
||||||
|
#expect(store.selection.ids == [newer], "nothing below, so the row above")
|
||||||
|
|
||||||
|
// The successor is drawn from the *pre-write* snapshot, so the second purge needs the reload
|
||||||
|
// that tells the store the row is gone — the lane-delete suite's rule, on the trash side.
|
||||||
|
await reload(store)
|
||||||
|
store.deleteTrashEntries([newer])
|
||||||
|
#expect(store.selection.isEmpty, "an emptied container selects nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lane row's own menu validation — "everything edit-shaped is disabled on trash selections …
|
||||||
|
/// **and lane width ops on lane rows**" (04-interactions.md ▸ The trash).
|
||||||
|
@MainActor
|
||||||
|
@Suite("Edit-shaped commands on a trashed lane row")
|
||||||
|
struct TrashedLaneRowValidationTests {
|
||||||
|
|
||||||
|
@Test("Rename, Style… and Open Card all refuse a lane row")
|
||||||
|
func editShapedRefusals() throws {
|
||||||
|
let fixture = try makeTrashedLaneBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
store.select([lane2], in: .trash)
|
||||||
|
|
||||||
|
// The container answers all three, which is why none of them needed a kind clause.
|
||||||
|
#expect(store.renameTarget == nil)
|
||||||
|
#expect(store.boardStyleTarget == nil)
|
||||||
|
#expect(store.openCardTarget == nil)
|
||||||
|
// The within-lane sort is inert too — its plan reads a *board* card selection.
|
||||||
|
#expect(store.sortPlan(.up) == nil)
|
||||||
|
#expect(store.sortPlan(.down) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The width pair batches over "the selected live lanes", which is `snapshot.lanes` narrowed by a
|
||||||
|
/// **board** selection — so a trashed row contributes nothing and both items disable.
|
||||||
|
@Test("The width stepper's batch is empty for a lane row")
|
||||||
|
func widthOpsRefuse() throws {
|
||||||
|
let fixture = try makeTrashedLaneBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
store.select([lane2], in: .trash)
|
||||||
|
|
||||||
|
let selected = store.snapshot.lanes.filter { store.selection.ids.contains($0.id) }
|
||||||
|
#expect(selected.isEmpty)
|
||||||
|
#expect(store.selection.container == .trash)
|
||||||
|
// And the row is not a lane on the strip at all, so Move Left/Right cannot name it either.
|
||||||
|
#expect(!SelectionGrammar.lanes(in: store.snapshot).contains(lane2))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete stays enabled and honest — the row is a real entry the container holds, so the one
|
||||||
|
/// Delete predicate answers `true` and the write it stages is the permanent one.
|
||||||
|
@Test("Delete is enabled on a lane row and stages the permanent write")
|
||||||
|
func deleteStaysHonest() throws {
|
||||||
|
let fixture = try makeTrashedLaneBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
store.select([lane2], in: .trash)
|
||||||
|
|
||||||
|
#expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
|
||||||
|
#expect(ItemPath.resolve(store.selection.ids, in: .trash, snapshot: store.snapshot)
|
||||||
|
== [.trashLane(lane2)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - The legacy tombstone migration
|
// MARK: - The legacy tombstone migration
|
||||||
|
|
||||||
/// 01-storage-format.md § Deletion: "Legacy `deleted:` keys migrate on load-and-write, never
|
/// 01-storage-format.md § Deletion: "Legacy `deleted:` keys migrate on load-and-write, never
|
||||||
|
|||||||
@@ -161,6 +161,13 @@ extension WriterFixture {
|
|||||||
try item(".trash/\(id)", "---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\n")
|
try item(".trash/\(id)", "---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A **lane** written straight into `<root>/.trash/` — the opaque unit (03-board-ui.md § Trash),
|
||||||
|
/// where `kind: lane` is the only thing that tells it from a card in the flat container.
|
||||||
|
@discardableResult
|
||||||
|
func trashedLane(_ id: String, order: String, title: String) throws -> URL {
|
||||||
|
try item(".trash/\(id)", "---\nschema: 1\ntitle: \(title)\norder: \(order)\nkind: lane\n---\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
/// The board as the loader reads it right now — the value a reload would have landed.
|
/// The board as the loader reads it right now — the value a reload would have landed.
|
||||||
func snapshot() throws -> BoardModel {
|
func snapshot() throws -> BoardModel {
|
||||||
try BoardLoader.load(boardRoot: root).model
|
try BoardLoader.load(boardRoot: root).model
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ Lanework is in early development. This list tracks what has actually shipped and
|
|||||||
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). A card has one presentation: selection changes only its styling, never its geometry, so the masonry never reflows on a click — the paperclip chip is the face's whole attachment story, and viewing the files themselves is the card window's job.
|
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). A card has one presentation: selection changes only its styling, never its geometry, so the masonry never reflows on a click — the paperclip chip is the face's whole attachment story, and viewing the files themselves is the card window's job.
|
||||||
- **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a target that is trashed or deleted discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock.
|
- **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a target that is trashed or deleted discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock.
|
||||||
|
|
||||||
- **Drag & drop** — cards, in either container, and lanes all travel as real system drag sessions, so a drag crosses window boundaries, shows the system's own copy badge, and carries a full-size replica of what it picked up. A dashed shadow sits at the exact landing spot and the board reflows to make room; the proposal is pure geometry over an analytically reconstructed resting layout — never measured mid-animation frames — so the shadow is stable rather than jittery, and a lane only reflows once the cursor reaches where the dragged run would actually land, holding its last proposal across the ambiguous stretch in between. Dragging any member of a multi-selection drags the whole selection: N contiguous shadows, one insertion point, landing in flatten order. Locality picks the default the way Finder's volumes do — within a board a drag moves, between boards it copies, with ⌥ forcing copy and ⌘ forcing move and the badge tracking live as the cursor crosses a boundary; a lane reordering inside its own board ignores ⌥ entirely, and a lane carries exactly its cards either way, since the trash is board-level and there is nothing lane-nested to strip. Dragging a trash card onto a lane restores it at the drop position — an ordinary move — while dropping it on another board follows the same copy default every cross-board drag does, ⌘ forcing the true restore-move. The same gesture runs the other way: dropping a live card on the shown trash deletes it, exactly as ⌫ would, with the shadow always taking the topmost row — which the rank minting makes honest rather than arbitrary: every arrival really does land above the current top. Lanes aren't deliverable that way, a foreign board's card isn't, and ⌥ isn't, since copying into the trash isn't a thing. Lanes taller than their viewport autoscroll from either edge, re-resolving the landing spot on every step so a stationary cursor still lands where the shadow shows. A foreign edit mid-drag re-grounds the drag rather than corrupting the drop: the zones re-derive against each new snapshot, a proposal whose lane was deleted withdraws and a release with none simply cancels, and a drag whose items all vanish dissolves itself. At release the board keeps drawing the dropped arrangement until the write round-trips through the watcher, so nothing snaps back for a frame; every drop is one write bracket — one reload, one commit — whatever the set's size. Files dragged in from Finder join the same dispatch: dropped on a card they copy into its `attachments/` (any type, multi-file, Finder-style renames on collision, the card highlighting while hovered), dropped on lane empty space they become one card per file — titled with the filename minus its extension, that file attached, landing at the drop position with a shadow per card. The trash column and its cards are inert to them, and a read-only board or an open inline editor refuses them outright.
|
- **Drag & drop** — cards, in either container, and lanes all travel as real system drag sessions, so a drag crosses window boundaries, shows the system's own copy badge, and carries a full-size replica of what it picked up. A dashed shadow sits at the exact landing spot and the board reflows to make room; the proposal is pure geometry over an analytically reconstructed resting layout — never measured mid-animation frames — so the shadow is stable rather than jittery, and a lane only reflows once the cursor reaches where the dragged run would actually land, holding its last proposal across the ambiguous stretch in between. Dragging any member of a multi-selection drags the whole selection: N contiguous shadows, one insertion point, landing in flatten order. Locality picks the default the way Finder's volumes do — within a board a drag moves, between boards it copies, with ⌥ forcing copy and ⌘ forcing move and the badge tracking live as the cursor crosses a boundary; a lane reordering inside its own board ignores ⌥ entirely, and a lane carries exactly its cards either way, since the trash is board-level and there is nothing lane-nested to strip. Dragging a trash card onto a lane restores it at the drop position — an ordinary move — while dropping it on another board follows the same copy default every cross-board drag does, ⌘ forcing the true restore-move. The same gesture runs the other way: dropping a live card on the shown trash deletes it, exactly as ⌫ would, with the shadow always taking the topmost row — which the rank minting makes honest rather than arbitrary: every arrival really does land above the current top. A lane drag proposes the same delete over the column, and a trashed lane row drags back out to a strip slot the way a trash card drags back into a lane; a foreign board's item isn't deliverable there and neither is ⌥, since copying into the trash isn't a thing. Lanes taller than their viewport autoscroll from either edge, re-resolving the landing spot on every step so a stationary cursor still lands where the shadow shows. A foreign edit mid-drag re-grounds the drag rather than corrupting the drop: the zones re-derive against each new snapshot, a proposal whose lane was deleted withdraws and a release with none simply cancels, and a drag whose items all vanish dissolves itself. At release the board keeps drawing the dropped arrangement until the write round-trips through the watcher, so nothing snaps back for a frame; every drop is one write bracket — one reload, one commit — whatever the set's size. Files dragged in from Finder join the same dispatch: dropped on a card they copy into its `attachments/` (any type, multi-file, Finder-style renames on collision, the card highlighting while hovered), dropped on lane empty space they become one card per file — titled with the filename minus its extension, that file attached, landing at the drop position with a shadow per card. The trash column and its cards are inert to them, and a read-only board or an open inline editor refuses them outright.
|
||||||
|
|
||||||
- **The keyboard** — the board is fully operable without the mouse, and every board function has a menu item. Arrows walk to the nearest card in the direction — across a lane's interior masonry columns, across lanes, and into and out of the shown trash — with ⇧-arrows extending the range from the anchor and stopping dead at the board/trash and card/lane boundaries rather than silently reaching past them. ⌥-arrows jump: ⌥↑/⌥↓ to the lane's first/last card, ⌥←/⌥→ to the first/last lane (⌥→ reaching the trash when it's shown), and a second ⌥↑ on a lane's first card escalates into selecting the lane itself — the one keyboard entry to lane selection, from which ←/→ move between lanes, ↓ descends back into the cards, and an empty selection seeds at the first lane's first card so an arrow from nothing always means the same thing. The selection scrolls itself into view. The Board menu carries the rest: Open Card (⌘↩) — the one command that stays live mid-edit, committing the title and opening the window — Move Up/Move Down (⌥⌘↑/⌥⌘↓), which sort within a lane in logical order and gather a scattered selection into a block behind its first card on the first press, and Move Left/Move Right (⌘←/⌘→), which slide a selected lane one slot and never into the trash. Deleting picks the successor sibling Finder-style, so repeated ⌫ walks down a lane; an external deletion deliberately doesn't.
|
- **The keyboard** — the board is fully operable without the mouse, and every board function has a menu item. Arrows walk to the nearest card in the direction — across a lane's interior masonry columns, across lanes, and into and out of the shown trash — with ⇧-arrows extending the range from the anchor and stopping dead at the board/trash and card/lane boundaries rather than silently reaching past them. ⌥-arrows jump: ⌥↑/⌥↓ to the lane's first/last card, ⌥←/⌥→ to the first/last lane (⌥→ reaching the trash when it's shown), and a second ⌥↑ on a lane's first card escalates into selecting the lane itself — the one keyboard entry to lane selection, from which ←/→ move between lanes, ↓ descends back into the cards, and an empty selection seeds at the first lane's first card so an arrow from nothing always means the same thing. The selection scrolls itself into view. The Board menu carries the rest: Open Card (⌘↩) — the one command that stays live mid-edit, committing the title and opening the window — Move Up/Move Down (⌥⌘↑/⌥⌘↓), which sort within a lane in logical order and gather a scattered selection into a block behind its first card on the first press, and Move Left/Move Right (⌘←/⌘→), which slide a selected lane one slot and never into the trash. Deleting picks the successor sibling Finder-style, so repeated ⌫ walks down a lane; an external deletion deliberately doesn't.
|
||||||
|
|
||||||
- **The clipboard** — ⌘X/⌘C/⌘V move cards *and* lanes, within a board and across boards, so structure transfers without a mouse. It's a hybrid: the pasteboard carries a small manifest plus the titles as plain text, while the real content — whole folders, attachments and strays and all — is snapshotted into Application Support the instant you press ⌘C, so a copy captures the item as it was at that moment and survives the original being deleted, its volume unmounting, or the app quitting and relaunching. The store keeps exactly one snapshot: every copy and every launch sweeps whatever the pasteboard no longer points at. If a snapshot has gone missing by the time you paste, the manifest still carries each item's full `index.md`, so the paste lands with its content intact — and says so out loud, naming exactly what was left behind ("Pasted 'Fix login' without its 2 attachments") rather than leaving you to find an empty `attachments/` later. Cut is Finder-style deferred: the items dim in place and stay put until a paste moves them, voiding if another app takes the pasteboard or the source board closes (the paste then quietly becomes a copy), and voiding *per item* if one is deleted in the meantime — so a paste moves whatever survived, and a cut emptied down to nothing simply does nothing. Paste lands after the anchor card, at a selected lane's bottom, or at the last member of a multi-selection in flatten order — the same anchor ⌘N uses — and a lane payload lands after the anchor lane or at the board's right end, which is one of the two ways out of a board with no lanes at all. Copies keep `created` and take fresh identities throughout; a lane carries exactly its cards, copied or moved, because the trash is board-level and there is nothing lane-nested to strip; pasting a lane back into its own board is the within-board duplicate the drag deliberately doesn't offer. The clipboard works on trash cards like on any card — ⌘C yields a live copy wherever you paste it, and ⌘X in the trash followed by ⌘V into a lane is the keyboard-native restore — while paste never targets the trash itself, and the read-only lock blocks cut without ever blocking copy, because copying out is a read.
|
- **The clipboard** — ⌘X/⌘C/⌘V move cards *and* lanes, within a board and across boards, so structure transfers without a mouse. It's a hybrid: the pasteboard carries a small manifest plus the titles as plain text, while the real content — whole folders, attachments and strays and all — is snapshotted into Application Support the instant you press ⌘C, so a copy captures the item as it was at that moment and survives the original being deleted, its volume unmounting, or the app quitting and relaunching. The store keeps exactly one snapshot: every copy and every launch sweeps whatever the pasteboard no longer points at. If a snapshot has gone missing by the time you paste, the manifest still carries each item's full `index.md`, so the paste lands with its content intact — and says so out loud, naming exactly what was left behind ("Pasted 'Fix login' without its 2 attachments") rather than leaving you to find an empty `attachments/` later. Cut is Finder-style deferred: the items dim in place and stay put until a paste moves them, voiding if another app takes the pasteboard or the source board closes (the paste then quietly becomes a copy), and voiding *per item* if one is deleted in the meantime — so a paste moves whatever survived, and a cut emptied down to nothing simply does nothing. Paste lands after the anchor card, at a selected lane's bottom, or at the last member of a multi-selection in flatten order — the same anchor ⌘N uses — and a lane payload lands after the anchor lane or at the board's right end, which is one of the two ways out of a board with no lanes at all. Copies keep `created` and take fresh identities throughout; a lane carries exactly its cards, copied or moved, because the trash is board-level and there is nothing lane-nested to strip; pasting a lane back into its own board is the within-board duplicate the drag deliberately doesn't offer. The clipboard works on trash cards like on any card — ⌘C yields a live copy wherever you paste it, and ⌘X in the trash followed by ⌘V is the keyboard-native restore, a card into a lane and a trashed lane row after the anchor lane — while paste never targets the trash itself, and the read-only lock blocks cut without ever blocking copy, because copying out is a read.
|
||||||
|
|
||||||
- **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched.
|
- **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched.
|
||||||
|
|
||||||
- **The trash** — deleting a card **moves** it: its folder travels into the board's reserved `.trash/`, always landing at the top, and View ▸ Show Trash reveals a trailing column where those cards live. A trashed card is an ordinary card in a special place — the same card face, the same colour stripe, the same attachments chip, the same search, the same selection, the same clipboard — so `.trash/` is self-describing in Finder and to agents, and there is no tombstone flag anywhere. Lanes are never trashed: deleting a lane deletes it, folder and contents, with undo as the net. The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it, and its newest-first order falls out of ordinary ranks with no timestamp sort. There is no Put Back: restore by dragging a card out into any lane at any position, or ⌘X in the trash and ⌘V into a lane — both are ordinary moves, so a restored card lands where you put it. Drop a live card on the column to delete it — the pointer's twin of ⌫, writing the identical move, and its shadow always takes the top row because that is genuinely where the card lands. Delete is one vocabulary staged by place: ⌫/⌘⌫ moves a board card to the trash and deletes a trash card permanently, and ⇧⌘⌫ Empty Trash… purges the whole container — each confirmed where the loss is real, named by count, and Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style…, Finder file drops — applies to a trash selection, and a selection never mixes trashed with live.
|
- **The trash** — deleting a card **moves** it: its folder travels into the board's reserved `.trash/`, always landing at the top, and View ▸ Show Trash reveals a trailing column where those cards live. A trashed card is an ordinary card in a special place — the same card face, the same colour stripe, the same attachments chip, the same search, the same selection, the same clipboard — so `.trash/` is self-describing in Finder and to agents, and there is no tombstone flag anywhere. **Lanes delete into the trash too**: the folder travels subtree-intact and shows as one distinct dimmed row carrying its title and held-card count — an opaque unit that never expands, whose cards aren't individually addressable, and which restores whole or purges whole (its confirmation counting the cards it would take with it). The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it, and its newest-first order falls out of ordinary ranks with no timestamp sort. There is no Put Back: restore by dragging a card out into any lane at any position, or ⌘X in the trash and ⌘V into a lane — both are ordinary moves, so a restored card lands where you put it. Drop a live card on the column to delete it — the pointer's twin of ⌫, writing the identical move, and its shadow always takes the top row because that is genuinely where the card lands. Delete is one vocabulary staged by place: ⌫/⌘⌫ moves a board card to the trash and deletes a trash card permanently, and ⇧⌘⌫ Empty Trash… purges the whole container — each confirmed where the loss is real, named by count, and Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style…, Finder file drops — applies to a trash selection, and a selection never mixes trashed with live.
|
||||||
|
|
||||||
- **Live search** — the board window's toolbar carries one item, a search field (Edit ▸ Find, ⌘F), and typing in it filters the board as you type: a card stays when its title *or* its body contains the query, case- and diacritic-insensitively (so `resume` finds "Résumé"), and everything else animates out under one gentle spring while the survivors reflow. Scope is title and body only — attachment filenames are deliberately not searched. The filter is the single source of truth for what's on the board rather than a highlight over it: the masonry, each lane's count badge, drop zones, the rubber band, ⇧-ranges, Select All, arrow navigation and the ⌥-jumps all read it, and and the shown trash filters like any other lane. Nothing invisible stays selected — a card the query hides leaves the selection the moment it goes, and so does one an agent edits out of the match while you search. The field is a control, not an editor: board commands stay live and act on the selection while you type (⌘N included, which clears the search first so a new card is never born invisible), only ⌘←/⌘→ and ⌥⌘←/⌥⌘→ stand down so they stay caret chords, plain ⌫ edits the query while ⌘⌫ still deletes the selection, and Return is swallowed because a live filter has nothing to submit. Tab hands the keyboard to the board with the query intact. Escape steps out one layer per press — a non-empty field clears, an empty one returns focus to the board, and a board-focused Escape under an active search clears it before it means deselect. A rename is deliberately not a carve-out: rename a card out of the match during a search and it animates away exactly as an agent's edit would.
|
- **Live search** — the board window's toolbar carries one item, a search field (Edit ▸ Find, ⌘F), and typing in it filters the board as you type: a card stays when its title *or* its body contains the query, case- and diacritic-insensitively (so `resume` finds "Résumé"), and everything else animates out under one gentle spring while the survivors reflow. Scope is title and body only — attachment filenames are deliberately not searched. The filter is the single source of truth for what's on the board rather than a highlight over it: the masonry, each lane's count badge, drop zones, the rubber band, ⇧-ranges, Select All, arrow navigation and the ⌥-jumps all read it, and and the shown trash filters like any other lane. Nothing invisible stays selected — a card the query hides leaves the selection the moment it goes, and so does one an agent edits out of the match while you search. The field is a control, not an editor: board commands stay live and act on the selection while you type (⌘N included, which clears the search first so a new card is never born invisible), only ⌘←/⌘→ and ⌥⌘←/⌥⌘→ stand down so they stay caret chords, plain ⌫ edits the query while ⌘⌫ still deletes the selection, and Return is swallowed because a live filter has nothing to submit. Tab hands the keyboard to the board with the query intact. Escape steps out one layer per press — a non-empty field clears, an empty one returns focus to the board, and a board-focused Escape under an active search clears it before it means deselect. A rename is deliberately not a carve-out: rename a card out of the match during a search and it animates away exactly as an agent's edit would.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user