Phase 2 completes the lanes-in-trash card. TrashEntry merges the trash's two kinds by rank in exactly ONE place (ItemPath.resolve's own merge deleted in favor of it — the three-merge-points finding shrinks instead of growing). TrashLaneRowView renders the opaque row — tertiary plate, level-default lane glyph never the lane's own icon, title + card count, no accents, no expansion; the column badge counts rendered rows. Selection grammar: kind-homogeneous trash selections — ranges skip the other kind, ⇧-extension stops at the kind boundary, plain arrows walk the merged order, marquee stays card-only (now load-bearing: rows register frames for arrows), Select All card-scoped; successor-on-purge crosses kinds like navigation as the interim for open Gap 7b5cbc90. Drag: TrashDrop accepts lane sessions (drop on shown trash deletes), restoreLanes routes a trash-sourced strip drop as an arrival-ranked within-board move with an undo step. Clipboard: ⌘X/⌘V lane restore via opaque lane subjects; fixed boardRoot(ofLaneFolder:) returning .trash as the root — a same-board restore looked like an import and would have reminted the lane it was restoring (pinned by test). A11y: row = one flattened "title, deleted lane, N cards" element with Delete/Reveal actions; BoardDiff crossings read lanes as deleted/restored, shown-trash churn digested at row level. Agent guide stays v7 — the literal already teaches lanes-trash-by-move and kind stamping; drift-guard pins those lines. README trash paragraph notes lanes. Both schemes 1893 tests / 322 suites green. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
349 lines
18 KiB
Swift
349 lines
18 KiB
Swift
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 {
|
|
|
|
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
|
|
|
|
/// 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
|
|
|
|
private var pointSize: CGFloat { BoardMetrics.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
|
|
|
|
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)
|
|
.font(.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 secondary background, 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))
|
|
.font(.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.
|
|
///
|
|
/// 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 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 },
|
|
source: store
|
|
)
|
|
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<ItemID> {
|
|
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.
|
|
private var dragReplica: some View {
|
|
let count = store.selection.container == .trash && store.selection.ids.contains(lane.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(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)
|
|
.font(.body)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
Text(AccessibilityPhrases.cardCount(lane.heldCards))
|
|
.font(.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))
|
|
}
|
|
|
|
// 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<ItemID> {
|
|
guard store.selection.container == .trash, store.selection.ids.contains(lane.id) else {
|
|
return [lane.id]
|
|
}
|
|
return store.selection.ids
|
|
}
|
|
|
|
// MARK: - Selection treatment
|
|
|
|
private var isSelected: Bool {
|
|
store.selection.container == .trash && store.selection.ids.contains(lane.id)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|