View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight rungs, app-wide and persisted (the Show Comments precedent) — a viewing comfort, not a property of any one board. The level travels as BoardZoomContext in the environment, injected on BoardView alone so the banner strip, search bar, sheets and popovers stay at the system size; the environment is also what carries it through CardFaceView's equality gate, which compares nothing that moves with the level. Every BoardMetrics figure follows zoom.bodyPointSize — card and lane chrome, drag replicas and the count badge, the resize handle, the trash column — and the drop registry carries the ruler for event-time reads, with the autoscroller's three reaches turning font-derived (reachSide named as the stripGap it always equalled). Lanes still divide the window; zoom never moves the window or its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung announces itself to VoiceOver, and the render suite pins both invariants: a rung repaints every face, a no-op Actual Size repaints nothing. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
366 lines
19 KiB
Swift
366 lines
19 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
|
|
|
|
/// 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
|
|
|
|
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<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)
|
|
.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<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)
|
|
}
|
|
}
|