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.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user