import AppKit import SwiftUI // MARK: - The trashed lane's row /// A **trashed lane**, as the one row the trash column gives it (03-board-ui.md § Trash, re-ruled /// 2026-07-29 — "Lanes trash too"): /// /// > A trashed lane is an opaque unit: one distinct dimmed row showing its title and held-card count /// > ("Doing — 5 cards"), no styling accents, never expandable; its cards are invisible to search and /// > not individually addressable — it restores whole or purges whole. /// /// ### Why it is not a `CardFaceView` role /// /// The card face has one axis — which container it is drawn in — and the trash side of it draws *a /// card*: its stripe, its icon tint, its attachments chip, its four-line title. A trashed lane has /// none of that to draw and must not appear to: the design asks for a **distinct** dimmed row /// precisely so the column never reads as "these are all cards", and 03's "no styling accents" /// forbids the stripe the face exists to paint. A third `CardFaceRole` would be a role whose every /// branch was an absence, over a `Card` this view does not have (`TrashedLane` is deliberately not a /// `Lane` — see its own note). So this is its own small view, and what it shares with the face — the /// selection stroke's vocabulary, the drag dim, the marquee registration, the container-scoped click /// funnel — it shares by calling the same store paths and the same modifiers. /// /// ### What it does *not* have, and why each absence is a ruling /// /// - **No expansion, no cards** — "never expandable; its cards are … not individually addressable". /// The subtree is not in the snapshot at all, so there is nothing here that could be drawn even by /// accident (`TrashedLane`). /// - **No styling accents** — no left stripe, no top band, no icon tint, whatever the lane's /// `background`/`icon`/`icon-color` say. The bytes ride along untouched for the restore; the row /// simply does not read them. /// - **No rename, no Style…, no width** — "everything edit-shaped is disabled on trash selections … /// and lane width ops on lane rows" (04-interactions.md ▸ The trash). Absences rather than /// disabled modifiers, `CardFaceRole`'s rule: the menu-bar items answer the same way through /// `renameTarget`/`boardStyleTarget`/`LaneWidthCommands`, all of which require a `.board` /// selection. /// - **No Finder file drop** — the column's own delegate clears the file highlight (`TrashDrop`). struct TrashLaneRowView: View, Equatable { let store: BoardStore let lane: TrashedLane /// The window's purge-alert host — this row's Delete is the **permanent** one, so it goes /// through the same confirmation the menu bar's does (03-board-ui.md § Trash; `CardFaceView` /// carries the same collaborator for the same reason). let confirmations: TrashConfirmations /// The board window's drop machinery: this row's drag is a **lane** session in the `.trash` /// container — the same payload a live lane header produces, which is what makes drag-restore /// ordinary (04-interactions.md ▸ The trash ▸ Drag-to-restore). let drops: BoardDropContext /// The strip's rubber band. The row registers its frame like a card face does — **not** so the /// band can sweep it (it never selects lane rows) but because the same registry is the arrows' /// geometry and the band's begin guard (`MarqueeTargetRegistry`). let marquee: MarqueeControl /// Whether this row is in the trash-side selection — a parameter for `CardFaceView.isSelected`'s /// reason, and resolved by the same hoisted read in `TrashLaneView.scrollableCards` that feeds /// the faces beside it, so a row and a card in one column can never disagree about what is /// selected. let isSelected: Bool /// The size of the selection this row belongs to — **1 when unselected**, normalized by the /// parent. The drag replica's fan and count badge are its only reader (`dragReplica`). let selectedCount: Int /// Increase Contrast, for the plate's borders — `CardFaceView`'s rule, so a selected row and a /// selected card wear the same ring at the same strength. @Environment(\.colorSchemeContrast) private var contrast /// The board's ruler (`BoardZoom`) — `CardFaceView`'s rule again, so a trash row and a card face /// are drawn on one scale. @Environment(\.boardZoom) private var zoom private var pointSize: CGFloat { zoom.bodyPointSize } /// The card plate's radius: the rows sit in one column and a row with a different corner would /// read as a different *kind of surface* rather than as a different kind of row. What /// distinguishes it is the dimming and the wording, per 03. private var cornerRadius: CGFloat { BoardMetrics.cardCornerRadius(bodyPointSize: pointSize) } /// This row's drawn width — the drag replica's, measured for `CardFaceView`'s reason. @State private var measuredWidth: CGFloat = 0 /// The row's rebuild gate — `CardFaceView.==`'s twin, one level over: the lane value (a /// `TrashedLane` is `Equatable` down to its title and held-card count), the two selection figures /// the column resolves, the store and the confirmation host by identity, and the band and the /// drop machinery by their own equivalence tests, which exist because the window rebuilds both /// structs — closures and all — on every body pass. /// /// **This row had no gate until the selection came down as a parameter, and that is the reason it /// has one now.** Before, the column's body re-ran only when the trash's contents changed, so /// there was nothing worth suppressing; hoisting the selection read into /// `TrashLaneView.scrollableCards` made that body re-run on every selection change on the trash /// side, and without a gate here every row would rebuild on each one — the very shape the change /// exists to remove (`CardFaceView.isSelected`). Applied through `.equatable()` at the call site, /// exactly as the card face beside it is. /// /// Deliberately NOT compared: `@State` (per-identity, preserved across updates anyway) and /// `@Environment` values (SwiftUI invalidates on those itself). And, as with the face, the gate /// has no say over this body's remaining Observation reads — `store.transient.pendingCut`, /// `drops.session.isDragging` — which invalidate it directly. nonisolated static func == (lhs: TrashLaneRowView, rhs: TrashLaneRowView) -> Bool { lhs.lane == rhs.lane && lhs.isSelected == rhs.isSelected && lhs.selectedCount == rhs.selectedCount && lhs.store === rhs.store && lhs.confirmations === rhs.confirmations && lhs.marquee.isEquivalent(to: rhs.marquee) && lhs.drops.isEquivalent(to: rhs.drops) } var body: some View { row .contextMenu { menu } // The menu's rows as VoiceOver custom actions — "its actions are the same Delete / // Reveal in Finder" (10-accessibility.md ▸ Trash lane), each calling the same method its // menu row does so the two surfaces cannot drift. .accessibilityActions { actions } } /// Title and held-card count, dimmed — "one distinct dimmed row showing its title and held-card /// count" (03-board-ui.md § Trash). /// /// The dimming is the *whole* row's, secondary throughout: this is a thing that has been thrown /// away, and the column's cards beside it are the live-looking ones. **State is never /// colour-alone** (10-accessibility.md): the row also says "deleted lane" in its accessibility /// label and carries the lane glyph, so the distinction survives without the wash. private var row: some View { HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) { // The lane glyph, at the level default and **never the lane's own `icon`** — "no styling // accents" (03 § Trash). It says *lane*, which is the one thing the row must not be // mistaken about. Image(systemName: ItemSymbol.lane) .foregroundStyle(.secondary) .imageScale(.medium) Text(lane.title.value ?? AccessibilityPhrases.untitled) .boardFont(.body) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) heldCount } .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize)) .frame(maxWidth: .infinity, alignment: .leading) // Quieter than a card's plate, which is what "dimmed" is here: the cards beside it keep the // ordinary card plate (`BoardSurface.cardPlate`), and this row sits a step further back. .background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.tertiary)) .overlay( RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder(plateStroke, lineWidth: plateStrokeWidth) ) // The deferred cut's dim — "⌘X on a trashed lane row + ⌘V after the anchor lane restores" // (04-interactions.md ▸ The trash ▸ Clipboard), so a pending cut has to show here exactly as // it does on a card. .cutTreatment(of: lane.id, in: store) // Dragged out, the row stays visible and dims: a restore is not a removal until the write // lands (`TrashLaneView.renderedRows`, the card side's rule). .opacity(drops.session.isDragging(lane.id) ? ClipboardTreatment.dimmedOpacity : 1) .contentShape(Rectangle()) // **One flattened element, never a container** — "A trashed lane is one flattened opaque // element — '⟨title⟩, deleted lane, N cards' — never a container: its cards are not in the // tree" (10-accessibility.md ▸ Trash lane). `.ignore` unconditionally: nothing in this row is // ever a control, because nothing here is editable. .accessibilityElement(children: .ignore) .accessibilityLabel(AccessibilityPhrases.trashedLaneLabel(title: lane.title.value, cards: lane.heldCards)) // The cut-pending phrase, on the row's value — the card element's rule, minus the attachment // count an opaque unit has no answer for. .accessibilityValue(AccessibilityPhrases.cardValue( attachments: 0, isCutPending: store.transient.pendingCut.ids.contains(lane.id) )) .accessibilityAddTraits(isSelected ? [.isSelected] : []) // VO-Space toggles, through the same `BoardStore.click` funnel the pointer uses — the // ⌘-click analogue, one uniform rule whatever the element's kind (10-accessibility.md). .accessibilityAction { toggleSelection() } // The click grammar, in the **trash** container at the **lane** level: "lane rows join by // click grammar" (04-interactions.md ▸ The trash), and the kind travelling with the click is // what makes a ⌘-click from a trash card degrade to a replace rather than mixing kinds. // // `togglesOnRepeat` is deliberately false: it is the *live* lane header's behaviour // ("click again to unselect"), and this row is not a lane header — it is a row in a column, // and Finder does not deselect a row by clicking it twice. .onTapGesture { store.click(SelectionTarget(id: lane.id, kind: .lane, container: .trash), modifier: .current) } // **Double-click does nothing** — "a trashed lane row never expands" (03 § Trash), which is // the absence of a recogniser rather than a gesture that fires and refuses. .onDrag(startDrag, preview: { dragReplica }) .onGeometryChange(for: CGSize.self) { $0.size } action: { measuredWidth = $0.width } .marqueeTarget(lane.id, kind: .lane, container: .trash, in: marquee.registry) } /// "Doing — 5 cards": the row's other half (03-board-ui.md § Trash), counted at load and never /// derived from a walked subtree (`TrashedLane.heldCards`). /// /// A plain caption rather than the lane header's capsule badge: the badge is a live lane's /// furniture, and this row is deliberately not one. private var heldCount: some View { Text(AccessibilityPhrases.cardCount(lane.heldCards)) .boardFont(.caption) .monospacedDigit() .foregroundStyle(.tertiary) // Folded into the flattened element's label above, like the card face's chips. .accessibilityHidden(true) } // MARK: - The drag out /// The row's drag — **the restore**, and deliberately the same session a live lane header starts /// (04-interactions.md ▸ The trash: "Drag-to-restore follows the locality model: … a trashed lane /// row onto its own board's strip — is an ordinary move to the drop position. Dropped on /// *another* board it follows the copy default … ⌘-drag forces the true cross-board /// restore-move"). /// /// A `.lanes` session in the `.trash` container, carrying the same `DragPayload` shape /// `LaneView.startLaneDrag` produces with the folder pointing into `.trash/`. Everything that /// makes it behave — which strip slot it proposes, which operation the badge shows, what the /// release writes — is then the ordinary lane machinery (`DragLocality.operation`, /// `BoardDropContext.commitDrop`). /// /// **Multi-drag carries the whole trash-side lane selection**, in the column's own order, and a /// row outside the selection drags alone — the card face's targeting rule at the other level. /// /// **A kind-blind selection can span both kinds, and the session says so** (04-interactions.md ▸ /// The trash, ruled 2026-07-31): a `.lanes` session cannot carry the cards in it, so rather than /// drop them silently the flag travels and the release refuses with the notice /// (`DragSession.mixesKinds`) — "pickup is allowed — the selection is legal". /// /// Refused under the read-only lock and while an inline editor is focused, like every other /// mutating gesture; a refusal is an item provider carrying nothing, so no session begins. private func startDrag() -> NSItemProvider { guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() } let ids = draggedIDs let rows = store.snapshot.trashedLanes.filter { ids.contains($0.id) } guard !rows.isEmpty else { return NSItemProvider() } let mixesKinds = SelectionGrammar.mixesKinds( ItemReferenceSet(ids: ids, container: .trash), in: store.snapshot) let root = store.rootURL let payload = DragPayload( boardRoot: root, kind: .lanes, container: .trash, items: rows.map { DragPayload.Item( id: $0.id.rawValue, folder: ItemPath.trashLane($0.id).folder(under: root).path, title: $0.title.value ) } ) drops.session.beginLanes( rows.map(\.id), folders: payload.folders, // **One unit per row.** A lane's width is layout the *opaque unit does not carry* // (`TrashedLane` models the row and nothing else), so the shadow spans the strip's // standard width; the restored lane then draws at whatever `width` its untouched // frontmatter still says, on the next reload. units: rows.map { _ in 1 }, // **The session's container is this row's**, matching the payload's — which is what // routes the release to `BoardStore.restoreLanes` (the move *out* of `.trash/`) rather // than to `moveLanes`' strip permutation, and what the mixed-payload exit keys on. container: .trash, source: store, mixesKinds: mixesKinds ) return payload.itemProvider() } /// What travels: the whole selection when this row is in it, else this row alone — container- /// and kind-scoped, since the selection is homogeneous on both axes. private var draggedIDs: Set { let selection = store.selection guard selection.container == .trash, selection.ids.contains(lane.id), selection.ids.count > 1 else { return [lane.id] } return selection.ids } /// The image under the cursor: this row at its drawn width, fanned when the whole selection /// rides along — `CardFaceView.dragReplica`'s treatment, so a restore looks like every other /// drag on the board. /// /// Off `selectedCount` rather than the store, for the face's reason exactly: `.onDrag`'s preview /// builder is non-escaping, so a read here happens at body time and would keep this row /// subscribed to every selection change on the board. The `max` is belt over the parent's braces, /// `CardFaceView.dragReplica`'s note again. private var dragReplica: some View { let count = max(1, selectedCount) return ZStack { if count > 2 { replicaFace.offset(x: 10, y: 10).opacity(0.45) } if count > 1 { replicaFace.offset(x: 5, y: 5).opacity(0.7) } replicaFace } .overlay(alignment: .topTrailing) { DragCountBadge(count: count) } .padding(BoardMetrics.replicaPadding(bodyPointSize: pointSize)) } /// A static rendition of the row — no gestures, no geometry observer, and crucially no marquee /// registration (`CardFaceView.replicaFace`'s note explains what one would steal). private var replicaFace: some View { HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) { Image(systemName: ItemSymbol.lane) .foregroundStyle(.secondary) .imageScale(.medium) Text(lane.title.value ?? AccessibilityPhrases.untitled) .boardFont(.body) .foregroundStyle(.secondary) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) Text(AccessibilityPhrases.cardCount(lane.heldCards)) .boardFont(.caption) .monospacedDigit() .foregroundStyle(.tertiary) } .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize)) .frame( width: BoardMetrics.cardReplicaWidth(measured: measuredWidth, bodyPointSize: pointSize), alignment: .leading ) .background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.tertiary)) .dragReplicaShadow(zoom: zoom) } // MARK: - The row's two rows of menu /// Delete and Reveal in Finder — "its actions are the same Delete / Reveal in Finder" /// (10-accessibility.md ▸ Trash lane), which is the trash card's inventory exactly /// (11-command-nexus.md ▸ Context menus). No Open, no Rename, no Style…, no Width: the row is /// opaque and nothing in the trash is edit-shaped. @ViewBuilder private var menu: some View { Button("Delete") { requestPurge() } .disabled(!store.acceptsBoardMutations) Divider() Button("Reveal in Finder") { revealInFinder() } } @ViewBuilder private var actions: some View { Button("Delete") { requestPurge() } .disabled(!store.acceptsBoardMutations) Button("Reveal in Finder") { revealInFinder() } } /// The **permanent** delete, through the window's confirmation host — the alert names the /// freight ("Permanently delete lane 'Doing' and its 5 cards?", `TrashModel.purgePrompt`), which /// is the whole reason an opaque row's count is carried in the snapshot at all. private func requestPurge() { confirmations.requestTrashDelete(of: targetIDs, in: store) } private func revealInFinder() { NSWorkspace.shared.activateFileViewerSelecting( ItemPath.resolve(targetIDs, in: .trash, snapshot: store.snapshot) .map { $0.folder(under: store.rootURL) } ) } /// VO-Space's landing: the ⌘-click funnel, on this row, in its own container and kind. private func toggleSelection() { store.click( SelectionTarget(id: lane.id, kind: .lane, container: .trash), modifier: .command ) } /// What this row's menu acts on: the whole selection when this row is part of it, else this row /// alone — standard macOS context-menu targeting, container-scoped like the card face's. private var targetIDs: Set { guard store.selection.container == .trash, store.selection.ids.contains(lane.id) else { return [lane.id] } return store.selection.ids } // MARK: - Selection treatment /// The accent ring when selected, a separator hairline under Increase Contrast, nothing /// otherwise — `CardFaceView.plateStroke`'s three-way branch, minus the file-drop hover the /// trash never has. private var plateStroke: AnyShapeStyle { if isSelected { AnyShapeStyle(Color.accentColor) } else if Accommodations.drawsRestingBorder(contrast: contrast) { AnyShapeStyle(.separator) } else { AnyShapeStyle(.clear) } } private var plateStrokeWidth: CGFloat { Accommodations.borderWidth(isSelected ? 1.5 : 1, contrast: contrast) } }