The board window's accessibility tree per DESIGN/10: lanes are containers labeled "<title>, lane, N cards" (filter-aware count = renderedCards, the badge's own collection); cards are one flattened element each — label = title or the untitled placeholder, value = attachment count + "cut, pending paste", selection via trait; face icon, stripe, and paperclip are decorative and hidden. Masonry never leaks into traversal: slots carry order-keyed accessibilitySortPriority, so a wide lane reads by card order, not column-major. Lane titles carry the heading trait for the rotor. VO-Space is the ⌘-click analogue routed through the existing BoardStore.click funnel (SelectionGrammar stays the single answer for toggle and container-boundary rules) — cards and lane headers both. Context-menu rows double as custom accessibility actions, each calling the same private method as its menu row so the surfaces cannot drift; trash cards expose Delete and Reveal in Finder and never Open. The trash column is pinned last via sort priority 0, its label/value re-routed through the new AccessibilityPhrases seam; toggling trash visibility posts a one-line announcement from the store seam (both command faces). The invisible lane-resize drag strip leaves the tree — the stepper and menu items are the accessible width path. AccessibilityPhrases is the pure vocabulary seam (labels, values, plural folding shared with TrashModel.phrase), pinned by its own test suite. Both schemes build; 1466 unit tests green. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
372 lines
20 KiB
Swift
372 lines
20 KiB
Swift
import AppKit
|
|
import Observation
|
|
import SwiftUI
|
|
|
|
// MARK: - TrashLaneView
|
|
|
|
/// The trash column: the trailing, visually distinct column the board's `.trash/` cards live in
|
|
/// (03-board-ui.md § Trash, resettled 2026-07-28 — the materialized trash).
|
|
///
|
|
/// ### Ordinary cards, in a column that says where they are
|
|
///
|
|
/// **Its contents are ordinary cards in a special place**, so there is nothing to derive and nothing
|
|
/// 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,
|
|
/// so there is no `Lane` value for any of those to act on);
|
|
/// - 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. 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
|
|
///
|
|
/// **"Dropping a live card on the shown trash deletes it"** (04-interactions.md ▸ The trash) — the
|
|
/// drag becomes the pointer's delete gesture, and release moves the dragged card(s) into `.trash/`.
|
|
/// So the column declares an `onDrop` (`TrashDropDelegate`), and it is the narrowest one on the
|
|
/// board: a board card drag from **this** board, unmodified. It diverges from every other drop in one
|
|
/// way, and the ranks are what make the divergence honest: **the shadow always takes the topmost
|
|
/// row**, because every arrival mints a rank above the current top.
|
|
///
|
|
/// The drag *out* is the restore, and it is deliberately not special: a trash card's drag is an
|
|
/// ordinary `.cards` session in the `.trash` container (`CardFaceView.startTrashCardDrag`), and
|
|
/// `BoardDropContext.commitDrop` hands it to the same `moveCards`/`copyCards`/`receiveCards` every
|
|
/// board card uses. "Restoring is an ordinary move out … there is no restore-specific machinery and
|
|
/// no Put Back" (03 § Trash).
|
|
///
|
|
/// **Finder file drops stay inert** — "Finder file drops on trash cards are inert" (▸ The trash) —
|
|
/// and say so twice: the delegate clears the file highlight over the column, and the face's hover
|
|
/// 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." 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.
|
|
///
|
|
/// ### Accessibility
|
|
///
|
|
/// "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 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
|
|
/// **trash** container (`DragSession`, 04-interactions.md ▸ The trash).
|
|
let drops: BoardDropContext
|
|
|
|
/// 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 card face registers its frame into the same registry.
|
|
let marquee: MarqueeControl
|
|
|
|
/// 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
|
|
|
|
/// The lane plate's corner radius — matched to `LaneView`'s so the column reads as a sibling of
|
|
/// the lanes rather than as a different kind of object.
|
|
private let cornerRadius: CGFloat = 10
|
|
|
|
/// 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
|
|
cards
|
|
}
|
|
.background(
|
|
RoundedRectangle(cornerRadius: cornerRadius)
|
|
.fill(.quaternary.opacity(0.35))
|
|
)
|
|
// **The delete gesture's drop target**, over the whole column — the header included, since
|
|
// the ruling is "dropping a live card on the shown trash", not on one of its rows. It
|
|
// declares every type the board's other targets do, because single-target dispatch hands the
|
|
// 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(AccessibilityPhrases.trashLabel)
|
|
// The **rendered** count, like a lane's: the shown trash's cards participate in the filter,
|
|
// so a query narrows the spoken count exactly as it narrows the badge and the column itself.
|
|
.accessibilityValue(AccessibilityPhrases.trashValue(cards: renderedCards.count))
|
|
}
|
|
|
|
/// The cards the column shows.
|
|
///
|
|
/// **Shown, the trash's cards "participate in the filter exactly like any other card"**
|
|
/// (03-board-ui.md § Trash — "the point of the pivot"), so the search predicate narrows this
|
|
/// collection exactly as it narrows `LaneView.renderedCards`, and the count badge follows for
|
|
/// free because it reads this same value. Hidden, the column renders nothing and registers
|
|
/// nothing, so "hidden trash is invisible to search" needs no code at all.
|
|
///
|
|
/// **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
|
|
|
|
/// Where the delete gesture's shadow run opens in this column — always the topmost row — or `nil`
|
|
/// when no proposal names the trash, which is every other moment of the app's life.
|
|
private var proposal: Int? {
|
|
drops.session.trashProposal(onBoardRooted: store.rootURL)
|
|
}
|
|
|
|
/// 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 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 = 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))
|
|
return result
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
/// Dimmed and hatched, with the trash symbol, the stable "Trash" title and a count badge
|
|
/// (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. 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) {
|
|
Image(systemName: "trash")
|
|
.foregroundStyle(.secondary)
|
|
.imageScale(.medium)
|
|
Text("Trash")
|
|
.font(.headline)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
countBadge
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 8)
|
|
.background {
|
|
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius)
|
|
.fill(.quaternary.opacity(0.5))
|
|
.overlay {
|
|
DiagonalHatch()
|
|
.stroke(.quaternary, lineWidth: 1)
|
|
.clipShape(UnevenRoundedRectangle(
|
|
topLeadingRadius: cornerRadius,
|
|
topTrailingRadius: cornerRadius
|
|
))
|
|
}
|
|
}
|
|
// 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("\(renderedCards.count)")
|
|
.font(.caption)
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 1)
|
|
.background(Capsule().fill(.quaternary))
|
|
}
|
|
|
|
// MARK: - Cards
|
|
|
|
/// 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 cards: some View {
|
|
ScrollViewReader { proxy in
|
|
scrollableCards
|
|
.onChange(of: store.transient.selectionHead) { _, head in
|
|
guard let head, renderedCards.contains(where: { $0.id == head }) else { return }
|
|
proxy.scrollTo(TrashSlot.identity(of: head))
|
|
}
|
|
}
|
|
}
|
|
|
|
private var scrollableCards: some View {
|
|
ScrollView(.vertical) {
|
|
// **`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 deletions, and Empty Trash… exists.
|
|
MasonryLayout(columns: 1, spacing: cardSpacing) {
|
|
ForEach(Array(slots.enumerated()), id: \.element.id) { index, slot in
|
|
Group {
|
|
switch slot {
|
|
case let .card(card):
|
|
CardFaceView(
|
|
store: store,
|
|
card: card,
|
|
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). 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: LaneDropRegistry.nominalCardHeight)
|
|
}
|
|
}
|
|
// A slot is a card, so it arrives and leaves in the card's dialect — a delete
|
|
// files one in, a restore or a purge takes one out, and both halves of that pair
|
|
// should read alike from either side of the strip. The transaction is the
|
|
// reload's, like the lanes' (`Motion.reloadAnimates`).
|
|
.transition(Motion.cardTransition(reduced: reduceMotion))
|
|
// `order`-keyed traversal, `LaneView`'s rule on the trash side. The column is
|
|
// one masonry column, so geometry and `order` agree here and the priority is
|
|
// belt over braces — written anyway because the *reason* it agrees is the
|
|
// column's fixed one width unit, which is a layout fact rather than a
|
|
// traversal guarantee, and a two-unit trash would silently read column-major.
|
|
.accessibilitySortPriority(Double(slots.count - index))
|
|
// The scroll target — `LaneView`'s rule, and outermost for its reason.
|
|
.id(slot.id)
|
|
}
|
|
}
|
|
// The reflow that opens the topmost row for the shadow, keyed on **the drop proposal and
|
|
// nothing else** (03-board-ui.md § Motion's narrow keys) — the trash column's own copy of
|
|
// 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 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 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 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: - What the column lays out
|
|
|
|
/// One slot of the trash column — a card, or one slot of the delete gesture's run.
|
|
///
|
|
/// `LaneSlot`'s smaller sibling — smaller by exactly one case, because nothing is ever created in the
|
|
/// trash so there is no placeholder to stand in for it — and keyed on the same principle: a slot's id
|
|
/// decides whether the echo reload reads as a *swap* or as a removal and an insertion.
|
|
private enum TrashSlot: Identifiable {
|
|
|
|
/// A card the snapshot's trash already holds.
|
|
case card(Card)
|
|
|
|
/// One of the drag's N shadows, holding the topmost rows open (04-interactions.md ▸ The trash).
|
|
case shadow(index: Int)
|
|
|
|
var id: String {
|
|
switch self {
|
|
case let .card(card): Self.identity(of: card.id)
|
|
// Constant per position in the run, so a run that grows or shrinks animates as slots rather
|
|
// than blinking (`LaneSlot`'s rule).
|
|
case let .shadow(index): "shadow:\(index)"
|
|
}
|
|
}
|
|
|
|
/// A card slot's id, spelled once so `scrollTo` and the slot cannot disagree about what the
|
|
/// scroll reader is looking for (`LaneSlot.identity(of:)`).
|
|
static func identity(of item: ItemID) -> String { "trash:\(item.rawValue)" }
|
|
}
|
|
|
|
// MARK: - The hatch
|
|
|
|
/// Diagonal hatching for the trash header — the "dimmed/hatched" treatment 03-board-ui.md asks for,
|
|
/// drawn rather than imaged so it takes whatever width the division gives the column.
|
|
///
|
|
/// The lines start a full header-height to the left of the leading edge so the first stroke reaches
|
|
/// the top-left corner instead of beginning partway across.
|
|
private struct DiagonalHatch: Shape {
|
|
var spacing: CGFloat = 7
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
var path = Path()
|
|
guard spacing > 0, rect.height > 0 else { return path }
|
|
var x = rect.minX - rect.height
|
|
while x < rect.maxX {
|
|
path.move(to: CGPoint(x: x, y: rect.maxY))
|
|
path.addLine(to: CGPoint(x: x + rect.height, y: rect.minY))
|
|
x += spacing
|
|
}
|
|
return path
|
|
}
|
|
}
|