Materialize the trash — faces, menus, and grammar
Phase 3 finishes the pivot at the surface. One card face serves two containers: CardFaceView extracted with a role — board or trash — so stripe, tint, chip, selection stroke, cut dim, marquee registration, and drag are shared by construction, the trash side differing only in its absences: no Open, no rename, no Style, no file-hover highlight, and a Delete that goes through the confirmation host. The column rewrote around the lanes' own single-column masonry so drag reflow reads as positional slides; chrome stays the hatched header, symbol, and count — 11 gives Empty Trash to the File menu alone. Two real grammar bugs die here: plain Backspace on a trash selection purged without the confirmation the menu raises, and the context menu's Delete resolved against the standing selection, so right-clicking a trash card under a board selection silently did nothing — it now stages the clicked set explicitly. Open, Rename, Style, and Empty Trash validation became testable store seams; the column is one named accessibility container of ordinary card elements. The tombstone era is swept: deleteItem, restoreItem, stripTombstonedChildren — dead since lane copies stopped nesting trash — the restore verb, the unreachable put-back banner row, and every quasi-lane doc comment. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
+114
-289
@@ -7,14 +7,25 @@ import SwiftUI
|
||||
/// 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).
|
||||
///
|
||||
/// ### A rendering of `snapshot.trash`, and a quasi-lane
|
||||
/// ### 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: 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. 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. It is a *quasi*-lane because it wears a lane's shape while sharing none of a lane's
|
||||
/// machinery:
|
||||
/// **Its contents 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 —
|
||||
/// search, selection, rendering, styling, and clipboard all treat it exactly like any other card"
|
||||
/// (03 § Trash), and one view is the only way to make *rendering* literally true rather than
|
||||
/// 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.
|
||||
///
|
||||
/// ### What makes it a column and not a lane
|
||||
///
|
||||
/// The chrome 03 asks for, and nothing beyond it — "trailing (rightmost) position when shown,
|
||||
/// visually distinct — dimmed/hatched header, trash SF Symbol, count badge; no new-card button; not
|
||||
/// draggable, not resizable, excluded from lane reordering":
|
||||
///
|
||||
/// - it spans a **fixed one width unit** — no `width` frontmatter, no stepper, no resize handle, and
|
||||
/// the edge drag never reaches it (`LaneLayoutMath.totalUnits(of:trashUnits:)` supplies the unit,
|
||||
@@ -22,7 +33,9 @@ import SwiftUI
|
||||
/// - it is **not draggable and not reorderable** — the header carries no gesture, and it is absent
|
||||
/// from the drop proposal's slot list by construction, since `BoardView` builds that from the
|
||||
/// snapshot's lanes;
|
||||
/// - it has **no new-card button**: nothing is created in the trash.
|
||||
/// - it has **no new-card button**: nothing is created in the trash. There is deliberately no Empty
|
||||
/// Trash button either — that command's home is File ▸ Empty Trash… (⇧⌘⌫), and 11-command-nexus.md
|
||||
/// gives the column no pointer affordance of its own.
|
||||
///
|
||||
/// ### The drop it takes, and the drag it starts
|
||||
///
|
||||
@@ -34,33 +47,34 @@ import SwiftUI
|
||||
/// 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, 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).
|
||||
/// 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).
|
||||
///
|
||||
/// **Finder file drops stay inert** — "Finder file drops on trash cards are inert" (▸ The trash) —
|
||||
/// and say so directly: the delegate clears the file highlight over the column.
|
||||
/// and say so twice: the delegate clears the file highlight over the column, and the face's hover
|
||||
/// treatment is board-only (`CardFaceRole`).
|
||||
///
|
||||
/// ### No editing in the trash
|
||||
///
|
||||
/// "No editing in the trash: trash cards don't open — double-click stops at selection; move it out
|
||||
/// first." There is deliberately no double-tap recogniser, no rename editor, and no Style… row.
|
||||
/// first." That is the face's `.trash` role: no double-tap recogniser, no rename editor, no Style…
|
||||
/// rows — absences rather than a pile of `disabled` modifiers.
|
||||
///
|
||||
/// ### What is still phase 3's
|
||||
/// ### Accessibility
|
||||
///
|
||||
/// The column renders the materialized trash correctly and its selection, drag, drop, filter and
|
||||
/// context menu all speak the new container vocabulary — but its *visual* treatment is still the
|
||||
/// tombstone era's compact dimmed plate rather than the card face 03 now implies ("a trashed card is
|
||||
/// an ordinary card in a special place"). Reworking the plate into the ordinary face, and the
|
||||
/// accessibility labelling 10-accessibility.md asks for, is the trash's own phase-3 card.
|
||||
/// "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.
|
||||
struct TrashLaneView: View {
|
||||
|
||||
let store: BoardStore
|
||||
|
||||
/// The window's purge-alert host, threaded down rather than read from the focus system: a
|
||||
/// context menu's content is built in its own host, where a `@FocusedValue` is not reliably the
|
||||
/// board window's, and the row's Delete Immediately must raise the *same* alert the menu bar's
|
||||
/// does.
|
||||
/// board window's, and the row's Delete must raise the *same* alert the menu bar's does.
|
||||
let confirmations: TrashConfirmations
|
||||
|
||||
/// The board window's drop machinery — a card's drag is an ordinary card session in the
|
||||
@@ -69,10 +83,10 @@ struct TrashLaneView: View {
|
||||
|
||||
/// The strip's rubber band. The column's empty space is its third surface, in the **trash**
|
||||
/// container — "the rubber band stays on the side it started on" (04-interactions.md ▸ The
|
||||
/// trash) — and every row registers its frame into the same registry.
|
||||
/// trash) — and every card face registers its frame into the same registry.
|
||||
let marquee: MarqueeControl
|
||||
|
||||
/// Reduce Motion, for the row transition below — 10-accessibility.md names the trash
|
||||
/// Reduce Motion, for the card transition below — 10-accessibility.md names the trash
|
||||
/// specifically ("and trash animations all get reduced variants").
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
@@ -80,18 +94,13 @@ struct TrashLaneView: View {
|
||||
/// the lanes rather than as a different kind of object.
|
||||
private let cornerRadius: CGFloat = 10
|
||||
|
||||
private let rowSpacing: CGFloat = 6
|
||||
|
||||
/// The height a shadow row holds open. A trash row's height is content-driven (one or two title
|
||||
/// lines) and the cards being proposed have no row yet to be measured, so the shadow is drawn at
|
||||
/// the nominal single-line plate — `LaneDropRegistry`'s own answer to the same question, in this
|
||||
/// column's smaller idiom.
|
||||
private let nominalRowHeight: CGFloat = 32
|
||||
/// Between the cards — `LaneView.cardSpacing`, because these are the same cards.
|
||||
private let cardSpacing: CGFloat = 8
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
rows
|
||||
cards
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
@@ -103,6 +112,13 @@ struct TrashLaneView: View {
|
||||
// deepest region whatever session is in flight and a narrower target would strand the rest
|
||||
// (`TrashDropDelegate`).
|
||||
.onDrop(of: boardDropTypes, delegate: TrashDropDelegate(context: drops))
|
||||
// "The last container, labeled as Trash with its count" (10-accessibility.md ▸ Trash lane).
|
||||
// `.contain` rather than `.combine`: the cards inside are ordinary card elements and must
|
||||
// stay individually reachable — combining them would collapse the container the design asks
|
||||
// VoiceOver to enter.
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Trash")
|
||||
.accessibilityValue(TrashModel.phrase(renderedCards.count))
|
||||
}
|
||||
|
||||
/// The cards the column shows.
|
||||
@@ -112,9 +128,24 @@ struct TrashLaneView: View {
|
||||
/// 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.
|
||||
private var cards: [Card] {
|
||||
let filter = store.searchFilter
|
||||
return store.snapshot.trash.filter { filter.matches($0) }
|
||||
///
|
||||
/// **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)
|
||||
}
|
||||
|
||||
/// `renderedCards` 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) }
|
||||
}
|
||||
|
||||
// MARK: - The delete gesture's landing
|
||||
@@ -125,14 +156,14 @@ struct TrashLaneView: View {
|
||||
drops.session.trashProposal(onBoardRooted: store.rootURL)
|
||||
}
|
||||
|
||||
/// The rows the `VStack` lays out: the entries, with the delete gesture's shadow run opened at
|
||||
/// the top.
|
||||
/// The slots the column lays out: the cards, with the delete gesture's shadow run opened at the
|
||||
/// top.
|
||||
///
|
||||
/// The run stands until the echo reload brings the real tombstones — the committed-overlay hold
|
||||
/// keeps the arrangement the release proposed on screen for that round trip, exactly as every
|
||||
/// other container's does (`CommittedHold`).
|
||||
/// The run stands until the echo reload brings the real cards — 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 = cards.map(TrashSlot.card)
|
||||
var result = renderedCards.map(TrashSlot.card)
|
||||
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))
|
||||
@@ -145,8 +176,12 @@ struct TrashLaneView: View {
|
||||
/// (03-board-ui.md § Trash ▸ Rendering).
|
||||
///
|
||||
/// The hatching is what makes the column read as *not a lane* at a glance — the design asks for
|
||||
/// "visually distinct", and a lane's header is the surface this must not be mistaken for. It
|
||||
/// carries no gesture at all: no selection (the quasi-lane "is never selectable as a lane"), no
|
||||
/// "visually distinct", and a lane's header is the surface this must not be mistaken for. Now
|
||||
/// that the cards inside wear their ordinary faces, this header is the *whole* of "you are
|
||||
/// looking at the trash", which is why it keeps its full treatment rather than softening.
|
||||
/// **State is never colour-alone** (10-accessibility.md): the header is hatched *plus* labeled.
|
||||
///
|
||||
/// It carries no gesture at all: no selection (the column "is never selectable as a lane"), no
|
||||
/// reorder drag, no context menu.
|
||||
private var header: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
@@ -175,13 +210,15 @@ struct TrashLaneView: View {
|
||||
))
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
// The container carries the label and the count (see `body`), so the header itself is
|
||||
// decoration for VoiceOver rather than a second element saying the same thing.
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
/// The card count — the same collection the body renders, so the badge cannot disagree with
|
||||
/// what is on screen (`LaneView.countBadge`'s rule).
|
||||
private var countBadge: some View {
|
||||
Text("\(cards.count)")
|
||||
Text("\(renderedCards.count)")
|
||||
.font(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -190,51 +227,57 @@ struct TrashLaneView: View {
|
||||
.background(Capsule().fill(.quaternary))
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
// MARK: - Cards
|
||||
|
||||
/// The rows, scrollable, with the navigation head kept in view.
|
||||
/// The cards, 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.
|
||||
private var rows: some View {
|
||||
private var cards: some View {
|
||||
ScrollViewReader { proxy in
|
||||
scrollableRows
|
||||
scrollableCards
|
||||
.onChange(of: store.transient.selectionHead) { _, head in
|
||||
guard let head, cards.contains(where: { $0.id == head }) else { return }
|
||||
guard let head, renderedCards.contains(where: { $0.id == head }) else { return }
|
||||
proxy.scrollTo(TrashSlot.identity(of: head))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var scrollableRows: some View {
|
||||
private var scrollableCards: some View {
|
||||
ScrollView(.vertical) {
|
||||
// **A plain `VStack`, deliberately not lazy.** Every row must keep its drawn frame
|
||||
// **`MasonryLayout` at one column, and a plain `VStack` deliberately not.** The trash is
|
||||
// one width unit, so its masonry is a single column — but it is the *same* layout the
|
||||
// lanes use, which is what makes the drag's make-room reflow read as positional slides
|
||||
// here exactly as it does there (DRAG-REORDER.md § The card masonry).
|
||||
//
|
||||
// Not lazy, for `LaneView`'s reason squared: every face must keep its drawn frame
|
||||
// registered in `MarqueeTargetRegistry` — the rubber band sweeps those frames and the
|
||||
// arrows navigate by them (`NavigationMath`) — and a lazy stack only builds the rows it
|
||||
// has scrolled to, so an unbuilt row is invisible to both. The constraint is affordable
|
||||
// because a trash is small: it holds one board's tombstones, and Empty Trash… exists.
|
||||
VStack(alignment: .leading, spacing: rowSpacing) {
|
||||
// because a trash is small: it holds one board's deletions, and Empty Trash… exists.
|
||||
MasonryLayout(columns: 1, spacing: cardSpacing) {
|
||||
ForEach(slots) { slot in
|
||||
Group {
|
||||
switch slot {
|
||||
case let .card(card):
|
||||
TrashCardRow(
|
||||
CardFaceView(
|
||||
store: store,
|
||||
card: card,
|
||||
confirmations: confirmations,
|
||||
drops: drops,
|
||||
registry: marquee.registry
|
||||
role: .trash(confirmations: confirmations),
|
||||
marquee: marquee,
|
||||
drops: drops
|
||||
)
|
||||
case .shadow:
|
||||
// The delete gesture's shadow, holding the topmost row open
|
||||
// (04-interactions.md ▸ The trash).
|
||||
DragShadow(cornerRadius: 6)
|
||||
// (04-interactions.md ▸ The trash). At the nominal card height: the cards
|
||||
// being proposed have no face here yet to be measured.
|
||||
DragShadow(cornerRadius: CardFaceMetrics.cornerRadius)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: nominalRowHeight)
|
||||
.frame(height: LaneDropRegistry.nominalCardHeight)
|
||||
}
|
||||
}
|
||||
// A row is a card, so it arrives and leaves in the card's dialect — a delete
|
||||
// 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`).
|
||||
@@ -248,73 +291,32 @@ struct TrashLaneView: View {
|
||||
// the rule `BoardView` applies to the strip and `LaneView` to its masonry.
|
||||
.animation(Motion.dragReflow(reduced: reduceMotion), value: proposal)
|
||||
// `maxHeight: .infinity` here, not just `maxWidth`, is what makes the gesture surface
|
||||
// below reach the column's full height rather than stopping where the last row ends —
|
||||
// below reach the column's full height rather than stopping where the last card ends —
|
||||
// the same fix `LaneView.scrollableCards` applies to its masonry, and for the identical
|
||||
// reason: a `ScrollView` proposes its content only the height that content asks for, so a
|
||||
// view sized to fit its rows leaves the blank space beneath them un-hit-testable. "The
|
||||
// view sized to fit its cards leaves the blank space beneath them un-hit-testable. "The
|
||||
// column's gesture surface is full height" (04-interactions.md ▸ The trash, settled)
|
||||
// needs that blank space to actually belong to the view the gesture below is on.
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(6)
|
||||
.contentShape(Rectangle())
|
||||
// The band's trash-side surface. It arms from the column's empty space, full height
|
||||
// (above) included, so a drag can start from the blank area below the last row exactly
|
||||
// as the board background allows on the live side — a drag begun on a row instead is that
|
||||
// row's drag-out, and the begin guard makes that geometric rather than a matter of
|
||||
// (above) included, so a drag can start from the blank area below the last card exactly
|
||||
// as the board background allows on the live side — a drag begun on a face instead is that
|
||||
// card's drag-out, and the begin guard makes that geometric rather than a matter of
|
||||
// gesture priority (`MarqueeControl`).
|
||||
.simultaneousGesture(marquee.gesture(in: .trash))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The plate
|
||||
|
||||
/// The trash row's *appearance*, with none of anybody's behaviour — the compact, dimmed plate a
|
||||
/// tombstone wears.
|
||||
///
|
||||
/// **Two views draw it and neither may drift**: the row itself, and its own drag replica (a drag
|
||||
/// image is a snapshot, and one built out of the live plate would re-register the row's frame from
|
||||
/// inside the preview's geometry and then deregister it when the image went away — quietly stealing
|
||||
/// the row from the rubber band and the arrow keys).
|
||||
private struct TrashRowPlate: View {
|
||||
|
||||
let symbol: String
|
||||
|
||||
/// The title as written, or `nil` for an untitled card — "Untitled" is a rendering, never a value
|
||||
/// (03-board-ui.md § Card face).
|
||||
let title: String?
|
||||
|
||||
var isSelected: Bool = false
|
||||
|
||||
private let cornerRadius: CGFloat = 6
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: symbol)
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.small)
|
||||
Text(title ?? "Untitled")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary.opacity(0.6)))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - What the column lays out
|
||||
|
||||
/// One row of the trash column — an entry, or one slot of the delete gesture's run.
|
||||
/// One slot of the trash column — a card, or one slot of the delete gesture's run.
|
||||
///
|
||||
/// `LaneSlot`'s smaller sibling, 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.
|
||||
/// `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.
|
||||
private enum TrashSlot: Identifiable {
|
||||
|
||||
/// A card the snapshot's trash already holds.
|
||||
@@ -334,7 +336,7 @@ private enum TrashSlot: Identifiable {
|
||||
|
||||
/// A card 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 { "entry:\(item.rawValue)" }
|
||||
static func identity(of item: ItemID) -> String { "trash:\(item.rawValue)" }
|
||||
}
|
||||
|
||||
// MARK: - The hatch
|
||||
@@ -359,180 +361,3 @@ private struct DiagonalHatch: Shape {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
|
||||
/// One trash row: a compact, dimmed plate carrying the card's symbol and title.
|
||||
///
|
||||
/// **Face-like but not a card face.** It shares the plate, the symbol and the title, and it
|
||||
/// deliberately shares none of the face's *editing* affordances: no rename editor, no Open, no
|
||||
/// Style…. That is 03-board-ui.md's no-editing-in-the-trash rule expressed as an absence rather
|
||||
/// than as a pile of `disabled` modifiers. (Making it the ordinary card face is phase 3's — see
|
||||
/// `TrashLaneView`.)
|
||||
private struct TrashCardRow: View {
|
||||
|
||||
let store: BoardStore
|
||||
let card: Card
|
||||
let confirmations: TrashConfirmations
|
||||
let drops: BoardDropContext
|
||||
|
||||
/// Where the rubber band looks up what it is sweeping — the card face's rule, in the trash
|
||||
/// container (`View.marqueeTarget`).
|
||||
let registry: MarqueeTargetRegistry
|
||||
|
||||
var body: some View {
|
||||
plate.onDrag(startRowDrag, preview: { dragReplica })
|
||||
}
|
||||
|
||||
private var plate: some View {
|
||||
rowFace
|
||||
// The row being dragged out dims in place — the source stays visible in the trash,
|
||||
// because a restore is not a removal until the write lands.
|
||||
.opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
|
||||
// The deferred cut wears the same dim wherever it lands. It genuinely fires here now:
|
||||
// "⌘X works — cut in the trash, paste into a lane is the keyboard-native restore"
|
||||
// (04-interactions.md ▸ The trash, resettled 2026-07-28).
|
||||
.cutTreatment(of: card.id, in: store)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { select() }
|
||||
.marqueeTarget(card.id, kind: .card, container: .trash, in: registry)
|
||||
.contextMenu { menu }
|
||||
}
|
||||
|
||||
/// This card as the shared plate draws it — appearance only, no gesture, no context menu and
|
||||
/// crucially no marquee registration, which is what makes it safe for the drag replica to render
|
||||
/// (see `TrashRowPlate`).
|
||||
private var rowFace: some View {
|
||||
TrashRowPlate(
|
||||
symbol: ItemSymbol.name(card.icon, fallback: ItemSymbol.card),
|
||||
title: card.title.value,
|
||||
isSelected: isSelected
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Selection
|
||||
|
||||
private var isSelected: Bool {
|
||||
store.selection.container == .trash && store.selection.ids.contains(card.id)
|
||||
}
|
||||
|
||||
/// A click selects this card in the **trash** container, through the same grammar the board's
|
||||
/// surfaces use — plain replaces, ⌘ toggles, ⇧ ranges (`SelectionGrammar`).
|
||||
///
|
||||
/// The container travels with the click, and that is what keeps the one remaining homogeneity
|
||||
/// boundary true: a ⌘-click across it replaces rather than mixing (04-interactions.md ▸ The
|
||||
/// trash). There is no kind axis inside the trash any more — lanes are never trashed. No
|
||||
/// `togglesOnRepeat` — click-again-to-unselect is the lane's behaviour, not a card's.
|
||||
///
|
||||
/// **A double click is two of these and nothing more**: no editor, no card window, no timer.
|
||||
private func select() {
|
||||
store.click(
|
||||
SelectionTarget(id: card.id, kind: .card, container: .trash),
|
||||
modifier: .current
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Drag out
|
||||
|
||||
/// Begins the card's drag out of the trash — an ordinary **card session in the trash container**,
|
||||
/// which is the whole of what makes it a restore (04-interactions.md ▸ The trash;
|
||||
/// `DragLocality.operation`).
|
||||
///
|
||||
/// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the
|
||||
/// destination lane's masonry, so "an ordinary move to the drop position" is the same arithmetic
|
||||
/// every other card drop uses, committed by the same `moveCards`.
|
||||
///
|
||||
/// **Multi-drag carries the whole trash selection**, in the column's own order — the order the
|
||||
/// rows are drawn in, which is `order` ascending like any lane's.
|
||||
///
|
||||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||||
/// mutating gesture.
|
||||
private func startRowDrag() -> NSItemProvider {
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||
let selection = store.selection
|
||||
let ids: Set<ItemID> = selection.container == .trash
|
||||
&& selection.ids.contains(card.id)
|
||||
&& selection.ids.count > 1
|
||||
? selection.ids
|
||||
: [card.id]
|
||||
|
||||
let rows = store.snapshot.trash.filter { ids.contains($0.id) }
|
||||
guard !rows.isEmpty else { return NSItemProvider() }
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
boardRoot: root,
|
||||
kind: .cards,
|
||||
container: .trash,
|
||||
items: rows.map {
|
||||
DragPayload.Item(
|
||||
id: $0.id.rawValue,
|
||||
folder: ItemPath.trashCard($0.id).folder(under: root).path,
|
||||
title: $0.title.value
|
||||
)
|
||||
}
|
||||
)
|
||||
drops.session.beginCards(
|
||||
rows.map(\.id),
|
||||
folders: payload.folders,
|
||||
heights: rows.map { _ in LaneDropRegistry.nominalCardHeight },
|
||||
container: .trash,
|
||||
source: store
|
||||
)
|
||||
return payload.itemProvider()
|
||||
}
|
||||
|
||||
/// The image under the cursor: the row as it is drawn, fanned with a count badge for a
|
||||
/// multi-drag — the card replica's treatment, at a trash row's size.
|
||||
private var dragReplica: some View {
|
||||
let count = store.selection.container == .trash && store.selection.ids.contains(card.id)
|
||||
? max(1, store.selection.ids.count)
|
||||
: 1
|
||||
return ZStack {
|
||||
if count > 2 { rowFace.offset(x: 10, y: 10).opacity(0.45) }
|
||||
if count > 1 { rowFace.offset(x: 5, y: 5).opacity(0.7) }
|
||||
rowFace
|
||||
}
|
||||
.frame(width: 200)
|
||||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
// MARK: - The trash card's context menu
|
||||
|
||||
/// Delete and Reveal in Finder — the two rows 11-command-nexus.md gives a trash card, and no
|
||||
/// others ("Trash cards | Delete (permanent — 03's recoverability confirm), Reveal in Finder").
|
||||
///
|
||||
/// **Put Back is gone** with the tombstone model: restoring is drag-out or ⌘X/⌘V (03 § Trash).
|
||||
/// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays
|
||||
/// enabled on trash selections", read-only lock included — inspecting a folder before a purge is
|
||||
/// exactly the errand it exists for.
|
||||
@ViewBuilder
|
||||
private var menu: some View {
|
||||
Button("Delete") {
|
||||
confirmations.requestPurge(of: targetIDs, in: store)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Reveal in Finder") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, and the same rule the card face and the lane
|
||||
/// header apply to Style….
|
||||
private var targetIDs: Set<ItemID> {
|
||||
guard store.selection.container == .trash, store.selection.ids.contains(card.id) else {
|
||||
return [card.id]
|
||||
}
|
||||
return store.selection.ids
|
||||
}
|
||||
|
||||
private var targetFolders: [URL] {
|
||||
ItemPath.resolve(targetIDs, in: .trash, snapshot: store.snapshot)
|
||||
.map { $0.folder(under: store.rootURL) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user