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:
2026-07-28 18:18:39 -04:00
parent 53bc71f7fb
commit 797d020d01
34 changed files with 1272 additions and 1422 deletions
+64 -44
View File
@@ -138,19 +138,7 @@ struct OpenCardCommand: View {
private var isEnabled: Bool {
guard let store, opener?.open != nil else { return false }
return store.isEditingInline || soleSelectedCard != nil
}
/// The sole selected **board card**, or `nil`. A lane, a multi-selection and a trash
/// selection all answer `nil` "everything edit-shaped is disabled on trash selections Open
/// Card, Rename, Style" (04 The trash), and a card window is tied to one card.
private var soleSelectedCard: ItemID? {
guard let store else { return nil }
let selection = store.selection
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
BoardStore.boardItem(id, in: store.snapshot)?.cardID != nil
else { return nil }
return id
return store.isEditingInline || store.openCardTarget != nil
}
private func open() {
@@ -176,7 +164,61 @@ struct OpenCardCommand: View {
return
}
if let card = soleSelectedCard { open(card) }
if let card = store.openCardTarget { open(card) }
}
}
// MARK: - The three edit-shaped targets, as pure predicates
/// **Everything edit-shaped refuses a trash selection** (04-interactions.md The trash: "Everything
/// edit-shaped is disabled on trash selections Open Card, Rename, Style"), and each of the three
/// answers that with one expression used for both its `disabled` state and its action the
/// `newCardTarget` idiom, for its reason: two derivations of a rule are two chances to disagree.
///
/// They live on the store rather than inside the three menu rows so the grammar can be pinned
/// without a menu (`SelectionGrammarTests`) the same reason `TrashModel`'s validation is a pure
/// function of a snapshot and a selection. A view-private predicate is a rule nobody can test.
extension BoardStore {
/// Board Open Card's target: the sole selected **board card**, or `nil`. A lane, a
/// multi-selection and a trash selection all answer `nil` a card window is tied to one card,
/// and trash cards don't open ("double-click stops at selection; move it out first" 03 §
/// Trash).
///
/// Deliberately free of `acceptsBoardMutations`: opening a window is not a mutation, and the
/// item's own mid-edit branch is the focused-editor rule's one carve-out (`OpenCardCommand`).
var openCardTarget: ItemID? {
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
Self.boardItem(id, in: snapshot)?.cardID != nil
else { return nil }
return id
}
/// Board Rename's target: the sole selected board item, card or lane, with the title to seed
/// the editor with or `nil`. A trash selection never enables it, which `ItemReferenceSet`'s
/// container answers directly.
var renameTarget: (id: ItemID, title: String?)? {
guard acceptsBoardMutations else { return nil }
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
let item = Self.boardItem(id, in: snapshot)
else { return nil }
return (id: id, title: item.title)
}
/// Board Style's target: the selected board items, or **the board itself** when nothing is
/// selected "Board window: selected cards or lane; nothing selected = the board".
///
/// **A trash selection disables it rather than falling through to the board**: quietly restyling
/// the board because the user had a trashed card selected would be the silent retarget 03
/// forbids.
var boardStyleTarget: StyleTarget? {
guard acceptsBoardMutations else { return nil }
guard !selection.isEmpty else { return .board }
guard selection.container == .board else { return nil }
// Re-resolved against the snapshot on the way in, so the session starts out holding only
// items that render the same universe its own reload rule will hold it to.
let live = selection.resolved(against: snapshot).ids
return live.isEmpty ? nil : .items(live)
}
}
@@ -187,7 +229,7 @@ struct OpenCardCommand: View {
/// half alongside the lane-width pair).
///
/// **Validation and action read one answer** (`BoardStore.sortPlan`), the width pair's rule: the
/// items disable on everything the design calls inert a lane selection, a tombstoned selection, a
/// items disable on everything the design calls inert a lane selection, a trash selection, a
/// card selection spanning lanes ("cards never change lanes by -arrow") and additionally on a
/// block already at its lane's end, where the only outcome would be a silent no-op.
///
@@ -227,7 +269,7 @@ struct MoveCardCommands: View {
/// multi-lane move has no single unambiguous meaning ("one slot" for a discontiguous pair is not one
/// answer). So the items validate on exactly one selected live lane.
///
/// **Never into the trash** costs nothing: the quasi-lane is not in the live lane order, so a step
/// **Never into the trash** costs nothing: the column is not in the lane order, so a step
/// past the last real lane is simply off the end which is also the disable rule at the walls,
/// following the width stepper's floor style rather than letting the store no-op silently.
///
@@ -391,19 +433,10 @@ struct BoardRenameCommand: View {
var body: some View {
Button("Rename") {
guard let store, let target = renameTarget else { return }
guard let store, let target = store.renameTarget else { return }
store.transient.beginRename(of: target.id, currentTitle: target.title)
}
.disabled(renameTarget == nil)
}
private var renameTarget: (id: ItemID, title: String?)? {
guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
let item = BoardStore.boardItem(id, in: store.snapshot)
else { return nil }
return (id: id, title: item.title)
.disabled(store?.renameTarget == nil)
}
}
@@ -420,32 +453,19 @@ struct BoardRenameCommand: View {
///
/// Validation is `acceptsBoardMutations` the lock and the focused-editor rule, the latter naming
/// Style in its own list of board-scoped commands (04-interactions.md Grammar) plus one rule of
/// its own: **a tombstoned selection disables it rather than falling through to the board.**
/// Everything edit-shaped is disabled on tombstoned selections (04 The trash), and quietly
/// restyling the board because the user had a trashed card selected would be the silent retarget
/// 03 forbids.
/// its own: **a trash selection disables it rather than falling through to the board**
/// (`BoardStore.boardStyleTarget`, where both halves live).
struct BoardStyleCommand: View {
@FocusedValue(\.boardStore) private var store
var body: some View {
Button("Style…") {
guard let store, let target = styleTarget else { return }
guard let store, let target = store.boardStyleTarget else { return }
store.transient.beginStyleEditor(for: target)
}
.keyboardShortcut("s", modifiers: [.option, .command])
.disabled(styleTarget == nil)
}
private var styleTarget: StyleTarget? {
guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection
guard !selection.isEmpty else { return .board }
guard selection.container == .board else { return nil }
// Re-resolved against the snapshot on the way in, so the session starts out holding only
// items that render the same universe its own reload rule will hold it to.
let live = selection.resolved(against: store.snapshot).ids
return live.isEmpty ? nil : .items(live)
.disabled(store?.boardStyleTarget == nil)
}
}
+9 -9
View File
@@ -128,7 +128,7 @@ struct BoardDropContext {
// MARK: Re-grounding
/// **Rule 2 of the mid-drag re-grounding trio** (04-interactions.md Drag and drop): a proposal
/// whose target lane was tombstoned or vanished in a reload is invalidated tombstoned lanes are
/// whose target lane vanished in a reload is invalidated deleted lanes are
/// never drop targets the shadow withdraws, and no proposal stands until the pointer reaches a
/// live target.
///
@@ -152,7 +152,7 @@ struct BoardDropContext {
/// this strip's standard width and the cursor is the physical mouse, so neither input is a
/// measured frame (03-board-ui.md § Motion).
///
/// **The terminal slot is clamped before the trash.** The quasi-lane consumes one unit while
/// **The terminal slot is clamped before the trash.** The column consumes one unit while
/// shown and is never a landing spot for anything (04-interactions.md The trash: "no move or
/// 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.
@@ -264,7 +264,7 @@ struct BoardDropContext {
/// **A refusal falls through to the strip's own answer rather than withdrawing the proposal**,
/// which is precisely what this column did before it had a drop target of its own: a cursor over
/// it resolves to no lane, so `retargetCardsFromStrip` holds whatever the shadows already show
/// and `retargetLanes` clamps the terminal slot in front of the quasi-lane. That is the
/// and `retargetLanes` clamps the terminal slot in front of the trash column. That is the
/// hysteresis contract (DRAG-REORDER.md § Hysteresis) and it is also the honest reading of "the
/// trash proposes nothing for you": the column declines to be a target, it does not cancel the
/// drag the user is still holding. So a lane drag reorders across the column exactly as it always
@@ -390,9 +390,9 @@ struct BoardDropContext {
/// dispatch) and the live handler for the strip's own surfaces and unlike a card session, those
/// surfaces genuinely clear the proposal rather than holding it. A cursor over a gap, the outer
/// margin, or **the trash column** is over no lane at all (`LaneLayoutMath.laneIndex` answers
/// `nil` there, since the quasi-lane is absent from the live lane list by construction), so the
/// `nil` there, since the trash column is absent from the lane list by construction), so the
/// highlight withdraws and a release refuses: "Finder file drops (attachment import) on
/// tombstoned cards are inert" and the trash column is never a target (04-interactions.md The
/// trash cards are inert" and the trash column is never a file-drop target (04-interactions.md The
/// trash).
func retargetFileFromStrip(_ info: DropInfo) {
guard acceptsFileDrop(info), let cursor = stripCursor() else {
@@ -490,7 +490,7 @@ struct BoardDropContext {
/// against whatever the last render believed:
///
/// 1. the geometry was re-derived on every sample and the proposal is what it produced;
/// 2. a proposal naming a vanished or tombstoned lane is invalidated, and **release with no valid
/// 2. a proposal naming a vanished lane is invalidated, and **release with no valid
/// proposal cancels** items return, nothing is written;
/// 3. an emptied drag cancels itself, and a partly emptied one drops the survivors.
///
@@ -531,7 +531,7 @@ struct BoardDropContext {
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 quasi-lane is absent
// `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.
guard target.container == .strip else {
@@ -758,7 +758,7 @@ struct StripDropDelegate: DropDelegate {
/// 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;
/// - **Finder file sessions** by clearing the highlight outright: "Finder file drops (attachment
/// import) on tombstoned cards are inert" ( The trash), and the column has nothing else to offer
/// import) on trash cards are inert" ( The trash), and the column has nothing else to offer
/// them no lane, no card, nothing to attach to.
struct TrashDropDelegate: DropDelegate {
@@ -783,7 +783,7 @@ struct TrashDropDelegate: DropDelegate {
context.session.proposeFile(nil)
}
/// A release on the column commits whatever stands the tombstone when the trash is the
/// A release on the column commits whatever stands the delete when the trash is the
/// proposal, and otherwise the proposal the column declined to displace, which is the same
/// "the drop lands where the shadows show" promise as anywhere else.
func performDrop(info: DropInfo) -> Bool {
+22 -19
View File
@@ -19,14 +19,14 @@ import SwiftUI
///
/// - **Lane resize** the right-edge grab strip (above). Deliberately *not* a drag session
/// (DRAG-REORDER.md § Adjacent interaction).
/// - **Drag & drop** cards, lanes and trash rows travel as **system drag sessions**, which is what
/// - **Drag & drop** cards (in either container) and lanes travel as **system drag sessions**, which is what
/// crosses window boundaries, draws the copy badge and gives the full-size replica
/// (`DragSession`, `BoardDrops.swift`, DRAG-REORDER.md). The strip owns the drop geometry
/// registry and the strip-level drop target; the lanes own theirs.
/// - **The rubber band** a drag from any empty surface sweeps a selection (`MarqueeSession`,
/// `MarqueeMath`); the strip owns the session and the target registry, and hands both down.
/// - **The board's fixed grammar keys** (11-command-nexus.md Fixed grammar keys) the four
/// arrows and their / modes, Return's create/rename dispatch, 's tombstone, Escape's step
/// arrows and their / modes, Return's create/rename dispatch, 's staged delete, Escape's step
/// outward, and Select All. The chorded commands are the menu's (`BoardCommands`); everything
/// here is a plain key or a grammar modifier, which is exactly the split 04-interactions.md
/// Configurable bindings draws between what remaps and what does not.
@@ -35,7 +35,7 @@ import SwiftUI
/// carries those titles, and titles are the remapping mechanism's key, so a second row sharing one
/// is ruled out (04-interactions.md Configurable bindings).
///
/// - **The trash quasi-lane** trailing, one fixed unit, joining and leaving the width division as
/// - **The trash column** trailing, one fixed unit, joining and leaving the width division as
/// View Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash).
///
/// ### The live search filter
@@ -91,7 +91,7 @@ struct BoardView: View {
@State private var marquee = MarqueeSession()
/// Where every sweepable item is drawn, in strip coordinates. Owned here because the band is
/// the cards and trash rows only *register* into it (`MarqueeTargetRegistry`).
/// the card faces on either side only *register* into it (`MarqueeTargetRegistry`).
@State private var marqueeTargets = MarqueeTargetRegistry()
/// The name of the strip's coordinate space, which is what a drop out of the trash is resolved
@@ -252,7 +252,7 @@ struct BoardView: View {
case let .lane(lane):
laneSlot(lane, standard: standard)
// "Appear/disappear is scale + fade lanes ~0.9" (03-board-ui.md § Motion).
// A create, a delete and a Put Back all reach the strip as a lane arriving in
// A create, a delete and an undo all reach the strip as a lane arriving in
// or leaving this `ForEach`; whether that *performs* is decided upstream, at
// the reload that carried it (`Motion.reloadAnimates`) a transition with no
// animated transaction around it is simply an appearance.
@@ -267,7 +267,7 @@ struct BoardView: View {
}
}
if isTrashVisible {
// Trailing, always the quasi-lane has no position of its own to lose, which is
// Trailing, always the column has no position of its own to lose, which is
// also why it never appears in the drop proposal's inputs (those are built from
// `boardLanes`) and why the terminal slot clamps in front of it.
TrashLaneView(
@@ -436,7 +436,7 @@ struct BoardView: View {
// MARK: - Trash
/// Whether the trash quasi-lane is on screen transient, board-scoped, hidden on every open
/// Whether the trash column is on screen transient, board-scoped, hidden on every open
/// (03-board-ui.md § Trash Visibility). Read in two places (the unit total and the slot), so it
/// gets a name rather than being spelled twice.
private var isTrashVisible: Bool {
@@ -609,23 +609,26 @@ struct BoardView: View {
return .handled
}
/// **Plain tombstones the live selection** "a plain-key synonym of File Delete, kept
/// grammar so no second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md The
/// map).
/// **Plain deletes the selection** "a plain-key synonym of File Delete, kept grammar so no
/// second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md The map).
///
/// **Both stagings**, unlike the tombstone era's live-only reading: "Plain performs the same
/// delete as fixed grammar" (04-interactions.md The map, resettled 2026-07-28), and the delete
/// is staged by place inside the store (`BoardStore.deleteSelection`) rather than by two menu
/// items sharing a chord. Put Back the reason the bare key had to stay off the trash is
/// retired with the tombstone model.
/// delete as fixed grammar" (04-interactions.md The map, resettled 2026-07-28) a board
/// selection moves into `.trash/`, a trash selection deletes permanently.
///
/// **Which means the same confirmation, too.** It goes through `TrashConfirmations.requestDelete`
/// rather than straight to `BoardStore.deleteSelection`, because "the same chord deletes
/// permanently confirmation per 03's recoverability rule" and a bare key that skipped the alert
/// the menu item raises would be the one path in the app where one keystroke destroys a card
/// silently. The staging itself is still the store's the alert is the only thing this adds.
///
/// Inert while an inline editor is open, like every grammar key: the field owns as backspace,
/// and a stray one reaching the board mid-edit would delete the item being renamed.
private func handleDelete(_ press: KeyPress) -> KeyPress.Result {
// **Plain , spelled out.** The modified chords belong to the menu (Delete / Put Back),
// (Delete Immediately), (Empty Trash) and AppKit routes a key equivalent to the
// **Plain , spelled out.** The modified chords belong to the menu (Delete),
// (Delete Immediately), (Empty Trash) and AppKit routes a key equivalent to the
// menu before the view sees it. But and are nobody's key equivalent, and a fall-through
// that tombstoned the selection on a mistyped text-editing chord would be exactly the kind of
// that deleted the selection on a mistyped text-editing chord would be exactly the kind of
// accident 04-interactions.md's fixed grammar is careful to avoid.
guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else {
return .ignored
@@ -633,7 +636,7 @@ struct BoardView: View {
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
let selection = store.selection
guard !selection.isEmpty else { return .ignored }
store.deleteSelection()
confirmations.requestDelete(in: store)
return .handled
}
@@ -860,7 +863,7 @@ struct BoardView: View {
}
/// **/ jump to the current container's first/last card** the lane's, or the trash
/// quasi-lane's when that is where the cursor is.
/// trash column's 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
+600
View File
@@ -0,0 +1,600 @@
import AppKit
import SwiftUI
// MARK: - The card plate's metrics
/// The card plate's geometry, spelled once because **three views draw it**: the real face
/// (`CardFaceView`), the placeholder standing in for a card on its way (`NewCardStubView`'s
/// `.awaitingArrival` face), and since the face itself is what the trash column renders nothing
/// else at all. 02-architecture.md TransientBoardState overlays makes the create handoff "read as
/// one arrival the placeholder renders at the arriving card's exact geometry/chrome", and exact is
/// only checkable if there is one set of numbers rather than two that happen to agree.
enum CardFaceMetrics {
/// Shared by the plate, the accent stripe and the selection stroke, so the stripe reads as part
/// of the card's edge rather than a bar laid over it.
static let cornerRadius: CGFloat = 8
/// K1 · left edge stripe (03-board-ui.md § Styling Capabilities). Reserved as padding whether
/// or not a stripe paints, so colouring a card never shifts its title.
static let stripeWidth: CGFloat = 4
/// The plate's inset around its content.
static let contentPadding: CGFloat = 10
/// Between the icon, the title and the attachments chip.
static let rowSpacing: CGFloat = 6
}
// MARK: - Which side of the board a face is on
/// **The one axis a card face has** which container it is drawn in, and the collaborator that
/// container's grammar needs.
///
/// 03-board-ui.md § Trash, resettled 2026-07-28: "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". So there is **one** card face in this app, and this is the whole of what differs between
/// its two homes. Everything the two sides share the plate, the stripe, the icon tint, the
/// attachments chip, the selection treatment, the cut dim, the marquee registration, the drag is
/// shared by construction rather than by two views agreeing.
///
/// The three differences are all *absences on the trash side*, and each is 04-interactions.md The
/// trash's "everything edit-shaped is disabled on trash selections" showing up as a branch that is
/// simply not taken:
///
/// - **no Open** no double-click gesture at all ("trash cards don't open double-click stops at
/// selection"), which is also why `openCard` is the board case's payload rather than the view's;
/// - **no Rename** the inline editor is board-only (`isRenaming`), so a rename that somehow
/// targeted a trashed card would render nothing rather than open a field over it;
/// - **no Style** no popover anchor, and no Style rows in the context menu.
///
/// Plus the two that are not about editing: Finder file drops are inert over the trash ( The trash),
/// so the file-hover highlight is board-only; and the trash's context-menu Delete is *permanent*, so
/// it needs the window's confirmation host (11-command-nexus.md Context menus' Trash cards row).
enum CardFaceRole {
/// A card in a lane. Carries the board window's card opener 's pointer twin
/// (04-interactions.md Selection).
case board(openCard: (ItemID) -> Void)
/// A card in `<root>/.trash/`. Carries the window's purge-alert host, because the trash's Delete
/// is the permanent one and "confirms exactly where the loss is real" (03 § Trash).
case trash(confirmations: TrashConfirmations)
/// Which container a click on this face selects in, which container its drag begins in, and which
/// container the rubber band sweeps it as one answer, so the three can never disagree
/// (`SelectionGrammar`: "the container is the surface's, not the item's").
var container: ItemContainer {
switch self {
case .board: .board
case .trash: .trash
}
}
}
// MARK: - Card face
/// The card face: a rounded plate carrying a leading SF Symbol, the title (or its quiet "Untitled"
/// placeholder), a quiet trailing attachments indicator, and a left-edge colour accent stripe
/// (03-board-ui.md § Card face, § Styling Capabilities).
///
/// ### One face, two containers
///
/// **This is the trash's row too** (03-board-ui.md § Trash, resettled 2026-07-28 the materialized
/// trash): "a trashed card is an ordinary card in a special place rendering treat it exactly like
/// any other card". The tombstone era's compact dimmed plate is retired with the tombstones it drew;
/// a trashed card wears its style, its stripe, its icon tint and its attachments chip exactly as it
/// did in its lane, because it is the same card and the same view. What the trash takes away is
/// listed on `CardFaceRole` and nowhere else.
///
/// ### Title-only, deliberately
///
/// **No body excerpt** settled, "the face stays title-only the old 'iterate on the card face
/// later' item is closed with no growth". The only face chip in scope is attachments, "a quiet
/// indicator when the card has files the title dominates", which is why the paperclip is a
/// secondary-tinted caption and not a count pill: the eye should land on the title.
///
/// ### Two lenient fields, two different fallbacks
///
/// `icon` and `iconColor` are hand-written-only on cards (`iconColor` is **schema yes, control
/// no** the app never offers a picker for it, but honours what an author writes). Both degrade
/// rather than fail: an unknown symbol name draws the level default (`ItemSymbol`), and a colour
/// value that resolves to nothing draws the standard secondary tint. `background` degrades a third
/// way to **no stripe at all** because there is no sensible default colour for "the author
/// meant something we can't read", and a wrong colour is worse than none. In every case the bytes
/// on disk are untouched (`Palette`, 01-storage-format.md § Frontmatter).
///
/// ### One presentation, selection styling only
///
/// The face is a top-aligned title row and its two decorations the accent stripe and the
/// selection stroke are shapes in overlays. **A card has one presentation** (resettled
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): selection changes only this
/// face's styling the selection stroke below and never its geometry, so the masonry never
/// reflows on a click and every consumer of a card's drawn frame (the drop model's resting grid,
/// the rubber band's sweep universe) can trust a selection change to leave it untouched. Viewing an
/// attachment's media is the card window's job ( / double-click, 05-card-window.md), not the
/// face's the paperclip chip below is the face's whole attachment story (03-board-ui.md § Card
/// face).
struct CardFaceView: View {
let store: BoardStore
let card: Card
/// Which side of the board this face is drawn on, and what that side's grammar needs the view's
/// one axis (`CardFaceRole`).
let role: CardFaceRole
/// The strip's rubber band the registry this face registers its drawn frame into.
let marquee: MarqueeControl
/// The board window's drop machinery: this face registers its measured height into the geometry
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
let drops: BoardDropContext
/// The app-wide quick-style recents see `LaneView`'s own note.
@Environment(AppModel.self) private var appModel
/// The plate's corner radius shared with the accent stripe, which rounds its left corners to
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
/// Read from `CardFaceMetrics` rather than spelled here, because the new-card placeholder has to
/// draw this same plate for the create handoff to read as one arrival.
private var cornerRadius: CGFloat { CardFaceMetrics.cornerRadius }
/// K1 · left edge stripe (03-board-ui.md § Styling Capabilities, settled in the pathfinder's
/// treatment shootout).
private var stripeWidth: CGFloat { CardFaceMetrics.stripeWidth }
/// **The role's three absences, as a branch rather than as disabled modifiers.** Everything both
/// sides share is in `face`; what the board has and the trash does not is attached here, so the
/// trash's no-Open/no-Rename/no-Style is expressed by code that is not written rather than by
/// gestures that fire and refuse (`CardFaceRole`).
@ViewBuilder
var body: some View {
switch role {
case let .board(openCard):
face
// "A fast double-click opens the card window ('s pointer twin)" (04 Selection).
//
// `simultaneousGesture` rather than a second `onTapGesture(count: 2)`, deliberately: a
// second tap recogniser on the same view makes the single click *wait* to see whether a
// second one arrives, and selection must stay instant. Simultaneous means the first click
// of the pair selects and the second opens Finder's own behaviour.
//
// **Plain only.** and double-clicks are selection gestures that happened twice; opening
// a window out from under a range the user is still building would be a surprise.
.simultaneousGesture(TapGesture(count: 2).onEnded {
guard ClickModifier.current == .plain else { return }
openCard(card.id)
})
.contextMenu { boardMenu(openCard: openCard) }
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
}
case let .trash(confirmations):
face
.contextMenu { trashMenu(confirmations: confirmations) }
}
}
/// Everything the two containers share which, after the pivot, is the face itself.
private var face: some View {
titleRow
.frame(maxWidth: .infinity, alignment: .leading)
.padding(CardFaceMetrics.contentPadding)
// Constant, whether or not a stripe paints: every card's text sits on the same grid, so
// colouring a card never shifts its title relative to its uncoloured neighbours.
.padding(.leading, stripeWidth)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
.overlay(alignment: .leading) { accentStripe }
// The selection treatment, which a hovering Finder file drag borrows outright: "the card
// highlights while hovered" (04-interactions.md Drag and drop), and the accent stroke is
// already this face's vocabulary for "this one". The hover draws it a touch heavier so a
// hovered card that is *also* selected still reads as the target.
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(
isSelected || isFileHovered ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
lineWidth: isFileHovered ? 2.5 : 1.5
)
)
// The deferred cut's dim (04-interactions.md Clipboard: "cut items dim in place until paste
// moves them"). Above `contentShape` so the face stays fully clickable while it waits. It
// genuinely fires on the trash side too: "X works cut in the trash, paste into a lane is
// the keyboard-native restore" (04 The trash, resettled 2026-07-28).
.cutTreatment(of: card.id, in: store)
// The face being dragged out dims the same way while the session is in flight on the trash
// side, where the source stays visible: a restore is not a removal until the write lands.
// (On the board a dragged card is lifted out of the resting layout entirely, so this never
// has anything to act on there `LaneView.renderedCards`.)
.opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
.contentShape(Rectangle())
// **Clicking never edits** (04-interactions.md Selection, a pivot from the pathfinder's
// two-stage Finder rename): one click selects and that is all it does no timer, no
// slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
// Board Rename. The modifier grammar plain replaces, toggles, ranges is
// `SelectionGrammar`'s, reached through the store's one funnel.
//
// **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). A double click in the trash is two of these and nothing
// more no editor, no card window, no timer.
.onTapGesture {
store.click(SelectionTarget(id: card.id, kind: .card, container: role.container), modifier: .current)
}
// **The whole face is the drag surface** (04-interactions.md Drag and drop). `.onDrag`
// beside the tap recognisers above is the click-versus-drag split, the system's own: it holds
// the session off until the pointer really moves, so selecting and opening stay instant.
.onDrag(startDrag, preview: { dragReplica })
// The card's height, for the drop model's analytic resting grid. A height is content-driven
// and does not animate under the reflow only positions do, and those are never measured
// (`LaneDropRegistry`). The trash side registers too: a trash card dragged out is an
// ordinary card session, and the shadow it opens in the destination lane should be its real
// footprint rather than the nominal guess.
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
drops.registry.update(height: height, for: card.id)
}
.onDisappear { drops.registry.removeHeight(card.id) }
.marqueeTarget(card.id, kind: .card, container: role.container, in: marquee.registry)
}
// MARK: - The card drag
/// Begins this card's system drag session in **its own container**, which is the whole of what
/// makes a trash card's drag a restore (04-interactions.md The trash; `DragLocality.operation`).
private func startDrag() -> NSItemProvider {
switch role.container {
case .board: startBoardCardDrag()
case .trash: startTrashCardDrag()
}
}
/// A lane card's drag (DRAG-REORDER.md; 04-interactions.md Drag and drop).
///
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order**
/// "lane `order` first, then card `order`", `SelectionGrammar.boardCards`' single definition of
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
///
/// 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 startBoardCardDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let snapshot = store.snapshot
let ids = draggedIDs
// Flatten order, and the lane each member currently lives in the folder path's middle
// component.
var lanesByCard: [ItemID: ItemID] = [:]
var titles: [ItemID: String] = [:]
for lane in snapshot.lanes {
for member in lane.cards where ids.contains(member.id) {
lanesByCard[member.id] = lane.id
titles[member.id] = member.title.value
}
}
let ordered = SelectionGrammar.boardCards(in: snapshot).filter { ids.contains($0) }
guard !ordered.isEmpty else { return NSItemProvider() }
let root = store.rootURL
let payload = DragPayload(
boardRoot: root,
kind: .cards,
container: .board,
items: ordered.compactMap { id in
guard let laneID = lanesByCard[id] else { return nil }
return DragPayload.Item(
id: id.rawValue,
folder: root
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(id.rawValue, isDirectory: true)
.path,
title: titles[id]
)
}
)
drops.session.beginCards(
ordered,
folders: payload.folders,
// The dragged items' sizes, frozen at drag start the pickup transition scales the
// replica, and its lingering "last measured frame" would mis-size the shadow and the
// span-cap (03-board-ui.md § Motion).
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
container: .board,
source: store
)
return payload.itemProvider()
}
/// A trash card's drag out **the restore**, and deliberately not special: an ordinary `.cards`
/// session in the `.trash` container, which `BoardDropContext.commitDrop` hands 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).
///
/// 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.
private func startTrashCardDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let ids = draggedIDs
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 { drops.registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight },
container: .trash,
source: store
)
return payload.itemProvider()
}
/// What travels: the whole selection when this card is in it, else this card alone the drag's
/// half of the context-menu targeting rule, and container-scoped like everything else.
private var draggedIDs: Set<ItemID> {
let selection = store.selection
guard selection.container == role.container,
selection.ids.contains(card.id),
selection.ids.count > 1
else { return [card.id] }
return selection.ids
}
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
private var dragReplica: some View {
let count = store.selection.container == role.container && store.selection.ids.contains(card.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(12)
}
/// A static rendition of the face a drag image is a snapshot, so it carries no gestures, no
/// editor and no geometry observers, and crucially no marquee registration (one built out of the
/// live face would re-register the card's frame from inside the preview's geometry and then
/// deregister it when the image went away, quietly stealing the card from the rubber band and the
/// arrow keys).
private var replicaFace: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
.foregroundStyle(iconTint)
.imageScale(.medium)
Text(card.title.value ?? "Untitled")
.font(.body)
.lineLimit(4)
.frame(maxWidth: .infinity, alignment: .leading)
attachmentsIndicator
}
.padding(10)
.padding(.leading, stripeWidth)
.frame(width: 220, alignment: .leading)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
.overlay(alignment: .leading) { accentStripe }
}
// MARK: - Context menus
/// Open, Rename, Style, the quick-style recents row, Delete 11-command-nexus.md Context
/// menus' Card row, in its order.
@ViewBuilder
private func boardMenu(openCard: @escaping (ItemID) -> Void) -> some View {
// Open: Board Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card
// alone "a card window is tied to one card" (11-command-nexus.md), so unlike Style and
// Delete below it, this row never widens to the selection; Open never opens multiple, even
// when the clicked card is part of one. It calls the very `openCard` closure the double-click
// gesture above uses, not `OpenCardCommand`'s mid-edit branches: there is no gesture path from
// a focused inline editor to *this* card's context menu, so there is nothing here to commit
// first only the plain open.
Button("Open") {
openCard(card.id)
}
Divider()
// Rename: Board Rename's exact store path (`BoardRenameCommand`) `beginRename(of:
// currentTitle:)`, seeded with the card's live title. The menu-bar item additionally requires
// this card to be the *sole* selection; a context menu already names its target by where it
// was invoked, so standard macOS practice it acts on the clicked card outright.
Button("Rename") {
store.transient.beginRename(of: card.id, currentTitle: card.title.value)
}
.disabled(!store.acceptsBoardMutations)
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
Divider()
// Delete: File Delete's exact store path (`store.delete`, `TrashCommands`'s twin), on the
// widened target set below (`targetIDs`) the successor-selection rule is `delete(_:)`'s own,
// so this row gets it for free.
Button("Delete") {
store.delete(targetIDs)
}
.disabled(!store.acceptsBoardMutations)
}
/// 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.
///
/// The Delete row goes through the window's confirmation host rather than straight to the store,
/// because this delete is the **permanent** one and the alert is what stands between it and an
/// unrecoverable loss (03 § Trash; `TrashConfirmations.requestTrashDelete`).
@ViewBuilder
private func trashMenu(confirmations: TrashConfirmations) -> some View {
Button("Delete") {
confirmations.requestTrashDelete(of: targetIDs, in: store)
}
.disabled(!store.acceptsBoardMutations)
Divider()
Button("Reveal in Finder") {
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
}
}
/// What this card's menu acts on: the whole selection when this card is part of it, else this card
/// alone standard macOS context-menu targeting, shared by Style (`styleTarget`) and Delete
/// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked.
private var styleTarget: StyleTarget {
.items(targetIDs)
}
/// Delete's target set the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
/// because `store.delete(_:)` takes one directly. Container-scoped, so a trash row's menu never
/// widens to a board selection and vice versa.
private var targetIDs: Set<ItemID> {
guard store.selection.container == role.container, store.selection.ids.contains(card.id) else {
return [card.id]
}
return store.selection.ids
}
/// The folders Reveal in Finder points at resolved in this face's container, so a trash row
/// reveals `<root>/.trash/<uuid>` and never a lane path that no longer holds the card.
private var targetFolders: [URL] {
ItemPath.resolve(targetIDs, in: role.container, snapshot: store.snapshot)
.map { $0.folder(under: store.rootURL) }
}
// MARK: - Title row
private var titleRow: some View {
HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) {
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
.foregroundStyle(iconTint)
.imageScale(.medium)
titleOrEditor
// The title takes the row's width so the indicator sits hard against the trailing
// edge and so the rename field fills the same span the title occupied.
.frame(maxWidth: .infinity, alignment: .leading)
attachmentsIndicator
}
}
/// The title, or the rename editor when this card is the rename target. The four exits and their
/// store calls are 04-interactions.md Grammar's, stated once in `InlineTitleField`.
@ViewBuilder
private var titleOrEditor: some View {
if isRenaming {
InlineTitleField(
text: draft,
prompt: "Card title",
onCommit: { store.commitRename() },
onAbandon: { store.transient.discardRename() },
// **Click-away commits** a rename's rule, and the deliberate opposite of the
// placeholder's (04-interactions.md Grammar: "focus loss = commit, matching
// the card window's title field").
onFocusLoss: { store.commitRename() },
onCommitAndOpen: {
let id = card.id
store.commitRename()
if case let .board(openCard) = role { openCard(id) }
}
)
.font(.body)
} else {
Text(card.title.value ?? "Untitled")
.font(.body)
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
.lineLimit(4)
}
}
/// `iconColor`'s tint, or the standard secondary one. Deliberately not `AnyShapeStyle(.primary)`
/// on the fallback path: an uncoloured card icon is chrome, and chrome is secondary the tint
/// exists to make a *hand-coloured* icon stand out from its neighbours.
private var iconTint: AnyShapeStyle {
if let color = Palette.color(for: card.iconColor) {
AnyShapeStyle(color)
} else {
AnyShapeStyle(.secondary)
}
}
/// The one face chip in scope shown only when the card actually has files, and quiet enough
/// that the title still dominates (03-board-ui.md § Card face). The count goes to the
/// accessibility label rather than onto the face: it is useful to know, not to look at.
@ViewBuilder
private var attachmentsIndicator: some View {
if !card.attachments.isEmpty {
Image(systemName: "paperclip")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityLabel("\(card.attachments.count) attachments")
}
}
/// K1 · left edge stripe, painted with the resolved `background` "a card's [colour paints] a
/// stripe along its left edge; the surfaces themselves keep the standard chrome, so coloured
/// title text never sits on a coloured fill" (03-board-ui.md § Styling Capabilities).
///
/// A value that resolves to nothing a typo'd palette name, a malformed hex, a sequence where
/// a scalar belongs draws **no stripe**, and the value stays on disk exactly as written.
/// A `Shape` rather than a sized rectangle so it takes the plate's full height whatever the
/// content does a title wrapping across its full four lines included.
@ViewBuilder
private var accentStripe: some View {
if let color = Palette.color(for: card.background) {
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, bottomLeadingRadius: cornerRadius)
.fill(color)
.frame(width: stripeWidth)
// Decoration only: the whole plate is one click target for selection.
.allowsHitTesting(false)
}
}
// MARK: - Selection and rename plumbing
private var isSelected: Bool {
store.selection.container == role.container && store.selection.ids.contains(card.id)
}
/// Whether an external Finder file drag is hovering **this** card the attach highlight
/// (`DragSession.fileAttachTarget`). The face declares no drop target of its own: the hover is
/// resolved analytically inside the lane's delegate, which is what keeps single-target dispatch
/// to one implementation (`BoardDrops`).
///
/// **Never on the trash side**: "Finder file drops on trash cards are inert" (04-interactions.md
/// The trash), and the trash column's own delegate clears the file highlight rather than
/// proposing one so this is a second, structural statement of the same rule.
private var isFileHovered: Bool {
role.container == .board && drops.session.fileAttachTarget(onBoardRooted: store.rootURL) == card.id
}
/// **Board-only** "everything edit-shaped is disabled on trash selections Rename"
/// (04-interactions.md The trash). No path opens a rename on a trashed card, and this makes a
/// stray one render nothing rather than putting a live field over a card that cannot be edited.
private var isRenaming: Bool {
role.container == .board && store.transient.renameEditor?.targetID == card.id
}
private var draft: Binding<String> {
Binding(
get: { store.transient.renameEditor?.draftTitle ?? "" },
set: { store.transient.updateRenameDraft($0) }
)
}
}
+4 -4
View File
@@ -4,8 +4,8 @@ import SwiftUI
// MARK: - The Edit menu's clipboard row
/// Edit Cut / Copy / Paste (X / C / V) on the board 11-command-nexus.md's Edit row, whose
/// scope is "Board window: cards and lanes in the trash, C copy-out only (card and lane entries),
/// X disabled text editors: standard text clipboard".
/// scope is "Board window: cards and lanes in the trash, C copies out and X/V is the keyboard
/// restore path paste never targets the trash; text editors: standard text clipboard".
///
/// ### Why this is a responder answer and not three menu items
///
@@ -57,13 +57,13 @@ extension View {
/// **Cut items dim in place until paste moves them** (04-interactions.md Clipboard).
///
/// The same reduced opacity a trash row wears while it is being dragged, and for the same reason:
/// The same reduced opacity a trash card wears while it is being dragged out, and for the same reason:
/// the item is still there, still selectable, still the user's it is simply spoken for. A cut
/// item is deliberately *not* lifted out of the layout the way a dragged one is; a Finder-style
/// deferred cut promises the board looks unchanged until the paste lands.
///
/// Membership is read straight off `TransientBoardState.pendingCut`, which is where the rules
/// already live: a reload ejects a tombstoned or vanished member (so a deleted cut card undims by
/// already live: a reload ejects a member that crossed into the trash or vanished (so a deleted cut card undims by
/// itself), and `ClipboardStore` clears the set outright when the cut is consumed or voided.
func cutTreatment(of id: ItemID, in store: BoardStore) -> some View {
opacity(store.transient.pendingCut.ids.contains(id) ? ClipboardTreatment.dimmedOpacity : 1)
+4 -4
View File
@@ -48,7 +48,7 @@ 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 tombstones the dragged card(s), exactly the tombstone"),
/// 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`).
///
/// Kept out of the drop context so the ruling is checkable without a window, and stated once so the
@@ -70,7 +70,7 @@ enum TrashDrop {
/// 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 quasi-lane, so this is belt over braces; it is written down
/// 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.
@@ -78,7 +78,7 @@ enum TrashDrop {
/// 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.
/// - ** 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 tombstone but the original.
/// original stays exactly where it was, and there is nothing to delete but the original.
/// - **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.
@@ -306,7 +306,7 @@ final class DragSession {
// MARK: Where it would land
/// The current proposal, or `nil` when the drag has none a fresh session before the first
/// sample, or one whose target lane was tombstoned in a reload (rule 2 of the re-grounding
/// sample, or one whose target lane vanished in a reload (rule 2 of the re-grounding
/// trio). **Release with no valid proposal cancels.**
private(set) var proposal: DropTarget?
+5 -5
View File
@@ -66,11 +66,11 @@ enum LaneLayoutMath {
/// The unit total a strip of `lanes` divides across the sum of their display units, never
/// below 1 so `standardWidth` cannot be handed a zero divisor for an empty board.
///
/// The caller decides *which* lanes: the strip passes the live ones in snapshot order, because
/// a tombstoned lane renders nowhere on the board (03-board-ui.md § Trash collapses it to a
/// single trash entry) and so consumes none of the window's width.
/// The caller decides *which* lanes: the strip passes the snapshot's, in order. There is no
/// liveness question left to ask "Cards only. Lanes are never trashed" (03-board-ui.md §
/// Trash), so every lane the snapshot holds is a lane on screen consuming its units.
///
/// **`trashUnits` is the quasi-lane's fixed one unit, and it is *only* consumed while shown**
/// **`trashUnits` is the trash column's fixed one unit, and it is *only* consumed while shown**
/// (03-board-ui.md § Trash): the trash "spans a fixed one width unit no `width` frontmatter,
/// and neither the stepper nor the edge drag applies consumed only while shown". Passing it
/// here rather than fabricating a `Lane` for the trash is what keeps that true: there is no lane
@@ -89,7 +89,7 @@ enum LaneLayoutMath {
/// included) an index into `unitCounts`, or `nil` when `x` is not over a lane at all.
///
/// **The gaps and the margins answer `nil` deliberately**, and so does everything past the last
/// lane which is where the trash quasi-lane sits. That is the whole of drag-to-restore's
/// lane which is where the trash column sits. That is the whole of drag-to-restore's
/// "a drop anywhere else is a no-op" (03-board-ui.md § Trash): a drop that does not land
/// squarely on a live lane writes nothing rather than guessing at the nearest one.
///
+8 -425
View File
@@ -473,9 +473,9 @@ struct LaneView: View {
CardFaceView(
store: store,
card: card,
role: .board(openCard: openCard),
marquee: marquee,
drops: drops,
openCard: openCard
drops: drops
)
case let .placeholder(phase):
NewCardStubView(store: store, phase: phase, openCard: openCard)
@@ -487,7 +487,7 @@ struct LaneView: View {
}
}
// "Appear/disappear is scale + fade (cards scale from ~0.8 )"
// (03-board-ui.md § Motion), which is how a create, a delete, a Put Back and
// (03-board-ui.md § Motion), which is how a create, a delete, a restore and
// (m5) a search filter's leavers all reach the masonry. The placeholder wears it
// too: it is the card, one round trip early. Whether any of it *performs* is
// decided upstream at the reload for the real cards (`Motion.reloadAnimates`),
@@ -636,10 +636,10 @@ struct LaneView: View {
return result
}
/// **Tombstoned cards render nowhere**, and neither do the cards of a tombstoned lane the
/// ancestor walk is absolute (01-storage-format.md § Deletion, 02-architecture.md's effective
/// liveness). The lane half of that rule is `BoardView`'s, which never builds a `LaneView` for a
/// tombstoned lane at all.
/// **A deleted card renders nowhere, and needs no rule to**: deletion is a *move* into `.trash/`
/// (03-board-ui.md § Trash, resettled 2026-07-28), so a deleted card has physically left
/// `lane.cards` and the trash column renders it instead. The tombstone era's ancestor walk and
/// effective-liveness predicate are retired with the flag they read.
///
/// **A dragged card renders nowhere either, for as long as the session lasts.** It is lifted out
/// of the resting layout at pickup and stays out until release *whatever the effective operation
@@ -811,423 +811,6 @@ enum LaneSlot: Identifiable {
}
}
// MARK: - The card plate's metrics
/// The card plate's geometry, spelled once because **two views draw it**: the real face
/// (`CardFaceView`) and the placeholder standing in for a card on its way (`NewCardStubView`'s
/// `.awaitingArrival` face). 02-architecture.md TransientBoardState overlays makes the handoff
/// "read as one arrival the placeholder renders at the arriving card's exact geometry/chrome", and
/// exact is only checkable if there is one set of numbers rather than two that happen to agree.
private enum CardFaceMetrics {
/// Shared by the plate, the accent stripe and the selection stroke, so the stripe reads as part
/// of the card's edge rather than a bar laid over it.
static let cornerRadius: CGFloat = 8
/// K1 · left edge stripe (03-board-ui.md § Styling Capabilities). Reserved as padding whether
/// or not a stripe paints, so colouring a card never shifts its title.
static let stripeWidth: CGFloat = 4
/// The plate's inset around its content.
static let contentPadding: CGFloat = 10
/// Between the icon, the title and the attachments chip.
static let rowSpacing: CGFloat = 6
}
// MARK: - Card face
/// The card face: a rounded plate carrying a leading SF Symbol, the title (or its quiet "Untitled"
/// placeholder), a quiet trailing attachments indicator, and a left-edge colour accent stripe
/// (03-board-ui.md § Card face, § Styling Capabilities).
///
/// ### Title-only, deliberately
///
/// **No body excerpt** settled, "the face stays title-only the old 'iterate on the card face
/// later' item is closed with no growth". The only face chip in scope is attachments, "a quiet
/// indicator when the card has files the title dominates", which is why the paperclip is a
/// secondary-tinted caption and not a count pill: the eye should land on the title.
///
/// ### Two lenient fields, two different fallbacks
///
/// `icon` and `iconColor` are hand-written-only on cards (`iconColor` is **schema yes, control
/// no** the app never offers a picker for it, but honours what an author writes). Both degrade
/// rather than fail: an unknown symbol name draws the level default (`ItemSymbol`), and a colour
/// value that resolves to nothing draws the standard secondary tint. `background` degrades a third
/// way to **no stripe at all** because there is no sensible default colour for "the author
/// meant something we can't read", and a wrong colour is worse than none. In every case the bytes
/// on disk are untouched (`Palette`, 01-storage-format.md § Frontmatter).
///
/// ### One presentation, selection styling only
///
/// The face is a top-aligned title row and its two decorations the accent stripe and the
/// selection stroke are shapes in overlays. **A card has one presentation** (resettled
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): selection changes only this
/// face's styling the selection stroke below and never its geometry, so the masonry never
/// reflows on a click and every consumer of a card's drawn frame (the drop model's resting grid,
/// the rubber band's sweep universe) can trust a selection change to leave it untouched. Viewing an
/// attachment's media is the card window's job ( / double-click, 05-card-window.md), not the
/// face's the paperclip chip below is the face's whole attachment story (03-board-ui.md § Card
/// face).
private struct CardFaceView: View {
let store: BoardStore
let card: Card
/// The strip's rubber band the registry this face registers its drawn frame into.
let marquee: MarqueeControl
/// The board window's drop machinery: this face registers its measured height into the geometry
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
let drops: BoardDropContext
let openCard: (ItemID) -> Void
/// The app-wide quick-style recents see `LaneView`'s own note.
@Environment(AppModel.self) private var appModel
/// The plate's corner radius shared with the accent stripe, which rounds its left corners to
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
/// Read from `CardFaceMetrics` rather than spelled here, because the new-card placeholder has to
/// draw this same plate for the create handoff to read as one arrival.
private var cornerRadius: CGFloat { CardFaceMetrics.cornerRadius }
/// K1 · left edge stripe (03-board-ui.md § Styling Capabilities, settled in the pathfinder's
/// treatment shootout).
private var stripeWidth: CGFloat { CardFaceMetrics.stripeWidth }
var body: some View {
titleRow
.frame(maxWidth: .infinity, alignment: .leading)
.padding(CardFaceMetrics.contentPadding)
// Constant, whether or not a stripe paints: every card's text sits on the same grid, so
// colouring a card never shifts its title relative to its uncoloured neighbours.
.padding(.leading, stripeWidth)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
.overlay(alignment: .leading) { accentStripe }
// The selection treatment, which a hovering Finder file drag borrows outright: "the card
// highlights while hovered" (04-interactions.md Drag and drop), and the accent stroke is
// already this face's vocabulary for "this one". The hover draws it a touch heavier so a
// hovered card that is *also* selected still reads as the target.
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(
isSelected || isFileHovered ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
lineWidth: isFileHovered ? 2.5 : 1.5
)
)
// The deferred cut's dim (04-interactions.md Clipboard: "cut items dim in place until paste
// moves them"). Above `contentShape` so the face stays fully clickable while it waits.
.cutTreatment(of: card.id, in: store)
.contentShape(Rectangle())
// **Clicking never edits** (04-interactions.md Selection, a pivot from the pathfinder's
// two-stage Finder rename): one click selects and that is all it does no timer, no
// slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
// Board Rename. The modifier grammar plain replaces, toggles, ranges is
// `SelectionGrammar`'s, reached through the store's one funnel.
.onTapGesture {
store.click(SelectionTarget(id: card.id, kind: .card, container: .board), modifier: .current)
}
// "A fast double-click opens the card window ('s pointer twin)" (04 Selection).
//
// `simultaneousGesture` rather than a second `onTapGesture(count: 2)`, deliberately: a
// second tap recogniser on the same view makes the single click *wait* to see whether a
// second one arrives, and selection must stay instant. Simultaneous means the first click
// of the pair selects and the second opens Finder's own behaviour.
//
// **Plain only.** and double-clicks are selection gestures that happened twice; opening
// a window out from under a range the user is still building would be a surprise.
.simultaneousGesture(TapGesture(count: 2).onEnded {
guard ClickModifier.current == .plain else { return }
openCard(card.id)
})
// **The whole face is the drag surface** (04-interactions.md Drag and drop). `.onDrag`
// beside the tap recognisers above is the click-versus-drag split, the system's own: it holds
// the session off until the pointer really moves, so selecting and opening stay instant.
.onDrag(startCardDrag, preview: { dragReplica })
// The card's height, for the drop model's analytic resting grid. A height is content-driven
// and does not animate under the reflow only positions do, and those are never measured
// (`LaneDropRegistry`).
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
drops.registry.update(height: height, for: card.id)
}
.onDisappear { drops.registry.removeHeight(card.id) }
.marqueeTarget(card.id, kind: .card, container: .board, in: marquee.registry)
.contextMenu { cardMenu }
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
}
}
// MARK: - The card drag
/// Begins the card's system drag session (DRAG-REORDER.md; 04-interactions.md Drag and drop).
///
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order**
/// "lane `order` first, then card `order`", `SelectionGrammar.boardCards`' single definition of
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
///
/// 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 startCardDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let snapshot = store.snapshot
let selection = store.selection
let ids: Set<ItemID> = selection.container == .board
&& selection.ids.contains(card.id)
&& selection.ids.count > 1
? selection.ids
: [card.id]
// Flatten order, and the lane each member currently lives in the folder path's middle
// component.
var lanesByCard: [ItemID: ItemID] = [:]
var titles: [ItemID: String] = [:]
for lane in snapshot.lanes {
for member in lane.cards where ids.contains(member.id) {
lanesByCard[member.id] = lane.id
titles[member.id] = member.title.value
}
}
let ordered = SelectionGrammar.boardCards(in: snapshot).filter { ids.contains($0) }
guard !ordered.isEmpty else { return NSItemProvider() }
let root = store.rootURL
let payload = DragPayload(
boardRoot: root,
kind: .cards,
container: .board,
items: ordered.compactMap { id in
guard let laneID = lanesByCard[id] else { return nil }
return DragPayload.Item(
id: id.rawValue,
folder: root
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(id.rawValue, isDirectory: true)
.path,
title: titles[id]
)
}
)
drops.session.beginCards(
ordered,
folders: payload.folders,
// The dragged items' sizes, frozen at drag start the pickup transition scales the
// replica, and its lingering "last measured frame" would mis-size the shadow and the
// span-cap (03-board-ui.md § Motion).
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
container: .board,
source: store
)
return payload.itemProvider()
}
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
private var dragReplica: some View {
let count = store.selection.container == .board && store.selection.ids.contains(card.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(12)
}
/// A static rendition of the face a drag image is a snapshot, so it carries no gestures, no
/// editor and no geometry observers.
private var replicaFace: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
.foregroundStyle(iconTint)
.imageScale(.medium)
Text(card.title.value ?? "Untitled")
.font(.body)
.lineLimit(4)
.frame(maxWidth: .infinity, alignment: .leading)
attachmentsIndicator
}
.padding(10)
.padding(.leading, stripeWidth)
.frame(width: 220, alignment: .leading)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
.overlay(alignment: .leading) { accentStripe }
}
// MARK: - Context menu
/// Open, Rename, Style, the quick-style recents row, Delete 11-command-nexus.md Context
/// menus' Card row, in its order, complete as of m5.
@ViewBuilder
private var cardMenu: some View {
// Open: Board Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card
// alone "a card window is tied to one card" (11-command-nexus.md), so unlike Style and
// Delete below it, this row never widens to the selection; Open never opens multiple, even
// when the clicked card is part of one. It calls the very `openCard` closure the double-click
// gesture above uses, not `OpenCardCommand`'s mid-edit branches: there is no gesture path from
// a focused inline editor to *this* card's context menu, so there is nothing here to commit
// first only the plain open.
Button("Open") {
openCard(card.id)
}
Divider()
// Rename: Board Rename's exact store path (`BoardRenameCommand`) `beginRename(of:
// currentTitle:)`, seeded with the card's live title. The menu-bar item additionally requires
// this card to be the *sole* selection; a context menu already names its target by where it
// was invoked, so standard macOS practice it acts on the clicked card outright.
Button("Rename") {
store.transient.beginRename(of: card.id, currentTitle: card.title.value)
}
.disabled(!store.acceptsBoardMutations)
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
Divider()
// Delete: File Delete's exact store path (`store.delete`, `TrashCommands`'s twin), on the
// widened target set below (`targetIDs`) the successor-selection rule is `delete(_:)`'s own,
// so this row gets it for free.
Button("Delete") {
store.delete(targetIDs)
}
.disabled(!store.acceptsBoardMutations)
}
/// What this card's menu acts on: the whole selection when this card is part of it, else this card
/// alone standard macOS context-menu targeting, shared by Style (`styleTarget`) and Delete
/// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked.
private var styleTarget: StyleTarget {
guard store.selection.container == .board, store.selection.ids.contains(card.id) else {
return .items([card.id])
}
return .items(store.selection.ids)
}
/// Delete's target set the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
/// because `store.delete(_:)` takes one directly (the context-menu targeting rule, reused here
/// on the live side).
private var targetIDs: Set<ItemID> {
guard store.selection.container == .board, store.selection.ids.contains(card.id) else {
return [card.id]
}
return store.selection.ids
}
// MARK: - Title row
private var titleRow: some View {
HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) {
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
.foregroundStyle(iconTint)
.imageScale(.medium)
titleOrEditor
// The title takes the row's width so the indicator sits hard against the trailing
// edge and so the rename field fills the same span the title occupied.
.frame(maxWidth: .infinity, alignment: .leading)
attachmentsIndicator
}
}
/// The title, or the rename editor when this card is the rename target. Unchanged from the
/// stub this face replaces: the four exits and their store calls are 04-interactions.md
/// Grammar's, stated once in `InlineTitleField`.
@ViewBuilder
private var titleOrEditor: some View {
if isRenaming {
InlineTitleField(
text: draft,
prompt: "Card title",
onCommit: { store.commitRename() },
onAbandon: { store.transient.discardRename() },
// **Click-away commits** a rename's rule, and the deliberate opposite of the
// placeholder's (04-interactions.md Grammar: "focus loss = commit, matching
// the card window's title field").
onFocusLoss: { store.commitRename() },
onCommitAndOpen: {
let id = card.id
store.commitRename()
openCard(id)
}
)
.font(.body)
} else {
Text(card.title.value ?? "Untitled")
.font(.body)
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
.lineLimit(4)
}
}
/// `iconColor`'s tint, or the standard secondary one. Deliberately not `AnyShapeStyle(.primary)`
/// on the fallback path: an uncoloured card icon is chrome, and chrome is secondary the tint
/// exists to make a *hand-coloured* icon stand out from its neighbours.
private var iconTint: AnyShapeStyle {
if let color = Palette.color(for: card.iconColor) {
AnyShapeStyle(color)
} else {
AnyShapeStyle(.secondary)
}
}
/// The one face chip in scope shown only when the card actually has files, and quiet enough
/// that the title still dominates (03-board-ui.md § Card face). The count goes to the
/// accessibility label rather than onto the face: it is useful to know, not to look at.
@ViewBuilder
private var attachmentsIndicator: some View {
if !card.attachments.isEmpty {
Image(systemName: "paperclip")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityLabel("\(card.attachments.count) attachments")
}
}
/// K1 · left edge stripe, painted with the resolved `background` "a card's [colour paints] a
/// stripe along its left edge; the surfaces themselves keep the standard chrome, so coloured
/// title text never sits on a coloured fill" (03-board-ui.md § Styling Capabilities).
///
/// A value that resolves to nothing a typo'd palette name, a malformed hex, a sequence where
/// a scalar belongs draws **no stripe**, and the value stays on disk exactly as written.
/// A `Shape` rather than a sized rectangle so it takes the plate's full height whatever the
/// content does a title wrapping across its full four lines included.
@ViewBuilder
private var accentStripe: some View {
if let color = Palette.color(for: card.background) {
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, bottomLeadingRadius: cornerRadius)
.fill(color)
.frame(width: stripeWidth)
// Decoration only: the whole plate is one click target for selection.
.allowsHitTesting(false)
}
}
// MARK: - Selection and rename plumbing
private var isSelected: Bool {
store.selection.container == .board && store.selection.ids.contains(card.id)
}
/// Whether an external Finder file drag is hovering **this** card the attach highlight
/// (`DragSession.fileAttachTarget`). The face declares no drop target of its own: the hover is
/// resolved analytically inside the lane's delegate, which is what keeps single-target dispatch
/// to one implementation (`BoardDrops`).
private var isFileHovered: Bool {
drops.session.fileAttachTarget(onBoardRooted: store.rootURL) == card.id
}
private var isRenaming: Bool {
store.transient.renameEditor?.targetID == card.id
}
private var draft: Binding<String> {
Binding(
get: { store.transient.renameEditor?.draftTitle ?? "" },
set: { store.transient.updateRenameDraft($0) }
)
}
}
// MARK: - The new-card placeholder
/// The card being created, drawn as a pseudo-card in the masonry flow at standard card width
@@ -1371,7 +954,7 @@ private struct NewCardStubView: View {
/// and then the field disappears, which also fires the focus-loss handler an instant later. The
/// store's `commitRename`/`commitPlaceholder` and the transient state's `discard` all no-op against
/// an editor that is already closed, so the overlap costs nothing.
private struct InlineTitleField: View {
struct InlineTitleField: View {
@Binding var text: String
let prompt: String
+3 -3
View File
@@ -8,7 +8,7 @@
/// > a multi-selection anchors at its last member in flatten order (lane `order`, then card
/// > `order`, the multi-drag order; the same anchor serves paste): creation follows the last
/// > selected card, or appends to the last selected lane; with nothing selected or a
/// > **tombstoned** selection, which never anchors creation the **last-active lane** the lane
/// > **trash** selection, which never anchors creation the **last-active lane** the lane
/// > that most recently held selection or a creation in this window session falling back to the
/// > first lane. **Zero-lane board**: card creation disable[s] via menu validation until a
/// > lane exists.
@@ -59,7 +59,7 @@ enum NewCardTarget {
if let anchor = flattenAnchor(selection: selection, snapshot: snapshot) {
return anchor
}
// Nothing selected, a tombstoned selection, or a stale one the ids name nothing the board
// Nothing selected, a trash selection, or a stale one the ids name nothing the board
// renders, a selection the next reload will drop. Falls through rather than refusing: the
// user pressed N and the board has lanes. The target is then the lane that most recently
// held selection or a creation, and the first lane when there is no such lane (or it has
@@ -81,7 +81,7 @@ enum NewCardTarget {
/// rule 04 says they share.
///
/// `nil` covers the three cases that anchor nothing, which the callers then answer their own way:
/// an empty selection, a **tombstoned** one ("a tombstoned selection never anchors paste",
/// an empty selection, a **trash** one ("a trash selection never anchors paste",
/// settled and "a trashed card's live disk-lane never leaks in as 'the selected card's lane'",
/// which falls out of never looking at the trashed side at all), and a stale one whose ids name
/// nothing the board renders.
+2 -2
View File
@@ -4,7 +4,7 @@
/// The two rules verbatim, and each clause's branch below:
///
/// > Paste lands after the anchor card (or appends to a selected lane); a multi-selection anchors at
/// > its last member in flatten order the N target rule's shared anchor. **A tombstoned
/// > its last member in flatten order the N target rule's shared anchor. **A trash
/// > selection never anchors paste**: V stays enabled and behaves exactly as with nothing selected
/// > a card payload appends to the last-active lane, a lane payload lands at the board's right end.
///
@@ -68,7 +68,7 @@ enum PasteTarget {
guard let anchor = NewCardTarget.flattenAnchor(selection: selection, snapshot: snapshot),
let position = lanes.firstIndex(where: { $0.id == anchor.laneID })
else {
// Nothing selected, a tombstoned selection, or a stale one: the right end.
// Nothing selected, a trash selection, or a stale one: the right end.
return lanes.count
}
return position + 1
+43 -9
View File
@@ -52,17 +52,36 @@ final class TrashConfirmations {
/// The staging itself lives on the store (`BoardStore.deleteSelection`), so this is the alert and
/// nothing else the two can never disagree about which write a performs.
func requestDelete(in store: BoardStore) {
guard store.selection.container == .trash, store.purgeIsUnrecoverable else {
guard store.selection.container == .trash else {
store.deleteSelection()
return
}
requestTrashDelete(of: store.selection.ids, in: store)
}
/// The **permanent** half of that staging, aimed at an explicit set the trash card's
/// context-menu Delete (11-command-nexus.md Context menus' Trash cards row).
///
/// Its own entry point because a context menu names its target by where it was invoked, not by
/// what is selected: right-clicking a trash card while a *board* selection stands must purge the
/// clicked card, and a path that re-read `selection` would resolve those ids in the wrong
/// container and silently do nothing.
///
/// Same alert, same rule: it "confirms exactly where the loss is real", so
/// `purgeIsUnrecoverable` decides and where it does not, the purge runs straight through, which
/// is the same shrug Delete Immediately gives on a board that keeps history.
func requestTrashDelete(of ids: Set<ItemID>, in store: BoardStore) {
guard store.purgeIsUnrecoverable else {
store.deleteTrashCards(ids)
return
}
guard let prompt = TrashModel.purgePrompt(
for: store.selection.ids,
for: ids,
in: .trash,
snapshot: store.snapshot,
unrecoverable: true
) else { return }
pending = Pending(prompt: prompt, action: .deleteTrashCards(store.selection.ids))
pending = Pending(prompt: prompt, action: .deleteTrashCards(ids))
}
/// Raises Delete Immediately's alert **or purges outright** where the loss is not real.
@@ -70,6 +89,10 @@ final class TrashConfirmations {
/// The mode check is the one thing that decides between the two, and it lives on the store as a
/// named predicate (`BoardStore.purgeIsUnrecoverable`) so the git milestone changes one
/// expression rather than three call sites.
///
/// Its one caller is File Delete Immediately, which passes the selection's own ids which is
/// what makes reading `store.selection.container` for the prompt correct here and wrong for a
/// context menu (`requestTrashDelete` above exists for exactly that difference).
func requestPurge(of ids: Set<ItemID>, in store: BoardStore) {
guard store.purgeIsUnrecoverable else {
store.deleteImmediately(ids)
@@ -177,15 +200,26 @@ struct TrashCommands: View {
return TrashModel.canDeleteImmediately(selection: store.selection, in: store.snapshot)
}
/// **Trash shown and non-empty** (11-command-nexus.md's own scope for this row) where
/// "non-empty" reads `.trash/` itself and never the filtered view (03-board-ui.md § Trash: "menu
/// validation's non-empty reads `.trash/`, not the filtered view").
private var canEmptyTrash: Bool {
store?.canEmptyTrash == true
}
}
extension BoardStore {
/// **Trash shown and non-empty** (11-command-nexus.md's own scope for the Empty Trash row)
/// where "non-empty" reads `.trash/` itself and never the filtered view (03-board-ui.md § Trash:
/// "menu validation's non-empty reads `.trash/`, not the filtered view", so a search that hides
/// every trash card leaves the command enabled and its confirmation still names the true count).
///
/// The visibility clause is 04-interactions.md's, stated for the whole column: "hidden, it is
/// invisible to every gesture".
private var canEmptyTrash: Bool {
guard let store, store.acceptsBoardMutations, store.transient.isTrashVisible else { return false }
return !store.snapshot.trash.isEmpty
///
/// On the store rather than private to the menu row so the validation can be pinned without a
/// menu (`TrashModel`'s own reason for being a pure function of a snapshot).
var canEmptyTrash: Bool {
guard acceptsBoardMutations, transient.isTrashVisible else { return false }
return !snapshot.trash.isEmpty
}
}
+114 -289
View File
@@ -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) }
}
}