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:
@@ -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