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
354 lines
16 KiB
Swift
354 lines
16 KiB
Swift
import AppKit
|
|
import Observation
|
|
import SwiftUI
|
|
|
|
// MARK: - The window's confirmation host
|
|
|
|
/// The board window's purge alert, as a piece of window-local state (03-board-ui.md § Trash).
|
|
///
|
|
/// **It exists because a menu item cannot present anything.** Delete Immediately and Empty Trash…
|
|
/// live in the menu bar, the trash row's context menu carries a twin of the first, and all three must
|
|
/// raise *the same* alert on *the window in front* — so the request travels through the focus system
|
|
/// exactly as `BoardInfoPresentation` does, and the alert itself is hosted once by `BoardView`.
|
|
///
|
|
/// `@State` in `BoardWindowHost`, therefore one per window and dying with it: a half-answered
|
|
/// confirmation is not something to carry across a window's life.
|
|
@MainActor
|
|
@Observable
|
|
final class TrashConfirmations {
|
|
|
|
/// The alert waiting to be answered, or `nil` when none is.
|
|
///
|
|
/// The **phrasing is captured when the request is made**, not recomputed at render time: the
|
|
/// user is being asked about the trash as it was when they invoked the command, and a foreign
|
|
/// reload landing mid-alert must not silently change the sentence they are reading. The *action*
|
|
/// re-resolves against the current snapshot when it runs, so a confirmed purge never acts on a
|
|
/// card that has since gone — the writer treats an absent folder as success.
|
|
private(set) var pending: Pending?
|
|
|
|
struct Pending: Identifiable, Equatable {
|
|
let id = UUID()
|
|
let prompt: TrashModel.PurgePrompt
|
|
let action: Action
|
|
|
|
/// What the confirmation is standing in front of. Three cases, because the three commands
|
|
/// have genuinely different scopes and two different writes: the trash's own staged Delete,
|
|
/// Delete Immediately (which skips the trash from either container), and Empty Trash (which
|
|
/// names the whole container and re-derives its targets at the moment it runs).
|
|
enum Action: Equatable {
|
|
case deleteTrashCards(Set<ItemID>)
|
|
case purge(Set<ItemID>)
|
|
case emptyTrash
|
|
}
|
|
}
|
|
|
|
/// **File ▸ Delete, staged by place** (04-interactions.md ▸ The map) — with the confirmation the
|
|
/// trash side owes and the board side does not.
|
|
///
|
|
/// A board selection goes straight through: moving a card into the trash and deleting a lane are
|
|
/// both recoverable (the trash itself, and native undo — 03-board-ui.md § Trash), so neither
|
|
/// stands an alert. A **trash** selection is the permanent one, and it "confirms exactly where
|
|
/// the loss is real": `purgeIsUnrecoverable` decides, exactly as it does for Delete Immediately.
|
|
///
|
|
/// 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 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: ids,
|
|
in: .trash,
|
|
snapshot: store.snapshot,
|
|
unrecoverable: true
|
|
) else { return }
|
|
pending = Pending(prompt: prompt, action: .deleteTrashCards(ids))
|
|
}
|
|
|
|
/// Raises Delete Immediately's alert — **or purges outright** where the loss is not real.
|
|
///
|
|
/// 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)
|
|
return
|
|
}
|
|
guard let prompt = TrashModel.purgePrompt(
|
|
for: ids,
|
|
in: store.selection.container,
|
|
snapshot: store.snapshot,
|
|
unrecoverable: true
|
|
) else { return }
|
|
pending = Pending(prompt: prompt, action: .purge(ids))
|
|
}
|
|
|
|
/// Raises Empty Trash…'s alert. **Always** — it guards bulk scope rather than per-item
|
|
/// recoverability, so no board skips it.
|
|
func requestEmptyTrash(in store: BoardStore) {
|
|
guard let prompt = TrashModel.emptyTrashPrompt(
|
|
in: store.snapshot,
|
|
unrecoverable: store.purgeIsUnrecoverable
|
|
) else { return }
|
|
pending = Pending(prompt: prompt, action: .emptyTrash)
|
|
}
|
|
|
|
/// Runs the pending action and dismisses. Idempotent: an alert answered twice (the button, then
|
|
/// the dismissal SwiftUI drives from the binding) acts once.
|
|
func confirm(in store: BoardStore) {
|
|
guard let pending else { return }
|
|
self.pending = nil
|
|
switch pending.action {
|
|
case let .deleteTrashCards(ids): store.deleteTrashCards(ids)
|
|
case let .purge(ids): store.deleteImmediately(ids)
|
|
case .emptyTrash: store.emptyTrash()
|
|
}
|
|
}
|
|
|
|
func cancel() {
|
|
pending = nil
|
|
}
|
|
}
|
|
|
|
/// The focused board window's confirmation host — beside `FocusedValues.boardStore` and
|
|
/// `.boardInfo`, and reached the same way by the same kinds of caller.
|
|
struct FocusedTrashConfirmationsKey: FocusedValueKey {
|
|
typealias Value = TrashConfirmations
|
|
}
|
|
|
|
extension FocusedValues {
|
|
var trashConfirmations: TrashConfirmations? {
|
|
get { self[FocusedTrashConfirmationsKey.self] }
|
|
set { self[FocusedTrashConfirmationsKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
// MARK: - File ▸ Delete / Delete Immediately / Empty Trash…
|
|
|
|
/// The File menu's trash rows (11-command-nexus.md).
|
|
///
|
|
/// ### One Delete, staged by place
|
|
///
|
|
/// **Put Back is retired with the tombstone model** (04-interactions.md ▸ The map, resettled
|
|
/// 2026-07-28): "File ▸ Delete is the chord's only owner — no twin menu items, no shared-equivalent
|
|
/// routing". The ⌘⌫ chord therefore has exactly one owner, its validation is one predicate
|
|
/// (`TrashModel.canDelete`), and which write it performs is decided by the selection's *container*
|
|
/// inside the store rather than by AppKit picking whichever of two items happened to be enabled.
|
|
struct TrashCommands: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
@FocusedValue(\.trashConfirmations) private var confirmations
|
|
|
|
var body: some View {
|
|
Button("Delete") {
|
|
guard let store, let confirmations else { return }
|
|
confirmations.requestDelete(in: store)
|
|
}
|
|
.keyboardShortcut(.delete, modifiers: .command)
|
|
.disabled(!canDelete || confirmations == nil)
|
|
|
|
Button("Delete Immediately") {
|
|
guard let store, let confirmations else { return }
|
|
confirmations.requestPurge(of: store.selection.ids, in: store)
|
|
}
|
|
.keyboardShortcut(.delete, modifiers: [.option, .command])
|
|
.disabled(!canDeleteImmediately || confirmations == nil)
|
|
|
|
Button("Empty Trash…") {
|
|
guard let store, let confirmations else { return }
|
|
confirmations.requestEmptyTrash(in: store)
|
|
}
|
|
.keyboardShortcut(.delete, modifiers: [.shift, .command])
|
|
.disabled(!canEmptyTrash)
|
|
}
|
|
|
|
/// A non-empty selection that still names something, on a board that accepts writes — both
|
|
/// stagings at once, which is what having one item means.
|
|
private var canDelete: Bool {
|
|
guard let store, store.acceptsBoardMutations else { return false }
|
|
return TrashModel.canDelete(selection: store.selection, in: store.snapshot)
|
|
}
|
|
|
|
/// A **card** selection, in either container — "skips the trash from anywhere"
|
|
/// (11-command-nexus.md).
|
|
private var canDeleteImmediately: Bool {
|
|
guard let store, store.acceptsBoardMutations else { return false }
|
|
return TrashModel.canDeleteImmediately(selection: store.selection, in: store.snapshot)
|
|
}
|
|
|
|
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".
|
|
///
|
|
/// 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
|
|
}
|
|
}
|
|
|
|
// MARK: - View ▸ Show Trash
|
|
|
|
/// View ▸ Show Trash — a checkmark toggle with **no default chord** (11-command-nexus.md).
|
|
///
|
|
/// ⇧⌘T is deliberately left to the system's Show Tab Bar: window tabbing stays enabled, so the chord
|
|
/// is the system's, and a user who wants one here assigns it through the remapping mechanism.
|
|
///
|
|
/// **One stable title with a checkmark state** — "Show Trash" stays "Show Trash" when checked, never
|
|
/// becomes "Hide Trash" (04-interactions.md ▸ Configurable bindings, since the title is the key a
|
|
/// custom binding is stored under).
|
|
///
|
|
/// Neither the read-only lock nor the focused-editor rule closes it: showing the trash is a view
|
|
/// change, not a mutation, and a locked board is exactly when a user wants to look at what is in
|
|
/// there.
|
|
struct ShowTrashCommand: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
|
|
var body: some View {
|
|
Toggle("Show Trash", isOn: isVisible)
|
|
.disabled(store == nil)
|
|
}
|
|
|
|
/// The toggle's binding — and the one place hiding the trash has a consequence beyond layout.
|
|
///
|
|
/// **Hiding drops a trash selection** (04-interactions.md ▸ The trash: "hiding it clears a
|
|
/// selection of trash cards — nothing invisible stays selected, so the toggle-off drops the
|
|
/// selection rather than leave commands enabled against rows nobody can see"). A *board*
|
|
/// selection is untouched — the board it names is still right there.
|
|
///
|
|
/// The setter's body lives on the store (`BoardStore.setTrashVisible`) because the toolbar's
|
|
/// Show Trash item is this same command with a different face (03-board-ui.md ▸ Toolbar: "toggle
|
|
/// state matching the View menu checkmark"), and the consequence above has to be true of both.
|
|
private var isVisible: Binding<Bool> {
|
|
Binding(
|
|
get: { store?.transient.isTrashVisible ?? false },
|
|
set: { shown in store?.setTrashVisible(shown) }
|
|
)
|
|
}
|
|
}
|
|
|
|
extension BoardStore {
|
|
|
|
/// Show/Hide Trash, with its one consequence — **the whole of the View-menu row's behaviour**,
|
|
/// shared with the toolbar item that mirrors it.
|
|
func setTrashVisible(_ shown: Bool) {
|
|
// The re-divide is a *user-initiated structural change* — every lane compresses or relaxes
|
|
// as the trash's one unit joins or leaves the division — so it animates in the structural
|
|
// voice (03-board-ui.md § Motion; § Trash makes Show/Hide Trash "a re-divide trigger", a
|
|
// lane add's behaviour exactly). It is also one of the few structural changes that never
|
|
// touches disk, which is why it wears its own transaction here instead of arriving through
|
|
// the reload seam like the rest.
|
|
//
|
|
// Reduce Motion read from AppKit rather than from `@Environment`: a menu command's content
|
|
// is built outside any rendered hierarchy, where the environment's accessibility values are
|
|
// not reliably populated (`Motion.prefersReducedMotion`) — and a toolbar item has no
|
|
// environment at all.
|
|
withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
|
|
transient.isTrashVisible = shown
|
|
// Dropped inside the same transaction: the rows it pointed at are leaving under this
|
|
// very animation, and a selection that cleared outside it would be the highlight easing
|
|
// on its own — which 03 § Motion rules out ("the selection highlight rides whatever
|
|
// transaction is active").
|
|
if !shown, selection.container == .trash {
|
|
clearSelection()
|
|
}
|
|
}
|
|
announceTrashVisibility(shown)
|
|
}
|
|
|
|
/// **"Toggling visibility is announced"** (10-accessibility.md ▸ Trash lane).
|
|
///
|
|
/// A whole container joins or leaves the accessibility tree here and nothing else marks it: the
|
|
/// VoiceOver cursor does not move, no focus is lost, and the re-divide every lane performs is
|
|
/// silent by nature. Announced from the store rather than from either caller for
|
|
/// `setTrashVisible`'s own reason — the View menu row and the toolbar item are one command with
|
|
/// two faces, and a consequence written at one of them would be missing from the other.
|
|
///
|
|
/// One post, and deliberately no machinery around it: the live board's announcements — foreign
|
|
/// edits, vanishing focus, bracketed operations — are their own design (10 ▸ Live board
|
|
/// announcements) with a summarizer and a debounce behind them, and this is not an instalment of
|
|
/// that. Posted to the key window so it is attributed to the board the user is looking at, at
|
|
/// medium priority: informative, and not worth interrupting speech already in progress.
|
|
private func announceTrashVisibility(_ shown: Bool) {
|
|
let element: Any = NSApplication.shared.keyWindow ?? NSApplication.shared
|
|
NSAccessibility.post(
|
|
element: element,
|
|
notification: .announcementRequested,
|
|
userInfo: [
|
|
.announcement: AccessibilityPhrases.trashVisibility(shown: shown),
|
|
.priority: NSAccessibilityPriorityLevel.medium.rawValue
|
|
]
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - The alert
|
|
|
|
extension View {
|
|
|
|
/// Hosts the board window's purge alert — one surface for every path that asks for one.
|
|
///
|
|
/// An **alert** rather than a confirmation dialog: this is a modal, destructive yes/no about
|
|
/// named items, which is what macOS's alert is for, and what Finder puts in front of the same
|
|
/// gesture.
|
|
func trashPurgeAlert(store: BoardStore, confirmations: TrashConfirmations) -> some View {
|
|
alert(
|
|
confirmations.pending?.prompt.title ?? "",
|
|
isPresented: Binding(
|
|
get: { confirmations.pending != nil },
|
|
// Any dismissal that is not the confirm button is a cancel — Escape, a click
|
|
// outside, the sheet being torn down.
|
|
set: { presented in if !presented { confirmations.cancel() } }
|
|
),
|
|
presenting: confirmations.pending
|
|
) { pending in
|
|
Button(pending.prompt.confirmTitle, role: .destructive) {
|
|
confirmations.confirm(in: store)
|
|
}
|
|
Button("Cancel", role: .cancel) {
|
|
confirmations.cancel()
|
|
}
|
|
} message: { pending in
|
|
Text(pending.prompt.message)
|
|
}
|
|
}
|
|
}
|