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:
@@ -104,11 +104,31 @@ enum AccessibilityPhrases {
|
||||
/// Trash: one word, never "Hide Trash"-style state in the name).
|
||||
static let trashLabel = "Trash"
|
||||
|
||||
/// The trash container's value: its card count, filtered exactly as the lane labels' are — "the
|
||||
/// The trash container's value: its counts, filtered exactly as the lane labels' are — "the
|
||||
/// shown trash's cards participate in the filter exactly like any other card" (03-board-ui.md §
|
||||
/// Trash), so the column passes the collection its badge counts.
|
||||
static func trashValue(cards count: Int) -> String {
|
||||
cardCount(count)
|
||||
/// Trash), so the column passes the collections its badge counts.
|
||||
///
|
||||
/// **Both kinds are named when both are there** (lanes rejoined the trash 2026-07-29): the
|
||||
/// container's rows are cards *and* opaque lane units, and a value that spoke only the cards
|
||||
/// would understate what the VoiceOver cursor is about to walk into. A trash holding no lane rows
|
||||
/// — overwhelmingly the common case — reads exactly as it always did.
|
||||
///
|
||||
/// It stops at counting, deliberately: the *freight* phrasing ("2 lanes containing 9 more
|
||||
/// cards") belongs to the purge confirmations, where the number is what the user is about to
|
||||
/// lose (`TrashModel.subject(for:)`).
|
||||
static func trashValue(cards: Int, lanes: Int = 0) -> String {
|
||||
guard lanes > 0 else { return cardCount(cards) }
|
||||
return "\(cardCount(cards)), \(laneCount(lanes))"
|
||||
}
|
||||
|
||||
/// A trashed lane row — **one flattened opaque element**, "⟨title⟩, deleted lane, N cards"
|
||||
/// (10-accessibility.md ▸ Trash lane, lanes rejoined 2026-07-29).
|
||||
///
|
||||
/// "Deleted lane" and not "lane" is the whole of what the label has to carry beyond a live lane
|
||||
/// container's: the row is in the trash, it is not a container, and its N is the freight it took
|
||||
/// with it rather than a filtered count of children VoiceOver could enter (`TrashedLane`).
|
||||
static func trashedLaneLabel(title: String?, cards count: Int) -> String {
|
||||
"\(displayTitle(title)), deleted lane, \(cardCount(count))"
|
||||
}
|
||||
|
||||
/// What View ▸ Show Trash announces — "toggling visibility is announced"
|
||||
@@ -125,10 +145,11 @@ enum AccessibilityPhrases {
|
||||
// MARK: - Live board announcements
|
||||
|
||||
/// "3 lanes", "1 lane" — `cardCount`'s twin, and the second half of the digest's plural folding.
|
||||
/// Spelled here rather than borrowed from `TrashModel.phrase` because that one is a cards-only
|
||||
/// container's phrase by design ("Lanes are never trashed").
|
||||
/// Borrowed from `TrashModel.lanePhrase` for `cardCount`'s reason: since lanes rejoined the trash
|
||||
/// (2026-07-29) the purge confirmations count lanes too, and one folding means a spoken count and
|
||||
/// a confirmed one cannot drift.
|
||||
static func laneCount(_ count: Int) -> String {
|
||||
"\(count) lane\(count == 1 ? "" : "s")"
|
||||
TrashModel.lanePhrase(count)
|
||||
}
|
||||
|
||||
/// The digest's opening — and, on its own, the whole sentence for a change no bucket counts.
|
||||
|
||||
@@ -163,9 +163,11 @@ struct BoardDropContext {
|
||||
/// measured frame (03-board-ui.md § Motion).
|
||||
///
|
||||
/// **The terminal slot is clamped before the trash.** The column consumes one unit while
|
||||
/// shown and is never a landing spot for anything (04-interactions.md ▸ The trash: "no move or
|
||||
/// shown and is never a *position* on the strip (04-interactions.md ▸ The trash: "no move or
|
||||
/// paste ever targets the trash"), so it is absent from the slot list by construction and the end
|
||||
/// slot's uncapped reach past the last real lane lands *before* it.
|
||||
/// slot's uncapped reach past the last real lane lands *before* it. The delete a lane drag can
|
||||
/// propose over the column is not a slot at all — it is the column's own target answering
|
||||
/// (`retargetTrash`), and it names `.trash` rather than an index in this list.
|
||||
func retargetLanes() {
|
||||
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
|
||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
@@ -508,9 +510,10 @@ struct BoardDropContext {
|
||||
/// set's size (DRAG-REORDER.md § The drop commits).
|
||||
///
|
||||
/// **One of the containers is not a destination but a verb.** A proposal naming the trash commits
|
||||
/// a delete — the same write ⌫ performs, through the same `BoardWriter.deleteCardToTrash` in the same
|
||||
/// bracket (`BoardStore.deleteByDrag`), so a card deleted by drop is indistinguishable on disk
|
||||
/// from one deleted by keystroke (04-interactions.md ▸ The trash, settled 2026-07-28).
|
||||
/// a delete — the same write ⌫ performs, through the same `BoardWriter.deleteCardToTrash` /
|
||||
/// `deleteLaneToTrash` in the same bracket (`BoardStore.deleteByDrag`, `deleteLanesByDrag`), so an
|
||||
/// item deleted by drop is indistinguishable on disk from one deleted by keystroke
|
||||
/// (04-interactions.md ▸ The trash, settled 2026-07-28; lanes extended 2026-07-29).
|
||||
///
|
||||
/// The write is the first half; the second is the **committed-overlay hold**
|
||||
/// (`DragSession.commit`). The write is still in flight when this returns, so the session flips
|
||||
@@ -539,18 +542,53 @@ struct BoardDropContext {
|
||||
|
||||
switch kind {
|
||||
case .lanes:
|
||||
// **A lane drag never targets the trash**, and never a masonry either — a lane session
|
||||
// proposes only lane slots (04-interactions.md ▸ The trash). True by construction, since
|
||||
// `retargetLanes` is the only thing that proposes for one and the trash column is absent
|
||||
// from its slot list; written down because a commit that trusted the container implicitly
|
||||
// would be the one place the invariant could break silently.
|
||||
if target.isTrash {
|
||||
// **The pointer's delete gesture, at the lane level** (04-interactions.md ▸ The
|
||||
// trash, lanes extended 2026-07-29: "a lane drag over the shown trash proposes the
|
||||
// delete alongside its strip slots"): release moves the lane's folder — subtree
|
||||
// intact — into `.trash/`, exactly the ⌫ delete (`BoardStore.deleteLanesByDrag`).
|
||||
//
|
||||
// The gate is re-asked here rather than trusted from the hover, the card branch's
|
||||
// rule for its reason: the modifiers can change after the proposal stood, and no
|
||||
// callback reports it. A refusal cancels — the lane returns, nothing is written.
|
||||
guard TrashDrop.accepts(
|
||||
kind: kind,
|
||||
container: session.container,
|
||||
isWithinBoard: within,
|
||||
operation: operation,
|
||||
isTrashShown: store.transient.isTrashVisible,
|
||||
acceptsMutations: store.acceptsBoardMutations
|
||||
) else {
|
||||
cancelDrop()
|
||||
return false
|
||||
}
|
||||
store.deleteLanesByDrag(laneIDs: ids)
|
||||
break
|
||||
}
|
||||
// **A lane drag never targets a masonry** — a lane session proposes lane slots and the
|
||||
// trash column, and nothing else (04-interactions.md ▸ The trash). True by construction,
|
||||
// since `retargetLanes` and `retargetTrash` are the only things that propose for one;
|
||||
// written down because a commit that trusted the container implicitly would be the one
|
||||
// place the invariant could break silently.
|
||||
guard target.container == .strip else {
|
||||
cancelDrop()
|
||||
return false
|
||||
}
|
||||
if within {
|
||||
store.moveLanes(Set(ids), toIndex: target.index)
|
||||
// **The trash side is the restore, and it is a different write** (04 ▸ The trash:
|
||||
// "a trashed lane row [dropped] onto its own board's strip — is an ordinary move to
|
||||
// the drop position"): the folder comes *out* of `.trash/`, where `moveLanes`'
|
||||
// rank arithmetic — a permutation of the strip's own lanes — has nothing to say
|
||||
// about it. `restoreLanes` is that move, with the same one-bracket, one-step shape.
|
||||
if session.container == .trash {
|
||||
store.restoreLanes(Set(ids), toIndex: target.index)
|
||||
} else {
|
||||
store.moveLanes(Set(ids), toIndex: target.index)
|
||||
}
|
||||
} else {
|
||||
// Cross-board, from either container: the ordinary arrival, copy by default and
|
||||
// move under ⌘ — "Dropped on *another* board it follows the copy default … ⌘-drag
|
||||
// forces the true cross-board restore-move" (04 ▸ The trash).
|
||||
store.receiveLanes(folders, operation: operation, at: target.index)
|
||||
}
|
||||
|
||||
@@ -762,11 +800,11 @@ struct StripDropDelegate: DropDelegate {
|
||||
/// gives the deepest region the session whether it wants it or not (see the note above), and it
|
||||
/// routes them three ways:
|
||||
///
|
||||
/// - **card sessions** through `retargetTrash`, which proposes the topmost row for the ones the trash
|
||||
/// takes and falls through to the strip's own answer for the rest;
|
||||
/// - **lane sessions** the same way, and `TrashDrop.accepts` refuses them there, so what actually
|
||||
/// runs is the strip's `retargetLanes` — lane reordering keeps working across the column exactly as
|
||||
/// it did when the column was a hole in the strip's target;
|
||||
/// - **card and lane sessions alike** through `retargetTrash`, which proposes the topmost row for the
|
||||
/// ones the trash takes — a live item from this board, unmodified, either kind (lanes extended
|
||||
/// 2026-07-29) — and falls through to the strip's own answer for the rest, so a *trashed* row being
|
||||
/// dragged out keeps reordering against the strip across the column exactly as it did when the
|
||||
/// column was a hole in the strip's target;
|
||||
/// - **Finder file sessions** by clearing the highlight outright: "Finder file drops (attachment
|
||||
/// import) on trash cards are inert" (▸ The trash), and the column has nothing else to offer
|
||||
/// them — no lane, no card, nothing to attach to.
|
||||
|
||||
@@ -496,15 +496,22 @@ struct BoardView: View {
|
||||
store.transient.isTrashVisible
|
||||
}
|
||||
|
||||
/// The trash's cards as the column is showing them — the shown trash's cards "participate in
|
||||
/// the filter exactly like any other card" (03-board-ui.md § Trash), and the arrows walk what is
|
||||
/// on screen (`TrashLaneView` applies the identical predicate to the identical cards).
|
||||
/// The trash's **rows** as the column is showing them, both kinds in rank order — the shown
|
||||
/// trash's contents "participate in the filter exactly like any other card" (03-board-ui.md §
|
||||
/// Trash), and the arrows walk what is on screen (`TrashLaneView` applies the identical predicate
|
||||
/// to the identical rows).
|
||||
///
|
||||
/// **Rows and not cards, because this is navigation** (04-interactions.md ▸ The trash: "inside,
|
||||
/// plain arrows walk every row, card and lane row alike (navigation crosses kinds)", and ▸
|
||||
/// Grammar gives ⌥→ "the shown non-empty trash — its first *entry*"). The kind-scoped lists are
|
||||
/// the *ranging* grammar's (`SelectionGrammar.order`), which is what stops a ⇧-arrow at the kind
|
||||
/// boundary while a plain one crosses it.
|
||||
///
|
||||
/// Read by the three keyboard destinations that reach into the column — the arrow origin's
|
||||
/// order list, ⌥↑/⌥↓'s container, and ⌥→'s jump — so none of them can walk onto a card the
|
||||
/// order list, ⌥↑/⌥↓'s container, and ⌥→'s jump — so none of them can walk onto a row the
|
||||
/// filter took away.
|
||||
private var trashCards: [ItemID] {
|
||||
SelectionGrammar.trashCards(in: store.snapshot, filter: store.searchFilter)
|
||||
private var trashRows: [ItemID] {
|
||||
SelectionGrammar.trashRows(in: store.snapshot, filter: store.searchFilter)
|
||||
}
|
||||
|
||||
// MARK: - The drag
|
||||
@@ -795,11 +802,15 @@ struct BoardView: View {
|
||||
/// Select All and a foreign reload leave the arrows somewhere sensible without any of them
|
||||
/// having to name a cursor.
|
||||
///
|
||||
/// The **trash's list is its cards**, top to bottom — there are no lane entries to interleave
|
||||
/// any more (03-board-ui.md § Trash: "Cards only").
|
||||
/// The **trash's list is its rows**, top to bottom, both kinds interleaved by rank (lanes
|
||||
/// rejoined 2026-07-29) — and the trash side is **never the lane domain**, whatever the
|
||||
/// selection's kind: the lane domain is the strip's grammar (←/→ walk lanes, ↓ descends into
|
||||
/// cards, ⌘←/⌘→ move one), and a trashed lane row is a row in a column, not a lane on the board
|
||||
/// ("The trash lane itself is never selectable *as a lane*" — 04 ▸ The trash). Its arrows are the
|
||||
/// spatial ones every row gets.
|
||||
///
|
||||
/// Both lists are the **filtered** board (04 § Search: "arrow nav … read[s] it"), so the
|
||||
/// fallback lands on the last *visible* member rather than on a card the query hid.
|
||||
/// fallback lands on the last *visible* member rather than on a row the query hid.
|
||||
private func arrowOrigin() -> (head: ItemID, container: ItemContainer, isLaneDomain: Bool)? {
|
||||
let selection = store.selection
|
||||
guard !selection.isEmpty else { return nil }
|
||||
@@ -813,7 +824,7 @@ struct BoardView: View {
|
||||
list = SelectionGrammar.order(of: kind, in: .board, snapshot: store.snapshot, filter: store.searchFilter)
|
||||
case .trash:
|
||||
isLaneDomain = false
|
||||
list = trashCards
|
||||
list = trashRows
|
||||
}
|
||||
|
||||
if let head = store.transient.selectionHead, list.contains(head) {
|
||||
@@ -915,13 +926,16 @@ struct BoardView: View {
|
||||
return .handled
|
||||
}
|
||||
|
||||
/// **⌥↑/⌥↓ jump to the current container's first/last card** — the lane's, or the trash
|
||||
/// trash column's when that is where the cursor is.
|
||||
/// **⌥↑/⌥↓ jump to the current container's first/last row** — the lane's cards, or the trash
|
||||
/// column's rows when that is where the cursor is.
|
||||
///
|
||||
/// **⌥↑ escalates into the lane domain** (04 ▸ Grammar, settled — "the keyboard's one entry to
|
||||
/// lane selection"): with the lane's first card already the sole selection, the next ⌥↑ selects
|
||||
/// the *lane* itself. The trash deliberately never escalates: it "is never selectable as a lane",
|
||||
/// so a second ⌥↑ there is simply inert.
|
||||
///
|
||||
/// **In the trash the jump crosses kinds**, like every other navigation there: the ends it names
|
||||
/// are the column's, so ⌥↑ from a card can land on a lane row sitting above it.
|
||||
private func jumpWithinContainer(
|
||||
_ direction: NavigationMath.Direction,
|
||||
from head: ItemID,
|
||||
@@ -931,7 +945,7 @@ struct BoardView: View {
|
||||
var lane: ItemID?
|
||||
switch container {
|
||||
case .trash:
|
||||
siblings = trashCards
|
||||
siblings = trashRows
|
||||
case .board:
|
||||
guard let home = store.snapshot.lanes.first(where: { lane in
|
||||
lane.cards.contains { $0.id == head }
|
||||
@@ -956,11 +970,12 @@ struct BoardView: View {
|
||||
/// first card, since ⌥↑ is the one keyboard entry to lane selection.
|
||||
///
|
||||
/// **⌥→ reaches the shown trash** first (04 ▸ The trash: "the shown trash is the last container
|
||||
/// for card navigation, and ⌥→ jumps to it"); an empty or hidden column is not a destination, so
|
||||
/// for card navigation, and ⌥→ jumps to it" — ▸ Grammar names the landing as "its first
|
||||
/// **entry**", which is a row of either kind); an empty or hidden column is not a destination, so
|
||||
/// the jump falls through to the last lane. Empty lanes are scanned past in both directions —
|
||||
/// a jump that landed nowhere because the end lane happens to be empty would be a dead key.
|
||||
private func jumpToEndLane(_ direction: NavigationMath.Direction) -> KeyPress.Result {
|
||||
if direction == .right, isTrashVisible, let first = trashCards.first {
|
||||
if direction == .right, isTrashVisible, let first = trashRows.first {
|
||||
replaceSelection(with: first, in: .trash)
|
||||
return .handled
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@ struct DropTarget: Equatable, Sendable {
|
||||
/// That lane's **masonry**: the index is a position in its logical card order
|
||||
/// (DRAG-REORDER.md § The card masonry).
|
||||
case lane(ItemID)
|
||||
/// The **trash column**, which a board card drag proposes into to delete it
|
||||
/// (04-interactions.md ▸ The trash, settled 2026-07-28). The index is always the topmost row.
|
||||
/// The **trash column**, which a board card — or lane — drag proposes into to delete it
|
||||
/// (04-interactions.md ▸ The trash, settled 2026-07-28, lanes extended 2026-07-29). The index
|
||||
/// is always the topmost row.
|
||||
case trash
|
||||
}
|
||||
|
||||
@@ -47,9 +48,10 @@ struct DropTarget: Equatable, Sendable {
|
||||
|
||||
// MARK: - Dropping on the trash
|
||||
|
||||
/// **Drop-on-trash deletes** (04-interactions.md ▸ The trash, settled 2026-07-28: "the drag becomes
|
||||
/// the pointer's delete gesture — release moves the dragged card(s) into `.trash/`, exactly the ⌫ delete"),
|
||||
/// as the two pure facts the gesture is made of (`TrashDropTests`).
|
||||
/// **Drop-on-trash deletes** (04-interactions.md ▸ The trash, settled 2026-07-28, lanes extended
|
||||
/// 2026-07-29: "Dropping a live card — or lane — on the shown trash deletes it … a lane drag over the
|
||||
/// shown trash proposes the delete alongside its strip slots"), as the two pure facts the gesture is
|
||||
/// made of (`TrashDropTests`).
|
||||
///
|
||||
/// Kept out of the drop context so the ruling is checkable without a window, and stated once so the
|
||||
/// **hover** and the **release** cannot disagree about what the trash takes — the hazard being a
|
||||
@@ -69,16 +71,20 @@ enum TrashDrop {
|
||||
/// Whether the shown trash takes this session — the whole of the gate, and every clause is a
|
||||
/// refusal 04 states in its own words:
|
||||
///
|
||||
/// - **Lanes are not deliverable this way** — "a lane drag proposes only lane slots". (The strip's
|
||||
/// slot list has never contained the trash column, so this is belt over braces; it is written down
|
||||
/// because a guard that is only true by construction is one refactor from being false.)
|
||||
/// - **A trash card is already there.** A `.trash` session's vocabulary is restore and copy-out;
|
||||
/// dropping it back where it came from writes nothing.
|
||||
/// - **Cross-board is refused.** "No move or paste ever targets the trash": a foreign card
|
||||
/// - **Both kinds are deliverable** (lanes extended 2026-07-29, retiring "a lane drag proposes
|
||||
/// only lane slots"): "a lane drag over the shown trash proposes the delete alongside its strip
|
||||
/// slots", which is the same sentence the card gesture has always had one level down. A session
|
||||
/// in flight is a session of one of the two kinds, so the clause is `kind != nil` — there is no
|
||||
/// third kind to admit by accident, and a file session never arms this object at all.
|
||||
/// - **A trash item is already there.** A `.trash` session's vocabulary is restore and copy-out,
|
||||
/// at either level; dropping it back where it came from writes nothing.
|
||||
/// - **Cross-board is refused.** "No move or paste ever targets the trash": a foreign item
|
||||
/// delivered *into* this board's trash would be a transfer-and-delete compound, an operation the
|
||||
/// design gives no name and no undo story. The card stays where it is.
|
||||
/// design gives no name and no undo story. It stays where it is.
|
||||
/// - **⌥ is refused.** Copying into the trash is not a thing — the copy grammar promises the
|
||||
/// original stays exactly where it was, and there is nothing to delete but the original.
|
||||
/// original stays exactly where it was, and there is nothing to delete but the original. (A
|
||||
/// within-board lane drag never resolves to `.copy` anyway: ⌥ is ignored there,
|
||||
/// `DragLocality.operation`. The clause is the card gesture's and covers the lane for free.)
|
||||
/// - **Hidden, the trash is invisible to every gesture.** True by construction too (the column is
|
||||
/// not rendered, so it has no drop region), and stated here so the claim is testable.
|
||||
/// - **The mutating-gesture rule**, like every other write the pointer can start.
|
||||
@@ -91,7 +97,7 @@ enum TrashDrop {
|
||||
acceptsMutations: Bool
|
||||
) -> Bool {
|
||||
guard isTrashShown, acceptsMutations else { return false }
|
||||
guard kind == .cards, container == .board, isWithinBoard else { return false }
|
||||
guard kind != nil, container == .board, isWithinBoard else { return false }
|
||||
return operation == .move
|
||||
}
|
||||
}
|
||||
@@ -400,8 +406,12 @@ final class DragSession {
|
||||
/// Always `TrashDrop.landingIndex`, and read through this accessor anyway so the column asks the
|
||||
/// same "is it me?" question every other container asks, and gets the position from the same
|
||||
/// place.
|
||||
///
|
||||
/// **Either kind proposes here** (lanes extended 2026-07-29), so there is no kind clause: what
|
||||
/// makes the proposal legal is `TrashDrop.accepts`, asked at hover and again at release, and a
|
||||
/// second copy of its answer written here could only ever disagree with it.
|
||||
func trashProposal(onBoardRooted root: URL) -> Int? {
|
||||
guard kind == .cards, let proposal, proposal.container == .trash,
|
||||
guard isActive, let proposal, proposal.container == .trash,
|
||||
DragLocality.isSameBoard(proposal.boardRoot, root)
|
||||
else { return nil }
|
||||
return proposal.index
|
||||
|
||||
@@ -81,9 +81,14 @@ final class MarqueeSession {
|
||||
/// is on screen. It is also what makes the band correct for free across a lane resize, a reorder in
|
||||
/// flight, and a foreign reload — the frames simply re-register.
|
||||
///
|
||||
/// **Lanes are never registered.** The band selects cards — board cards or trash cards; a lane has
|
||||
/// no entry here at all, which is 04-interactions.md § Selection's "click-drag rubber-bands across
|
||||
/// lanes" made structural rather than filtered.
|
||||
/// **A live lane is never registered.** The band selects cards, and a lane on the strip has no entry
|
||||
/// here at all — 04-interactions.md § Selection's "click-drag rubber-bands across lanes" made
|
||||
/// structural rather than filtered.
|
||||
///
|
||||
/// **A trashed lane row is registered** (lanes rejoined the trash 2026-07-29), and not for the band:
|
||||
/// this registry is also the arrows' geometry (`NavigationMath`, where "plain arrows walk every row,
|
||||
/// card and lane row alike") and the begin guard's universe below. The band still never selects one
|
||||
/// — `MarqueeMath` filters by kind, which is where that rule lives for both containers.
|
||||
///
|
||||
/// It is also the **begin guard's** universe: a drag that starts inside a registered frame belongs
|
||||
/// to that item's own gesture (a card drag, a drag out of the trash), never to the band. Deciding
|
||||
|
||||
@@ -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
|
||||
|
||||
/// The trash column: the trailing, visually distinct column the board's `.trash/` cards live in
|
||||
/// (03-board-ui.md § Trash, resettled 2026-07-28 — the materialized trash).
|
||||
/// The trash column: the trailing, visually distinct column the board's `.trash/` entries live in
|
||||
/// (03-board-ui.md § Trash, resettled 2026-07-28 — the materialized trash; lanes rejoined
|
||||
/// 2026-07-29).
|
||||
///
|
||||
/// ### Ordinary cards, in a column that says where they are
|
||||
///
|
||||
/// **Its contents are ordinary cards in a special place**, so there is nothing to derive and nothing
|
||||
/// **Its cards are ordinary cards in a special place**, so there is nothing to derive and nothing
|
||||
/// to draw differently: the column renders `store.snapshot.trash` — which the loader parsed with the
|
||||
/// same card parse the lanes use and sorted by `order` like any lane's children — through the very
|
||||
/// same `CardFaceView` a lane renders. "A trashed card is an ordinary card in a special place —
|
||||
@@ -18,8 +19,16 @@ import SwiftUI
|
||||
/// approximately so: a trashed card keeps its icon, its icon tint, its left-edge accent stripe, its
|
||||
/// attachments chip and its four-line title, because it is the same card and the same face.
|
||||
///
|
||||
/// Newest-first falls out of the ranks (every arrival mints one above the current top), so there is
|
||||
/// no timestamp sort and no entry type here at all.
|
||||
/// ### And its other kind, which is the opposite case
|
||||
///
|
||||
/// **A trashed lane is an opaque unit** — "one distinct dimmed row showing its title and held-card
|
||||
/// count … no styling accents, never expandable" (03 § Trash) — so it gets its own small view
|
||||
/// (`TrashLaneRowView`) rather than a third card-face role: what a card face draws is exactly what
|
||||
/// the design says this row must not. The two kinds **interleave purely by trash rank**, which is
|
||||
/// `BoardModel.trashEntries`' merge and not a second one written here.
|
||||
///
|
||||
/// Newest-first falls out of the ranks (every arrival of either kind mints one above the current
|
||||
/// top), so there is no timestamp sort and no entry type here at all.
|
||||
///
|
||||
/// ### What makes it a column and not a lane
|
||||
///
|
||||
@@ -39,18 +48,21 @@ import SwiftUI
|
||||
///
|
||||
/// ### The drop it takes, and the drag it starts
|
||||
///
|
||||
/// **"Dropping a live card on the shown trash deletes it"** (04-interactions.md ▸ The trash) — the
|
||||
/// drag becomes the pointer's delete gesture, and release moves the dragged card(s) into `.trash/`.
|
||||
/// So the column declares an `onDrop` (`TrashDropDelegate`), and it is the narrowest one on the
|
||||
/// board: a board card drag from **this** board, unmodified. It diverges from every other drop in one
|
||||
/// way, and the ranks are what make the divergence honest: **the shadow always takes the topmost
|
||||
/// row**, because every arrival mints a rank above the current top.
|
||||
/// **"Dropping a live card — or lane — on the shown trash deletes it"** (04-interactions.md ▸ The
|
||||
/// trash, lanes extended 2026-07-29) — the drag becomes the pointer's delete gesture, and release
|
||||
/// moves the dragged item(s) into `.trash/`. So the column declares an `onDrop`
|
||||
/// (`TrashDropDelegate`), and it is the narrowest one on the board: a board drag from **this** board,
|
||||
/// unmodified. It diverges from every other drop in one way, and the ranks are what make the
|
||||
/// divergence honest: **the shadow always takes the topmost row**, because every arrival mints a rank
|
||||
/// above the current top.
|
||||
///
|
||||
/// The drag *out* is the restore, and it is deliberately not special: a trash card's drag is an
|
||||
/// ordinary `.cards` session in the `.trash` container (`CardFaceView.startTrashCardDrag`), and
|
||||
/// `BoardDropContext.commitDrop` hands it to the same `moveCards`/`copyCards`/`receiveCards` every
|
||||
/// board card uses. "Restoring is an ordinary move out … there is no restore-specific machinery and
|
||||
/// no Put Back" (03 § Trash).
|
||||
/// The drag *out* is the restore, and it is deliberately not special at either level: a trash card's
|
||||
/// drag is an ordinary `.cards` session in the `.trash` container
|
||||
/// (`CardFaceView.startTrashCardDrag`) and a trashed lane row's is an ordinary `.lanes` session in it
|
||||
/// (`TrashLaneRowView.startDrag`), which `BoardDropContext.commitDrop` hands to the same
|
||||
/// `moveCards`/`copyCards`/`receiveCards` and `restoreLanes`/`receiveLanes` every board item uses.
|
||||
/// "Restoring is an ordinary move out … there is no restore-specific machinery and no Put Back"
|
||||
/// (03 § Trash).
|
||||
///
|
||||
/// **Finder file drops stay inert** — "Finder file drops on trash cards are inert" (▸ The trash) —
|
||||
/// and say so twice: the delegate clears the file highlight over the column, and the face's hover
|
||||
@@ -66,8 +78,9 @@ import SwiftUI
|
||||
///
|
||||
/// "When shown, it is the last container, labeled as Trash with its count. Its cards are ordinary
|
||||
/// card elements" (10-accessibility.md, resettled 2026-07-28). The container name and value are set
|
||||
/// here; the elements inside are ordinary card faces because they *are* ordinary card faces. The full
|
||||
/// element tree — labels, values, traits, actions — is the accessibility milestone's.
|
||||
/// here; the card elements inside are ordinary card faces because they *are* ordinary card faces, and
|
||||
/// a trashed lane is "one flattened opaque element … never a container" (▸ Trash lane, lanes rejoined
|
||||
/// 2026-07-29), which is `TrashLaneRowView`'s own doing.
|
||||
struct TrashLaneView: View {
|
||||
|
||||
let store: BoardStore
|
||||
@@ -133,36 +146,46 @@ struct TrashLaneView: View {
|
||||
// VoiceOver to enter.
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel(AccessibilityPhrases.trashLabel)
|
||||
// The **rendered** count, like a lane's: the shown trash's cards participate in the filter,
|
||||
// The **rendered** counts, like a lane's: the shown trash's rows participate in the filter,
|
||||
// so a query narrows the spoken count exactly as it narrows the badge and the column itself.
|
||||
.accessibilityValue(AccessibilityPhrases.trashValue(cards: renderedCards.count))
|
||||
// Both kinds are named, because both are rows the VoiceOver cursor is about to walk into.
|
||||
.accessibilityValue(AccessibilityPhrases.trashValue(
|
||||
cards: renderedCards.count,
|
||||
lanes: renderedRows.count - renderedCards.count
|
||||
))
|
||||
}
|
||||
|
||||
/// The cards the column shows.
|
||||
/// The rows the column shows — **both kinds, interleaved by rank** (03-board-ui.md § Trash:
|
||||
/// "Lane rows and cards interleave in the one trash column purely by trash rank").
|
||||
///
|
||||
/// **Shown, the trash's cards "participate in the filter exactly like any other card"**
|
||||
/// (03-board-ui.md § Trash — "the point of the pivot"), so the search predicate narrows this
|
||||
/// collection exactly as it narrows `LaneView.renderedCards`, and the count badge follows for
|
||||
/// free because it reads this same value. Hidden, the column renders nothing and registers
|
||||
/// nothing, so "hidden trash is invisible to search" needs no code at all.
|
||||
/// **Shown, the trash's contents "participate in the filter exactly like any other card"**
|
||||
/// (03 § Trash — "the point of the pivot"), so the search predicate narrows this collection
|
||||
/// exactly as it narrows `LaneView.renderedCards`; a lane row matches by title alone, which is
|
||||
/// `SearchFilter`'s own rule for the opaque unit. Hidden, the column renders nothing and
|
||||
/// registers nothing, so "hidden trash is invisible to search" needs no code at all.
|
||||
///
|
||||
/// **A card being dragged out renders here anyway**, unlike a lane's: the source stays visible in
|
||||
/// the trash while the session is in flight, dimmed by the face's own treatment, because a
|
||||
/// restore is not a removal until the write lands.
|
||||
private var renderedCards: [Card] {
|
||||
Self.rendered(store.snapshot.trash, filter: store.searchFilter)
|
||||
/// **A row being dragged out renders here anyway**, unlike a lane's on the strip: the source
|
||||
/// stays visible in the trash while the session is in flight, dimmed by the row's own treatment,
|
||||
/// because a restore is not a removal until the write lands.
|
||||
private var renderedRows: [TrashEntry] {
|
||||
Self.rendered(store.snapshot, filter: store.searchFilter)
|
||||
}
|
||||
|
||||
/// `renderedCards` as a pure function of its two inputs — `LaneView.rendered`'s trash-side twin,
|
||||
/// The card half of `renderedRows` — what the badge and the spoken card count read.
|
||||
private var renderedCards: [TrashEntry] {
|
||||
renderedRows.filter { $0.kind == .card }
|
||||
}
|
||||
|
||||
/// `renderedRows` as a pure function of its two inputs — `LaneView.rendered`'s trash-side twin,
|
||||
/// split out for the same reason: so the rule can be pinned without a view
|
||||
/// (`SearchFilterTests`). The column, its count badge and its marquee registration all read the
|
||||
/// property, which reads this.
|
||||
///
|
||||
/// Two of the lane's four inputs are absent, and each absence is a ruling: **no rename
|
||||
/// exemption**, because nothing renames in the trash (04 ▸ The trash), and **no drag hiding**,
|
||||
/// because a card dragged *out* of the trash stays visible in it until the write lands.
|
||||
nonisolated static func rendered(_ trash: [Card], filter: SearchFilter) -> [Card] {
|
||||
trash.filter { filter.matches($0) }
|
||||
/// because a row dragged *out* of the trash stays visible in it until the write lands.
|
||||
nonisolated static func rendered(_ snapshot: BoardModel, filter: SearchFilter) -> [TrashEntry] {
|
||||
snapshot.trashEntries.filter { filter.matches($0) }
|
||||
}
|
||||
|
||||
// MARK: - The delete gesture's landing
|
||||
@@ -173,14 +196,14 @@ struct TrashLaneView: View {
|
||||
drops.session.trashProposal(onBoardRooted: store.rootURL)
|
||||
}
|
||||
|
||||
/// The slots the column lays out: the cards, with the delete gesture's shadow run opened at the
|
||||
/// The slots the column lays out: the rows, with the delete gesture's shadow run opened at the
|
||||
/// top.
|
||||
///
|
||||
/// The run stands until the echo reload brings the real cards — the committed-overlay hold keeps
|
||||
/// The run stands until the echo reload brings the real rows — the committed-overlay hold keeps
|
||||
/// the arrangement the release proposed on screen for that round trip, exactly as every other
|
||||
/// container's does (`CommittedHold`).
|
||||
private var slots: [TrashSlot] {
|
||||
var result = renderedCards.map(TrashSlot.card)
|
||||
var result = renderedRows.map(TrashSlot.entry)
|
||||
guard let proposal else { return result }
|
||||
let run = (0..<drops.session.shadowCount).map(TrashSlot.shadow)
|
||||
result.insert(contentsOf: run, at: min(max(0, proposal), result.count))
|
||||
@@ -254,10 +277,15 @@ struct TrashLaneView: View {
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
/// The card count — the same collection the body renders, so the badge cannot disagree with
|
||||
/// The row count — the same collection the body renders, so the badge cannot disagree with
|
||||
/// what is on screen (`LaneView.countBadge`'s rule).
|
||||
///
|
||||
/// **Rows, not cards**: a lane row is a row in this column, and a badge reading `3` above four
|
||||
/// drawn rows would break the one property this badge has. The *breakdown* is the spoken value's
|
||||
/// (`AccessibilityPhrases.trashValue`) and the purge confirmations' (`TrashModel.Freight`), where
|
||||
/// the difference between a card and a lane's freight actually matters.
|
||||
private var countBadge: some View {
|
||||
Text("\(renderedCards.count)")
|
||||
Text("\(renderedRows.count)")
|
||||
.font(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -268,16 +296,16 @@ struct TrashLaneView: View {
|
||||
|
||||
// MARK: - Cards
|
||||
|
||||
/// The cards, scrollable, with the navigation head kept in view.
|
||||
/// The rows, scrollable, with the navigation head kept in view.
|
||||
///
|
||||
/// **"Selection scrolls into view"** (04-interactions.md ▸ Grammar), watching the head rather
|
||||
/// than the whole selection so exactly one column responds to any one arrow — `LaneView`'s rule,
|
||||
/// on the trash side.
|
||||
/// on the trash side. Either kind of row can be the head: plain arrows walk them all.
|
||||
private var cards: some View {
|
||||
ScrollViewReader { proxy in
|
||||
scrollableCards
|
||||
.onChange(of: store.transient.selectionHead) { _, head in
|
||||
guard let head, renderedCards.contains(where: { $0.id == head }) else { return }
|
||||
guard let head, renderedRows.contains(where: { $0.id == head }) else { return }
|
||||
proxy.scrollTo(TrashSlot.identity(of: head))
|
||||
}
|
||||
}
|
||||
@@ -299,7 +327,7 @@ struct TrashLaneView: View {
|
||||
ForEach(Array(slots.enumerated()), id: \.element.id) { index, slot in
|
||||
Group {
|
||||
switch slot {
|
||||
case let .card(card):
|
||||
case let .entry(.card(card)):
|
||||
CardFaceView(
|
||||
store: store,
|
||||
card: card,
|
||||
@@ -307,6 +335,17 @@ struct TrashLaneView: View {
|
||||
marquee: marquee,
|
||||
drops: drops
|
||||
)
|
||||
case let .entry(.lane(lane)):
|
||||
// The opaque unit's row — its own view, because "no styling accents" and
|
||||
// "never expandable" are exactly what a card face is not
|
||||
// (`TrashLaneRowView`, 03-board-ui.md § Trash).
|
||||
TrashLaneRowView(
|
||||
store: store,
|
||||
lane: lane,
|
||||
confirmations: confirmations,
|
||||
drops: drops,
|
||||
marquee: marquee
|
||||
)
|
||||
case .shadow:
|
||||
// The delete gesture's shadow, holding the topmost row open
|
||||
// (04-interactions.md ▸ The trash). At the nominal card height: the cards
|
||||
@@ -316,10 +355,12 @@ struct TrashLaneView: View {
|
||||
.frame(height: LaneDropRegistry.nominalCardHeight)
|
||||
}
|
||||
}
|
||||
// A slot is a card, so it arrives and leaves in the card's dialect — a delete
|
||||
// files one in, a restore or a purge takes one out, and both halves of that pair
|
||||
// should read alike from either side of the strip. The transaction is the
|
||||
// reload's, like the lanes' (`Motion.reloadAnimates`).
|
||||
// A slot is a row in this column, so it arrives and leaves in the card's dialect
|
||||
// whatever its kind — a delete files one in, a restore or a purge takes one out,
|
||||
// and both halves of that pair should read alike from either side of the strip.
|
||||
// A lane row takes the same transition deliberately: what enters and leaves here
|
||||
// is a *row*, and the strip's lane transition is about a column of the board.
|
||||
// The transaction is the reload's, like the lanes' (`Motion.reloadAnimates`).
|
||||
.transition(Motion.cardTransition(reduced: reduceMotion))
|
||||
// `order`-keyed traversal, `LaneView`'s rule on the trash side. The column is
|
||||
// one masonry column, so geometry and `order` agree here and the priority is
|
||||
@@ -357,29 +398,33 @@ struct TrashLaneView: View {
|
||||
|
||||
// MARK: - What the column lays out
|
||||
|
||||
/// One slot of the trash column — a card, or one slot of the delete gesture's run.
|
||||
/// One slot of the trash column — a row of either kind, or one slot of the delete gesture's run.
|
||||
///
|
||||
/// `LaneSlot`'s smaller sibling — smaller by exactly one case, because nothing is ever created in the
|
||||
/// trash so there is no placeholder to stand in for it — and keyed on the same principle: a slot's id
|
||||
/// decides whether the echo reload reads as a *swap* or as a removal and an insertion.
|
||||
///
|
||||
/// The two kinds share one case (`TrashEntry`) rather than taking one each, because the column's
|
||||
/// ordering question is about *rows*: a card and a lane row are interchangeable to everything here
|
||||
/// except the one `switch` that draws them.
|
||||
private enum TrashSlot: Identifiable {
|
||||
|
||||
/// A card the snapshot's trash already holds.
|
||||
case card(Card)
|
||||
/// A row the snapshot's trash already holds — a card, or a trashed lane's opaque unit.
|
||||
case entry(TrashEntry)
|
||||
|
||||
/// One of the drag's N shadows, holding the topmost rows open (04-interactions.md ▸ The trash).
|
||||
case shadow(index: Int)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .card(card): Self.identity(of: card.id)
|
||||
case let .entry(entry): Self.identity(of: entry.id)
|
||||
// Constant per position in the run, so a run that grows or shrinks animates as slots rather
|
||||
// than blinking (`LaneSlot`'s rule).
|
||||
case let .shadow(index): "shadow:\(index)"
|
||||
}
|
||||
}
|
||||
|
||||
/// A card slot's id, spelled once so `scrollTo` and the slot cannot disagree about what the
|
||||
/// A row slot's id, spelled once so `scrollTo` and the slot cannot disagree about what the
|
||||
/// scroll reader is looking for (`LaneSlot.identity(of:)`).
|
||||
static func identity(of item: ItemID) -> String { "trash:\(item.rawValue)" }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user