Build the trash quasi-lane
Deletion becomes a two-stage, Finder-style story. File > Delete and plain Backspace tombstone the live selection; View > Show Trash (no chord — Shift-Cmd-T stays with the system's tab bar) reveals the quasi-lane: trailing, one fixed width unit consumed only while shown, hatched dimmed header, count badge, no new-card button, exempt from resize and reorder alike. Its contents are a pure view over the snapshot — the deterministic sort (deleted newest first, folder-name ties, unparseable stamps oldest) interleaves card rows with a tombstoned lane's single entry, whose count names what Put Back returns; the ancestor walk is absolute, so an own-flag card beneath a tombstoned lane has no row and recovery is deliberately two steps. Put Back twins Delete on Cmd-Backspace with validation enabling exactly one; restore fidelity is byte-perfect because nothing ever moved. Delete Immediately confirms exactly where loss is real (every board is mode-none today; the predicate names the git carve-out for m7), Empty Trash always confirms with the true whole-board count, and dragging a tombstoned card onto a live lane restores it there — positional drops and cross-board locality arrive with m5's machinery. The banner's delete phrasing drops "move to the trash" per the naming constraint: board deletion says Delete, "Move to Trash" stays reserved for the system Trash. 47 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -23,14 +23,15 @@ import SwiftUI
|
||||
/// order.
|
||||
/// - **The keyboard's narrow slice** — Return's create/rename dispatch and Escape's step outward.
|
||||
///
|
||||
/// - **The trash quasi-lane** — trailing, one fixed unit, joining and leaving the width division as
|
||||
/// View ▸ Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash).
|
||||
///
|
||||
/// ### What is deliberately not here yet
|
||||
///
|
||||
/// The trash quasi-lane, the toolbar, search, styling, the lane context menu, and drag & drop's real
|
||||
/// machinery (multi-drag, cross-board locality, the shadow's hold rule) all belong to later
|
||||
/// milestone cards, and the card face inside `LaneView` is still a stub those cards replace. The
|
||||
/// **selection grammar** here is likewise minimal — a click replaces the selection, and that is all:
|
||||
/// ⌘-click toggling, ⇧-click ranges, the rubber band and the cards-XOR-lanes homogeneity rule are
|
||||
/// m5's selection-model card.
|
||||
/// The toolbar, search, and drag & drop's real machinery (multi-drag, cross-board locality, the
|
||||
/// shadow's hold rule) all belong to later milestone cards. The **selection grammar** here is
|
||||
/// likewise minimal — a click replaces the selection, and that is all: ⌘-click toggling, ⇧-click
|
||||
/// ranges, the rubber band and the cards-XOR-lanes homogeneity rule are m5's selection-model card.
|
||||
struct BoardView: View {
|
||||
|
||||
let store: BoardStore
|
||||
@@ -40,6 +41,10 @@ struct BoardView: View {
|
||||
/// after the first body evaluation.
|
||||
let window: @MainActor () -> NSWindow?
|
||||
|
||||
/// The window's purge-alert host — see `TrashConfirmations` for why a menu item's confirmation
|
||||
/// has to be presented from here.
|
||||
let confirmations: TrashConfirmations
|
||||
|
||||
/// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar). A closure from
|
||||
/// `BoardWindowHost` rather than an `openWindow` call here, because building a `CardWindowRef`
|
||||
/// needs the board's own window ref, which is the host's identity and not the board's.
|
||||
@@ -56,6 +61,14 @@ struct BoardView: View {
|
||||
/// One reorder at a time, per window — same lifetime, same reasoning.
|
||||
@State private var reorder = LaneReorderSession()
|
||||
|
||||
/// One drag out of the trash at a time, per window — same lifetime again.
|
||||
@State private var trashDrag = TrashDragSession()
|
||||
|
||||
/// The name of the strip's coordinate space, which is what a drop out of the trash is resolved
|
||||
/// in: `LaneLayoutMath.laneIndex` reads an x measured from the strip's leading edge, outer margin
|
||||
/// included, and no global or lane-local space is that.
|
||||
static let stripSpace = "board-strip"
|
||||
|
||||
/// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
|
||||
/// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
|
||||
/// must go back to meaning create/rename rather than nothing at all.
|
||||
@@ -77,7 +90,11 @@ struct BoardView: View {
|
||||
? resize.standard
|
||||
: LaneLayoutMath.standardWidth(
|
||||
stripWidth: viewport.size.width,
|
||||
totalUnits: LaneLayoutMath.totalUnits(of: lanes),
|
||||
// The trash's one fixed unit joins the division **only while shown**, which is
|
||||
// the whole of "Show/Hide Trash is a re-divide trigger" (03-board-ui.md § Trash):
|
||||
// the window is never touched, the existing width simply divides across one more
|
||||
// unit and every lane compresses — a lane add's behaviour, exactly.
|
||||
totalUnits: LaneLayoutMath.totalUnits(of: lanes, trashUnits: isTrashVisible ? 1 : 0),
|
||||
gap: spacing)
|
||||
// The lanes in the order the strip should *show* them: their snapshot order at rest, and
|
||||
// the drag's would-be order while a reorder is in flight — which is how the siblings
|
||||
@@ -89,11 +106,29 @@ struct BoardView: View {
|
||||
ForEach(Array(shown.enumerated()), id: \.element.id) { position, lane in
|
||||
laneSlot(lane, at: position, among: shown, standard: standard)
|
||||
}
|
||||
if isTrashVisible {
|
||||
// Trailing, always — the quasi-lane has no position of its own to lose, which is
|
||||
// also why it never appears in the reorder proposal's inputs (those are built
|
||||
// from `liveLanes`).
|
||||
TrashLaneView(
|
||||
store: store,
|
||||
confirmations: confirmations,
|
||||
drag: TrashRowDrag { x in laneUnder(x: x, standard: standard) },
|
||||
dragSession: trashDrag
|
||||
)
|
||||
.frame(width: LaneLayoutMath.slotWidth(units: 1, standard: standard, gap: spacing))
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
.padding(spacing)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
// The space a drop out of the trash is resolved in — see `BoardView.stripSpace`. It goes
|
||||
// on the padded container so x = 0 is the strip's leading edge with the outer margin
|
||||
// included, which is the origin `LaneLayoutMath`'s arithmetic assumes.
|
||||
.coordinateSpace(.named(Self.stripSpace))
|
||||
}
|
||||
.background(boardBackground)
|
||||
.trashPurgeAlert(store: store, confirmations: confirmations)
|
||||
// The board's own anchor for the Style… popover — the surface a board-targeted session hangs
|
||||
// off, since the board has no item to attach to (`styleEditorPresentation`'s `nil` anchor).
|
||||
.popover(isPresented: styleEditorPresentation(store, anchor: nil), arrowEdge: .top) {
|
||||
@@ -113,6 +148,7 @@ struct BoardView: View {
|
||||
}
|
||||
.onKeyPress(.return) { handleReturn() }
|
||||
.onKeyPress(.escape) { handleEscape() }
|
||||
.onKeyPress(keys: [.delete], phases: .down) { handleDelete($0) }
|
||||
}
|
||||
|
||||
// MARK: - Styling
|
||||
@@ -186,6 +222,16 @@ struct BoardView: View {
|
||||
)
|
||||
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
|
||||
}
|
||||
// The drop highlight for a drag out of the trash: the lane the pointer is currently over.
|
||||
// Feedback lives on the *target* rather than on a travelling replica, because the replica —
|
||||
// its lift, its settle, the copy/move badge — is m5's drag card (03-board-ui.md § Motion).
|
||||
.overlay {
|
||||
if trashDrag.isTarget(lane.id) {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(Color.accentColor, lineWidth: 2)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.frame(width: slotWidth, alignment: .topLeading)
|
||||
.offset(x: dragging ? travelOffset(at: position, among: shown, standard: standard) : 0)
|
||||
.opacity(dragging ? 0.9 : 1)
|
||||
@@ -218,6 +264,32 @@ struct BoardView: View {
|
||||
store.snapshot.lanes.filter { !$0.isDeleted }
|
||||
}
|
||||
|
||||
// MARK: - Trash
|
||||
|
||||
/// Whether the trash quasi-lane is on screen — transient, board-scoped, hidden on every open
|
||||
/// (03-board-ui.md § Trash ▸ Visibility). Read in two places (the unit total and the slot), so it
|
||||
/// gets a name rather than being spelled twice.
|
||||
private var isTrashVisible: Bool {
|
||||
store.transient.isTrashVisible
|
||||
}
|
||||
|
||||
/// The live lane under `x` in strip coordinates, or `nil` — the strip's half of drag-to-restore.
|
||||
///
|
||||
/// Re-derived against `liveLanes` at gesture time rather than captured at drag start, which is
|
||||
/// 04-interactions.md ▸ Drag and drop's re-grounding rule: a foreign reload that adds or
|
||||
/// tombstones a lane mid-drag just moves the zones, and the next proposal targets the board as it
|
||||
/// now is. A tombstoned lane is never a drop target because it is never in this list.
|
||||
private func laneUnder(x: CGFloat, standard: CGFloat) -> ItemID? {
|
||||
let lanes = liveLanes
|
||||
guard let index = LaneLayoutMath.laneIndex(
|
||||
atX: x,
|
||||
unitCounts: unitCounts(of: lanes),
|
||||
standard: standard,
|
||||
gap: spacing
|
||||
) else { return nil }
|
||||
return lanes.indices.contains(index) ? lanes[index].id : nil
|
||||
}
|
||||
|
||||
// MARK: - Reorder
|
||||
|
||||
/// The order the strip shows: the snapshot's at rest, the drag's proposal while one is in
|
||||
@@ -320,6 +392,32 @@ struct BoardView: View {
|
||||
return .handled
|
||||
}
|
||||
|
||||
/// **Plain ⌫ tombstones the live selection** — "a plain-key synonym of File ▸ Delete, kept
|
||||
/// grammar so no second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md ▸ The
|
||||
/// map).
|
||||
///
|
||||
/// Deliberately **live-only**: the nexus scopes this key to a live selection, and ⌫'s trash-side
|
||||
/// role belongs to the ⌘⌫ twins, not to the bare key. A tombstoned selection is therefore inert
|
||||
/// here — Put Back is a chord.
|
||||
///
|
||||
/// Inert while an inline editor is open, like every grammar key: the field owns ⌫ as backspace,
|
||||
/// and a stray one reaching the board mid-edit would delete the item being renamed.
|
||||
private func handleDelete(_ press: KeyPress) -> KeyPress.Result {
|
||||
// **Plain ⌫, spelled out.** The modified chords belong to the menu — ⌘⌫ (Delete / Put Back),
|
||||
// ⌥⌘⌫ (Delete Immediately), ⇧⌘⌫ (Empty Trash…) — and AppKit routes a key equivalent to the
|
||||
// menu before the view sees it. But ⌥⌫ and ⌃⌫ are nobody's key equivalent, and a fall-through
|
||||
// that tombstoned the selection on a mistyped text-editing chord would be exactly the kind of
|
||||
// accident 04-interactions.md's fixed grammar is careful to avoid.
|
||||
guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else {
|
||||
return .ignored
|
||||
}
|
||||
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
|
||||
let selection = store.selection
|
||||
guard selection.liveness == .live, !selection.isEmpty else { return .ignored }
|
||||
store.deleteSelection()
|
||||
return .handled
|
||||
}
|
||||
|
||||
/// **Escape steps outward one layer per press** (04 ▸ Grammar): abandon an open editor, else
|
||||
/// clear the selection.
|
||||
///
|
||||
|
||||
@@ -68,10 +68,42 @@ enum LaneLayoutMath {
|
||||
///
|
||||
/// The caller decides *which* lanes: the strip passes the live ones in snapshot order, because
|
||||
/// a tombstoned lane renders nowhere on the board (03-board-ui.md § Trash collapses it to a
|
||||
/// single trash entry) and so consumes none of the window's width. When the trash quasi-lane
|
||||
/// arrives it joins this total as one fixed unit — "Show/Hide Trash is a re-divide trigger".
|
||||
static func totalUnits(of lanes: [Lane]) -> Int {
|
||||
max(1, lanes.reduce(0) { $0 + displayUnits(of: $1) })
|
||||
/// single trash entry) and so consumes none of the window's width.
|
||||
///
|
||||
/// **`trashUnits` is the quasi-lane's fixed one unit, and it is *only* consumed while shown**
|
||||
/// (03-board-ui.md § Trash): the trash "spans a fixed one width unit — no `width` frontmatter,
|
||||
/// and neither the stepper nor the edge drag applies — consumed only while shown". Passing it
|
||||
/// here rather than fabricating a `Lane` for the trash is what keeps that true: there is no lane
|
||||
/// value anywhere that a reorder, a resize or a width write could reach.
|
||||
///
|
||||
/// Show/Hide Trash is therefore a **re-divide trigger** and nothing more — the window is
|
||||
/// untouched, and the existing width divides across one more (or one fewer) unit, exactly as a
|
||||
/// lane add does (§ Layout — full visibility).
|
||||
static func totalUnits(of lanes: [Lane], trashUnits: Int = 0) -> Int {
|
||||
max(1, lanes.reduce(0) { $0 + displayUnits(of: $1) } + max(0, trashUnits))
|
||||
}
|
||||
|
||||
// MARK: - Hit testing
|
||||
|
||||
/// Which lane sits under `x` in strip coordinates (0 at the strip's leading edge, outer margin
|
||||
/// included) — an index into `unitCounts`, or `nil` when `x` is not over a lane at all.
|
||||
///
|
||||
/// **The gaps and the margins answer `nil` deliberately**, and so does everything past the last
|
||||
/// lane — which is where the trash quasi-lane sits. That is the whole of drag-to-restore's
|
||||
/// "a drop anywhere else is a no-op" (03-board-ui.md § Trash): a drop that does not land
|
||||
/// squarely on a live lane writes nothing rather than guessing at the nearest one.
|
||||
///
|
||||
/// Same analytic geometry as `LaneReorderMath.proposedIndex` — resting positions computed from
|
||||
/// the unit counts, never measured frames (03-board-ui.md § Motion, "motion never feeds back
|
||||
/// into logic").
|
||||
static func laneIndex(atX x: CGFloat, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> Int? {
|
||||
var left = gap
|
||||
for (index, units) in unitCounts.enumerated() {
|
||||
let width = slotWidth(units: units, standard: standard, gap: gap)
|
||||
if x >= left, x < left + width { return index }
|
||||
left += width + gap
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - The drag's snap
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
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 an
|
||||
/// item that has since gone — `BoardWriter.purgeItem` 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. Two cases, because the two commands have
|
||||
/// genuinely different scopes: one names a selection, the other names the whole trash and
|
||||
/// re-derives its targets at the moment it runs.
|
||||
enum Action: Equatable {
|
||||
case purge(Set<ItemID>)
|
||||
case emptyTrash
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 two call sites.
|
||||
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.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 .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 / Put Back / Delete Immediately / Empty Trash…
|
||||
|
||||
/// The File menu's trash rows (11-command-nexus.md).
|
||||
///
|
||||
/// ### The ⌘⌫ chord twins
|
||||
///
|
||||
/// Delete and Put Back are **two items sharing one key equivalent**, and validation enables exactly
|
||||
/// one of them: "AppKit routes a shared key equivalent to the enabled item" (04-interactions.md ▸
|
||||
/// The map, which names Finder's own Move to Trash/Put Back pair as the precedent). The two
|
||||
/// predicates are mirror images over the selection's liveness side
|
||||
/// (`TrashModel.canDelete`/`canActOnTrash`), so they can neither both enable nor both disable while
|
||||
/// something is selected — and a selection can never be mixed, because
|
||||
/// `ItemReferenceSet.resolved(against:)` treats a liveness flip as a vanish.
|
||||
///
|
||||
/// **Both titles stay stable** (titles-are-API): each remaps independently through the system
|
||||
/// mechanism, and remapping one never moves the other's role.
|
||||
struct TrashCommands: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
@FocusedValue(\.trashConfirmations) private var confirmations
|
||||
|
||||
var body: some View {
|
||||
Button("Delete") {
|
||||
store?.deleteSelection()
|
||||
}
|
||||
.keyboardShortcut(.delete, modifiers: .command)
|
||||
.disabled(!canDelete)
|
||||
|
||||
Button("Put Back") {
|
||||
guard let store else { return }
|
||||
store.putBack(store.selection.ids)
|
||||
}
|
||||
.keyboardShortcut(.delete, modifiers: .command)
|
||||
.disabled(!canActOnTrash)
|
||||
|
||||
Button("Delete Immediately") {
|
||||
guard let store, let confirmations else { return }
|
||||
confirmations.requestPurge(of: store.selection.ids, in: store)
|
||||
}
|
||||
.keyboardShortcut(.delete, modifiers: [.option, .command])
|
||||
.disabled(!canActOnTrash || confirmations == nil)
|
||||
|
||||
Button("Empty Trash…") {
|
||||
guard let store, let confirmations else { return }
|
||||
confirmations.requestEmptyTrash(in: store)
|
||||
}
|
||||
.keyboardShortcut(.delete, modifiers: [.shift, .command])
|
||||
.disabled(!canEmptyTrash)
|
||||
}
|
||||
|
||||
/// A live, non-empty selection on a board that accepts writes.
|
||||
private var canDelete: Bool {
|
||||
guard let store, store.acceptsBoardMutations else { return false }
|
||||
return TrashModel.canDelete(selection: store.selection, in: store.snapshot)
|
||||
}
|
||||
|
||||
/// A tombstoned, non-empty selection — Put Back's condition and Delete Immediately's alike, the
|
||||
/// two being the trash side's pair (04-interactions.md ▸ The trash: "menu validation stays
|
||||
/// binary").
|
||||
private var canActOnTrash: Bool {
|
||||
guard let store, store.acceptsBoardMutations else { return false }
|
||||
return TrashModel.canActOnTrash(selection: store.selection, in: store.snapshot)
|
||||
}
|
||||
|
||||
/// **Trash shown and non-empty** (11-command-nexus.md's own scope for this row) — where
|
||||
/// "non-empty" reads the *board's* tombstones and never the filtered view (03-board-ui.md §
|
||||
/// Trash: "a bulk command about the trash itself never silently narrows to the visible subset").
|
||||
///
|
||||
/// The visibility clause is 04-interactions.md's, stated for the whole quasi-lane: "hidden, it is
|
||||
/// invisible to every gesture".
|
||||
private var canEmptyTrash: Bool {
|
||||
guard let store, store.acceptsBoardMutations, store.transient.isTrashVisible else { return false }
|
||||
return !TrashModel.isEmpty(store.snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 tombstoned selection.** The rows it pointed at are no longer on screen, and
|
||||
/// "nothing invisible may stay selected" is the invariant every item-referencing set in this app
|
||||
/// already obeys (`ItemReferenceSet`); leaving one behind would also leave Put Back and Delete
|
||||
/// Immediately enabled against a trash the user cannot see, which 04-interactions.md rules out
|
||||
/// outright ("hidden, it is invisible to every gesture"). A *live* selection is untouched — the
|
||||
/// board it names is still right there.
|
||||
private var isVisible: Binding<Bool> {
|
||||
Binding(
|
||||
get: { store?.transient.isTrashVisible ?? false },
|
||||
set: { shown in
|
||||
guard let store else { return }
|
||||
store.transient.isTrashVisible = shown
|
||||
if !shown, store.selection.liveness == .trashed {
|
||||
store.clearSelection()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - The strip's half of a trash row's drag
|
||||
|
||||
/// What the strip lends the trash so a row can be dragged back onto the board (03-board-ui.md §
|
||||
/// Trash ▸ Drag-to-restore).
|
||||
///
|
||||
/// `LaneHeaderDrag`'s sibling, and for its reason: the gesture lives on the row, but the one thing it
|
||||
/// needs — *which lane is under the cursor* — is the strip's geometry, and it must be read at gesture
|
||||
/// time rather than at body-evaluation time.
|
||||
@MainActor
|
||||
struct TrashRowDrag {
|
||||
/// The live lane under an x in strip coordinates, or `nil` when the point is not over one — a
|
||||
/// gap, the outer margin, or the trash itself (`LaneLayoutMath.laneIndex`).
|
||||
let laneUnder: (CGFloat) -> ItemID?
|
||||
}
|
||||
|
||||
// MARK: - TrashDragSession
|
||||
|
||||
/// Window-local state for an in-flight drag out of the trash — `LaneReorderSession`'s sibling, and
|
||||
/// deliberately as small.
|
||||
///
|
||||
/// It holds only what the pointer contributes: which card, and which lane is currently under it.
|
||||
/// Everything else — the strip's geometry, the lanes themselves — is read fresh at render time, so a
|
||||
/// foreign reload mid-drag cannot leave this holding a stale board (04-interactions.md ▸ Drag and
|
||||
/// drop's re-grounding rule).
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TrashDragSession {
|
||||
|
||||
/// The card being dragged out of the trash; `nil` when idle.
|
||||
private(set) var cardID: ItemID?
|
||||
|
||||
/// The live lane the pointer is over, or `nil` when it is over nothing droppable. Observed: the
|
||||
/// lanes read it to draw the drop highlight, which is this milestone's whole visual feedback.
|
||||
private(set) var targetLaneID: ItemID?
|
||||
|
||||
/// How far the pointer must travel before a click on a row becomes a drag — the same threshold
|
||||
/// the lane header uses, so the two gestures feel alike.
|
||||
static let threshold: CGFloat = 4
|
||||
|
||||
var isActive: Bool { cardID != nil }
|
||||
|
||||
func isDragging(_ id: ItemID) -> Bool { cardID == id }
|
||||
|
||||
func isTarget(_ id: ItemID) -> Bool { targetLaneID == id }
|
||||
|
||||
func begin(cardID: ItemID) {
|
||||
self.cardID = cardID
|
||||
targetLaneID = nil
|
||||
}
|
||||
|
||||
func update(targetLaneID: ItemID?) {
|
||||
guard isActive else { return }
|
||||
self.targetLaneID = targetLaneID
|
||||
}
|
||||
|
||||
/// Ends the drag, handing the caller nothing — the *commit* needs the current snapshot, which
|
||||
/// the view has and this session deliberately does not. Idempotent, because a gesture can end
|
||||
/// after the card it was carrying has already vanished.
|
||||
func end() {
|
||||
cardID = nil
|
||||
targetLaneID = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TrashLaneView
|
||||
|
||||
/// The trash quasi-lane: the trailing, visually distinct column tombstoned items live in
|
||||
/// (03-board-ui.md § Trash).
|
||||
///
|
||||
/// ### A pure view, and a quasi-lane
|
||||
///
|
||||
/// **Nothing here moves anything on disk.** Tombstoned items keep their `deleted:` key and stay
|
||||
/// exactly where they are; this column is a rendering of `TrashModel.entries(of:)` and nothing more.
|
||||
/// It is a *quasi*-lane because it wears a lane's shape while sharing none of a lane's machinery:
|
||||
///
|
||||
/// - 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 reorder proposal's `unitCounts` by construction, since `BoardView` builds that from
|
||||
/// the snapshot's live lanes;
|
||||
/// - it has **no new-card button**: nothing is created in the trash.
|
||||
///
|
||||
/// ### No editing in the trash
|
||||
///
|
||||
/// "Tombstoned cards don't open — double-click does nothing beyond selection; Put Back or drag out
|
||||
/// first." There is deliberately no double-tap recogniser, no rename editor, and no Style… row: the
|
||||
/// trash is for restoring or purging, not working.
|
||||
///
|
||||
/// ### What is still a later card's
|
||||
///
|
||||
/// The **search filter** ("shown, it participates in the filter like any lane") and the full
|
||||
/// **keyboard grammar** — arrow walks into and out of the column, ⇧-ranges that stop at both the
|
||||
/// liveness and the kind boundary, the rubber band, ⌘C copy-out — are m5's. So is the drag's replica:
|
||||
/// what ships here is the drop, with the target lane highlighted and the source row dimmed in place.
|
||||
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 Immediately must raise the *same* alert the menu bar's
|
||||
/// does.
|
||||
let confirmations: TrashConfirmations
|
||||
|
||||
/// The strip's drop resolution — see `TrashRowDrag`.
|
||||
let drag: TrashRowDrag
|
||||
|
||||
/// The window's one drag-out session, owned by `BoardView` for the same lifetime the reorder
|
||||
/// session has.
|
||||
let dragSession: TrashDragSession
|
||||
|
||||
/// 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
|
||||
|
||||
private let rowSpacing: CGFloat = 6
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
rows
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(.quaternary.opacity(0.35))
|
||||
)
|
||||
}
|
||||
|
||||
/// The rows the column shows.
|
||||
///
|
||||
// m5-search: the shown trash "participates in the filter like any lane", 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.
|
||||
private var entries: [TrashEntry] {
|
||||
TrashModel.entries(of: store.snapshot)
|
||||
}
|
||||
|
||||
// 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. It
|
||||
/// carries no gesture at all: no selection (the quasi-lane "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
|
||||
))
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
/// The entry count — the same collection the body renders, so the badge cannot disagree with
|
||||
/// what is on screen (`LaneView.countBadge`'s rule, and it is why m5's filter needs no second
|
||||
/// change here).
|
||||
private var countBadge: some View {
|
||||
Text("\(entries.count)")
|
||||
.font(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(Capsule().fill(.quaternary))
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
|
||||
private var rows: some View {
|
||||
ScrollView(.vertical) {
|
||||
LazyVStack(alignment: .leading, spacing: rowSpacing) {
|
||||
ForEach(entries) { entry in
|
||||
TrashEntryRow(
|
||||
store: store,
|
||||
entry: entry,
|
||||
confirmations: confirmations,
|
||||
drag: drag,
|
||||
dragSession: dragSession
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
|
||||
/// One trash row: a compact, dimmed plate carrying the item's symbol and title — and, for a lane
|
||||
/// entry, the count of cards Put Back would return with it.
|
||||
///
|
||||
/// **Face-like but not a card face.** It shares the plate, the symbol and the title, and it
|
||||
/// deliberately shares none of the face's *editing* affordances: no rename editor, no Open, no
|
||||
/// Style…, no attachment carousel. That is 03-board-ui.md's no-editing-in-the-trash rule expressed
|
||||
/// as an absence rather than as a pile of `disabled` modifiers.
|
||||
private struct TrashEntryRow: View {
|
||||
|
||||
let store: BoardStore
|
||||
let entry: TrashEntry
|
||||
let confirmations: TrashConfirmations
|
||||
let drag: TrashRowDrag
|
||||
let dragSession: TrashDragSession
|
||||
|
||||
private let cornerRadius: CGFloat = 6
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: ItemSymbol.name(entry.icon, fallback: symbolFallback))
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.small)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(entry.title ?? "Untitled")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
if case let .lane(_, returning) = entry {
|
||||
// "N cards" — what Put Back brings back with the lane, not how many folders sit
|
||||
// inside it (`TrashModel.entries`' returning-count rule).
|
||||
Text("\(returning) card\(returning == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary.opacity(0.6)))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
|
||||
)
|
||||
// The row being dragged out dims further, so the gesture reads even without a replica.
|
||||
.opacity(dragSession.isDragging(entry.id) ? 0.45 : 1)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(rowGesture)
|
||||
.contextMenu { menu }
|
||||
}
|
||||
|
||||
private var symbolFallback: String {
|
||||
entry.isLaneEntry ? ItemSymbol.lane : ItemSymbol.card
|
||||
}
|
||||
|
||||
// MARK: - Selection
|
||||
|
||||
private var isSelected: Bool {
|
||||
store.selection.liveness == .trashed && store.selection.ids.contains(entry.id)
|
||||
}
|
||||
|
||||
/// A click replaces the selection with this one row, on the **trashed** side.
|
||||
///
|
||||
/// Replace-only is what enforces both invariants at once here: a selection that is always exactly
|
||||
/// one row can never mix live with tombstoned, nor card entries with lane entries
|
||||
/// (04-interactions.md ▸ The trash). The extension grammar — ⌘-click, ⇧-ranges that go inert at
|
||||
/// both boundaries, the rubber band that stays on the side it started on — is **m5's
|
||||
/// selection-model card**, and nothing here should pre-empt it.
|
||||
///
|
||||
/// **A double click is two of these and nothing more**: no editor, no card window, no timer.
|
||||
private func select() {
|
||||
store.select([entry.id], liveness: .trashed)
|
||||
}
|
||||
|
||||
// MARK: - Drag out
|
||||
|
||||
/// One gesture recognising the same click-versus-drag split the lane header uses: a plain click
|
||||
/// selects, and only movement past the threshold begins a drag out of the trash.
|
||||
///
|
||||
/// **Lane entries are not draggable** (03-board-ui.md § Trash: "a lane entry is not draggable —
|
||||
/// its entry is a compact row, not the lane; its move-out is Put Back"), so the drag half is
|
||||
/// simply absent for them and the release still selects.
|
||||
///
|
||||
/// The coordinate space is the strip's, because what the release needs is a *position* over the
|
||||
/// board, not a translation.
|
||||
private var rowGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .named(BoardView.stripSpace))
|
||||
.onChanged { value in
|
||||
guard isDraggable, !store.isReadOnly, !store.isEditingInline else { return }
|
||||
if !dragSession.isDragging(entry.id) {
|
||||
let travelled = max(abs(value.translation.width), abs(value.translation.height))
|
||||
guard travelled > TrashDragSession.threshold else { return }
|
||||
dragSession.begin(cardID: entry.id)
|
||||
}
|
||||
dragSession.update(targetLaneID: drag.laneUnder(value.location.x))
|
||||
}
|
||||
.onEnded { value in
|
||||
guard dragSession.isDragging(entry.id) else {
|
||||
select()
|
||||
return
|
||||
}
|
||||
dragSession.end()
|
||||
// A drop over anything but a live lane — the trash itself, a gap, the outer margin —
|
||||
// writes nothing. There is no replica to snap back; the row never left.
|
||||
guard let lane = drag.laneUnder(value.location.x) else { return }
|
||||
store.restoreByDrag(cardID: entry.id, intoLane: lane)
|
||||
}
|
||||
}
|
||||
|
||||
private var isDraggable: Bool { !entry.isLaneEntry }
|
||||
|
||||
// MARK: - The trash entry's context menu
|
||||
|
||||
/// Put Back, Delete Immediately, Reveal in Finder — the three rows 11-command-nexus.md gives a
|
||||
/// trash entry, and no others.
|
||||
///
|
||||
/// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays
|
||||
/// enabled on tombstoned selections", read-only lock included — inspecting a folder before a
|
||||
/// purge is exactly the errand it exists for.
|
||||
@ViewBuilder
|
||||
private var menu: some View {
|
||||
Button("Put Back") {
|
||||
store.putBack(targetIDs)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
|
||||
Button("Delete Immediately") {
|
||||
confirmations.requestPurge(of: targetIDs, in: store)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Reveal in Finder") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, and the same rule the card face and the lane
|
||||
/// header apply to Style…. Right-clicking something outside the selection acts on what was
|
||||
/// clicked, which is also what keeps a cross-kind menu from ever acting on a mixed set.
|
||||
private var targetIDs: Set<ItemID> {
|
||||
guard store.selection.liveness == .trashed, store.selection.ids.contains(entry.id) else {
|
||||
return [entry.id]
|
||||
}
|
||||
return store.selection.ids
|
||||
}
|
||||
|
||||
private var targetFolders: [URL] {
|
||||
TrashModel.paths(of: targetIDs, on: .trashed, in: store.snapshot)
|
||||
.map { $0.folder(under: store.rootURL) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user