Materialize the trash — store, undo, and the container universe
Phase 2 swaps every consumer: Liveness and its ancestor walk are gone, replaced by ItemContainer — a UUID set plus the container side it lives on, presence the whole test, one selection boundary instead of the old liveness law. Deletion stages by place: board cards move to the trash at a store-minted head rank, trash-side delete is permanent behind its confirmation, Delete Immediately skips the trash from anywhere, lane delete captures the subtree and removes the folder. Restore has no method at all — moveCards resolves members in either container, so drag-out and cut-paste are the ordinary moves 13 calls them, registering ordinary Move steps. The delete inverse moves the card back to its captured lane and rank; redo replays the captured trash rank, a value the gesture actually wrote; lane undo recreates the subtree byte-faithfully in session. Purges register nothing — where 13's trash section contradicts its own Rules on that, Rules wins, filed for ruling. Staleness collapsed to present-or-absent: a container is a path, so a foreign restore fails the delete step's expectation structurally. Legacy tombstones migrate on the loose-file tail hook, cards oldest-first so minting above top reproduces the retired newest-first column, lanes returning live, one folded loss row naming both directions. Put Back, restoreByDrag, receiveRestoredCards, TrashEntry, and the kind machinery are deleted; the trash column renders the container correctly with its full face rework left to phase 3. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -141,14 +141,14 @@ struct OpenCardCommand: View {
|
||||
return store.isEditingInline || soleSelectedCard != nil
|
||||
}
|
||||
|
||||
/// The sole selected **live card**, or `nil`. A lane, a multi-selection and a tombstoned
|
||||
/// selection all answer `nil` — "everything edit-shaped is disabled on tombstoned selections"
|
||||
/// (04 ▸ The trash), and a card window is tied to one card.
|
||||
/// The sole selected **board card**, or `nil`. A lane, a multi-selection and a trash
|
||||
/// selection all answer `nil` — "everything edit-shaped is disabled on trash selections … Open
|
||||
/// Card, Rename, Style…" (04 ▸ The trash), and a card window is tied to one card.
|
||||
private var soleSelectedCard: ItemID? {
|
||||
guard let store else { return nil }
|
||||
let selection = store.selection
|
||||
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first,
|
||||
BoardStore.liveItem(id, in: store.snapshot)?.cardID != nil
|
||||
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
|
||||
BoardStore.boardItem(id, in: store.snapshot)?.cardID != nil
|
||||
else { return nil }
|
||||
return id
|
||||
}
|
||||
@@ -161,8 +161,8 @@ struct OpenCardCommand: View {
|
||||
// holds it — and re-checked after, because one of those paths is *the lane vanished*.
|
||||
let lane = placeholder.laneID
|
||||
let created = store.commitPlaceholder()
|
||||
if store.snapshot.lanes.contains(where: { $0.id == lane && !$0.isDeleted }) {
|
||||
store.select([lane], liveness: .live)
|
||||
if store.snapshot.lanes.contains(where: { $0.id == lane }) {
|
||||
store.select([lane], in: .board)
|
||||
}
|
||||
if let created { open(created) }
|
||||
return
|
||||
@@ -170,7 +170,7 @@ struct OpenCardCommand: View {
|
||||
|
||||
if let editor = store.transient.renameEditor {
|
||||
let target = editor.targetID
|
||||
let isCard = BoardStore.liveItem(target, in: store.snapshot)?.cardID != nil
|
||||
let isCard = BoardStore.boardItem(target, in: store.snapshot)?.cardID != nil
|
||||
store.commitRename()
|
||||
if isCard { open(target) }
|
||||
return
|
||||
@@ -269,8 +269,8 @@ struct MoveLaneCommands: View {
|
||||
private func destination(_ delta: Int) -> (lane: ItemID, index: Int)? {
|
||||
guard let store, store.acceptsBoardMutations else { return nil }
|
||||
let selection = store.selection
|
||||
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first else { return nil }
|
||||
let lanes = SelectionGrammar.liveLanes(in: store.snapshot)
|
||||
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first else { return nil }
|
||||
let lanes = SelectionGrammar.lanes(in: store.snapshot)
|
||||
// A card id is in no lane order, so this is also the "not a lane" test.
|
||||
guard let from = lanes.firstIndex(of: id) else { return nil }
|
||||
let to = from + delta
|
||||
@@ -382,9 +382,9 @@ struct BoardInfoCommand: View {
|
||||
/// **lane's only rename path**: Return on a lane creates a card, so without this item a lane could
|
||||
/// never be renamed at all (04-interactions.md ▸ Selection).
|
||||
///
|
||||
/// Validation is the sole-selected-live-item rule — card or lane, either kind, exactly one. A
|
||||
/// tombstoned selection never enables it: "everything edit-shaped is disabled on tombstoned
|
||||
/// selections" (04 ▸ The trash), which `ItemReferenceSet`'s liveness side answers directly.
|
||||
/// Validation is the sole-selected-board-item rule — card or lane, either kind, exactly one. A
|
||||
/// trash selection never enables it: "everything edit-shaped is disabled on trash selections"
|
||||
/// (04 ▸ The trash), which `ItemReferenceSet`'s container answers directly.
|
||||
struct BoardRenameCommand: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
@@ -400,8 +400,8 @@ struct BoardRenameCommand: View {
|
||||
private var renameTarget: (id: ItemID, title: String?)? {
|
||||
guard let store, store.acceptsBoardMutations else { return nil }
|
||||
let selection = store.selection
|
||||
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first,
|
||||
let item = BoardStore.liveItem(id, in: store.snapshot)
|
||||
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
|
||||
let item = BoardStore.boardItem(id, in: store.snapshot)
|
||||
else { return nil }
|
||||
return (id: id, title: item.title)
|
||||
}
|
||||
@@ -441,7 +441,7 @@ struct BoardStyleCommand: View {
|
||||
guard let store, store.acceptsBoardMutations else { return nil }
|
||||
let selection = store.selection
|
||||
guard !selection.isEmpty else { return .board }
|
||||
guard selection.liveness == .live else { return nil }
|
||||
guard selection.container == .board else { return nil }
|
||||
// Re-resolved against the snapshot on the way in, so the session starts out holding only
|
||||
// items that render — the same universe its own reload rule will hold it to.
|
||||
let live = selection.resolved(against: store.snapshot).ids
|
||||
@@ -498,8 +498,8 @@ struct LaneWidthCommands: View {
|
||||
private var selectedLanes: [Lane] {
|
||||
guard let store, store.acceptsBoardMutations else { return [] }
|
||||
let selection = store.selection
|
||||
guard selection.liveness == .live, !selection.isEmpty else { return [] }
|
||||
return store.snapshot.lanes.filter { selection.ids.contains($0.id) && !$0.isDeleted }
|
||||
guard selection.container == .board, !selection.isEmpty else { return [] }
|
||||
return store.snapshot.lanes.filter { selection.ids.contains($0.id) }
|
||||
}
|
||||
|
||||
/// `width` is ≥ 1 (03-board-ui.md § Lane), and an item whose only outcome is a no-op reads
|
||||
|
||||
@@ -140,7 +140,7 @@ struct BoardDropContext {
|
||||
DragLocality.isSameBoard(proposal.boardRoot, store.rootURL),
|
||||
let laneID = proposal.laneID
|
||||
else { return }
|
||||
guard !store.snapshot.lanes.contains(where: { $0.id == laneID && !$0.isDeleted }) else { return }
|
||||
guard !store.snapshot.lanes.contains(where: { $0.id == laneID }) else { return }
|
||||
session.propose(nil)
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ struct BoardDropContext {
|
||||
func retargetLanes() {
|
||||
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
|
||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
let resting = store.snapshot.lanes.filter { !$0.isDeleted && !hidden.contains($0.id) }
|
||||
let resting = store.snapshot.lanes.filter { !hidden.contains($0.id) }
|
||||
let restingUnits = resting.map { LaneLayoutMath.displayUnits(of: $0) }
|
||||
let slot = DropSlotMath.laneSlot(
|
||||
cursorX: cursor.x,
|
||||
@@ -181,14 +181,14 @@ struct BoardDropContext {
|
||||
/// one shared retarget, so they can never disagree" (DRAG-REORDER.md § Edge autoscroll).
|
||||
func retargetCards(inLane laneID: ItemID) {
|
||||
guard session.isDraggingCards, let cursor = globalCursor() else { return }
|
||||
guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else {
|
||||
guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID }) else {
|
||||
revalidateProposal()
|
||||
return
|
||||
}
|
||||
guard let grid = registry.grids[laneID] else { return }
|
||||
|
||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
let rendered = lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) }
|
||||
let rendered = lane.cards.filter { !hidden.contains($0.id) }
|
||||
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
|
||||
let placement = MasonryPlacement(
|
||||
columnCount: grid.columns,
|
||||
@@ -218,7 +218,7 @@ struct BoardDropContext {
|
||||
/// answers `nil` there — and the proposal simply **holds**, which is the hysteresis contract.
|
||||
func retargetCardsFromStrip() {
|
||||
guard session.isDraggingCards, let cursor = stripCursor() else { return }
|
||||
let lanes = store.snapshot.lanes.filter { !$0.isDeleted }
|
||||
let lanes = store.snapshot.lanes
|
||||
let index = LaneLayoutMath.laneIndex(
|
||||
atX: cursor.x,
|
||||
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
|
||||
@@ -251,7 +251,7 @@ struct BoardDropContext {
|
||||
guard let sourceRoot = session.sourceRoot else { return false }
|
||||
return TrashDrop.accepts(
|
||||
kind: session.kind,
|
||||
side: session.side,
|
||||
container: session.container,
|
||||
isWithinBoard: DragLocality.isSameBoard(sourceRoot, store.rootURL),
|
||||
operation: session.resolveOperation(destinationRoot: store.rootURL),
|
||||
isTrashShown: store.transient.isTrashVisible,
|
||||
@@ -338,14 +338,14 @@ struct BoardDropContext {
|
||||
/// cursor and the snapshot, so it cannot oscillate — the drawn layout never feeds back into it.
|
||||
func retargetFile(inLane laneID: ItemID, info: DropInfo) {
|
||||
guard acceptsFileDrop(info), let cursor = globalCursor(),
|
||||
let lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }),
|
||||
let lane = store.snapshot.lanes.first(where: { $0.id == laneID }),
|
||||
let grid = registry.grids[laneID]
|
||||
else {
|
||||
session.proposeFile(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let rendered = lane.cards.filter { !$0.isDeleted }
|
||||
let rendered = lane.cards
|
||||
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
|
||||
let placement = MasonryPlacement(
|
||||
columnCount: grid.columns,
|
||||
@@ -399,7 +399,7 @@ struct BoardDropContext {
|
||||
session.proposeFile(nil)
|
||||
return
|
||||
}
|
||||
let lanes = store.snapshot.lanes.filter { !$0.isDeleted }
|
||||
let lanes = store.snapshot.lanes
|
||||
let index = LaneLayoutMath.laneIndex(
|
||||
atX: cursor.x,
|
||||
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
|
||||
@@ -498,7 +498,7 @@ struct BoardDropContext {
|
||||
/// set's size (DRAG-REORDER.md § The drop commits).
|
||||
///
|
||||
/// **One of the containers is not a destination but a verb.** A proposal naming the trash commits
|
||||
/// a tombstone — the same write ⌫ performs, through the same `BoardWriter.deleteItem` in the same
|
||||
/// a delete — the same write ⌫ performs, through the same `BoardWriter.deleteCardToTrash` in the same
|
||||
/// bracket (`BoardStore.deleteByDrag`), so a card deleted by drop is indistinguishable on disk
|
||||
/// from one deleted by keystroke (04-interactions.md ▸ The trash, settled 2026-07-28).
|
||||
///
|
||||
@@ -547,15 +547,16 @@ struct BoardDropContext {
|
||||
case .cards:
|
||||
if target.isTrash {
|
||||
// **The pointer's delete gesture** (04-interactions.md ▸ The trash, settled
|
||||
// 2026-07-28): "release tombstones the dragged card(s), exactly the ⌫ tombstone".
|
||||
// 2026-07-28): "release moves the dragged card(s) into `.trash/`" — exactly the ⌫
|
||||
// delete.
|
||||
//
|
||||
// The gate is re-asked here rather than trusted from the hover, because the one input
|
||||
// that can change between them arrives through no callback at all: ⌥ pressed after
|
||||
// the proposal stood would otherwise tombstone an original the copy grammar had just
|
||||
// the proposal stood would otherwise delete an original the copy grammar had just
|
||||
// promised to leave alone. A refusal cancels — items return, nothing is written.
|
||||
guard TrashDrop.accepts(
|
||||
kind: kind,
|
||||
side: session.side,
|
||||
container: session.container,
|
||||
isWithinBoard: within,
|
||||
operation: operation,
|
||||
isTrashShown: store.transient.isTrashVisible,
|
||||
@@ -571,27 +572,20 @@ struct BoardDropContext {
|
||||
cancelDrop()
|
||||
return false
|
||||
}
|
||||
switch (session.side, within) {
|
||||
case (.live, true):
|
||||
// **The trash side needs no branch of its own any more** (04-interactions.md ▸ The
|
||||
// trash, resettled 2026-07-28: "Drag-to-restore follows the locality model: dropping a
|
||||
// trash card into one of its own board's lanes is an ordinary move to the drop
|
||||
// position"). `moveCards`/`copyCards` resolve their members in either container, so a
|
||||
// restore *is* the within-board move and a cross-board restore *is* the ordinary
|
||||
// arrival — which is exactly what retiring the restore-specific machinery bought.
|
||||
if within {
|
||||
if operation == .copy {
|
||||
store.copyCards(Set(ids), toLane: laneID, at: target.index)
|
||||
} else {
|
||||
store.moveCards(Set(ids), toLane: laneID, at: target.index)
|
||||
}
|
||||
case (.live, false):
|
||||
} else {
|
||||
store.receiveCards(folders, operation: operation, toLane: laneID, at: target.index)
|
||||
case (.trashed, true):
|
||||
// Drag-to-restore, and its ⌥ twin. "Dropping a tombstoned card into one of its own
|
||||
// board's lanes restores it at the drop position"; ⌥ is the copy-out instead — a
|
||||
// live copy lands and the tombstoned original stays (04-interactions.md ▸ The trash,
|
||||
// "⌘C, ⌥-drag … always yield live copies").
|
||||
if operation == .copy {
|
||||
store.receiveRestoredCards(folders, operation: .copy, toLane: laneID, at: target.index)
|
||||
} else {
|
||||
store.restoreByDrag(cardIDs: ids, intoLane: laneID, at: target.index)
|
||||
}
|
||||
case (.trashed, false):
|
||||
store.receiveRestoredCards(folders, operation: operation, toLane: laneID, at: target.index)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ struct BoardView: View {
|
||||
guard ClickModifier.current == .plain else { return }
|
||||
store.clearSelection()
|
||||
}
|
||||
.simultaneousGesture(marqueeControl.gesture(side: .live))
|
||||
.simultaneousGesture(marqueeControl.gesture(in: .board))
|
||||
}
|
||||
|
||||
/// The lanes and the drag's shadows, plus the trash column when it is shown.
|
||||
@@ -269,7 +269,7 @@ struct BoardView: View {
|
||||
if isTrashVisible {
|
||||
// Trailing, always — the quasi-lane has no position of its own to lose, which is
|
||||
// also why it never appears in the drop proposal's inputs (those are built from
|
||||
// `liveLanes`) and why the terminal slot clamps in front of it.
|
||||
// `boardLanes`) and why the terminal slot clamps in front of it.
|
||||
TrashLaneView(
|
||||
store: store,
|
||||
confirmations: confirmations,
|
||||
@@ -427,11 +427,11 @@ struct BoardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The lanes the strip lays out, in snapshot order. **Tombstoned lanes render nowhere here** —
|
||||
/// 03-board-ui.md § Trash collapses each into a single restorable entry in the trash quasi-lane
|
||||
/// (a later card), and a lane that is not on the board consumes none of the window's width.
|
||||
private var liveLanes: [Lane] {
|
||||
store.snapshot.lanes.filter { !$0.isDeleted }
|
||||
/// The lanes the strip lays out, in snapshot order — every lane the board has. Deleting a lane
|
||||
/// is physical now (03-board-ui.md § Trash: "Cards only. Lanes are never trashed"), so a lane in
|
||||
/// the snapshot is a lane on the board, with no hidden state to filter for.
|
||||
private var boardLanes: [Lane] {
|
||||
store.snapshot.lanes
|
||||
}
|
||||
|
||||
// MARK: - Trash
|
||||
@@ -443,16 +443,15 @@ struct BoardView: View {
|
||||
store.transient.isTrashVisible
|
||||
}
|
||||
|
||||
/// The trash's rows as the column is showing them — the shown trash "participates in the filter
|
||||
/// like any lane" (03-board-ui.md § Trash), and the arrows walk what is on screen
|
||||
/// (`TrashLaneView.entries` applies the identical predicate to the identical rows).
|
||||
/// The trash's cards as the column is showing them — the shown trash's cards "participate in
|
||||
/// the filter exactly like any other card" (03-board-ui.md § Trash), and the arrows walk what is
|
||||
/// on screen (`TrashLaneView` applies the identical predicate to the identical cards).
|
||||
///
|
||||
/// Read by the three keyboard destinations that reach into the column — the arrow origin's
|
||||
/// order list, ⌥↑/⌥↓'s container, and ⌥→'s jump — so none of them can walk onto a row the
|
||||
/// order list, ⌥↑/⌥↓'s container, and ⌥→'s jump — so none of them can walk onto a card the
|
||||
/// filter took away.
|
||||
private var trashEntries: [TrashEntry] {
|
||||
let filter = store.searchFilter
|
||||
return TrashModel.entries(of: store.snapshot).filter { filter.matches($0) }
|
||||
private var trashCards: [ItemID] {
|
||||
SelectionGrammar.trashCards(in: store.snapshot, filter: store.searchFilter)
|
||||
}
|
||||
|
||||
// MARK: - The drag
|
||||
@@ -506,7 +505,7 @@ struct BoardView: View {
|
||||
/// (`arrivingLaneUnits`).
|
||||
private func standardWidth(stripWidth: CGFloat) -> CGFloat {
|
||||
if resize.isActive { return resize.standard }
|
||||
var units = LaneLayoutMath.totalUnits(of: liveLanes, trashUnits: isTrashVisible ? 1 : 0)
|
||||
var units = LaneLayoutMath.totalUnits(of: boardLanes, trashUnits: isTrashVisible ? 1 : 0)
|
||||
units += arrivingLaneUnits
|
||||
return LaneLayoutMath.standardWidth(
|
||||
stripWidth: stripWidth,
|
||||
@@ -569,7 +568,7 @@ struct BoardView: View {
|
||||
private var stripSlots: [StripSlot] {
|
||||
let session = appModel.dragSession
|
||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
var slots = liveLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane)
|
||||
var slots = boardLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane)
|
||||
guard let index = stripProposal else { return slots }
|
||||
let shadows = session.laneUnits.enumerated().map { StripSlot.shadow(index: $0.offset, units: $0.element) }
|
||||
slots.insert(contentsOf: shadows, at: min(max(0, index), slots.count))
|
||||
@@ -596,10 +595,10 @@ struct BoardView: View {
|
||||
}
|
||||
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
|
||||
let selection = store.selection
|
||||
guard selection.liveness == .live,
|
||||
guard selection.container == .board,
|
||||
selection.ids.count == 1,
|
||||
let id = selection.ids.first,
|
||||
let target = BoardStore.liveItem(id, in: store.snapshot)
|
||||
let target = BoardStore.boardItem(id, in: store.snapshot)
|
||||
else { return .ignored }
|
||||
|
||||
if target.cardID == nil {
|
||||
@@ -614,9 +613,11 @@ struct BoardView: View {
|
||||
/// 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.
|
||||
/// **Both stagings**, unlike the tombstone era's live-only reading: "Plain ⌫ performs the same
|
||||
/// delete as fixed grammar" (04-interactions.md ▸ The map, resettled 2026-07-28), and the delete
|
||||
/// is staged by place inside the store (`BoardStore.deleteSelection`) rather than by two menu
|
||||
/// items sharing a chord. Put Back — the reason the bare key had to stay off the trash — is
|
||||
/// retired with the tombstone model.
|
||||
///
|
||||
/// 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.
|
||||
@@ -631,7 +632,7 @@ struct BoardView: View {
|
||||
}
|
||||
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
|
||||
let selection = store.selection
|
||||
guard selection.liveness == .live, !selection.isEmpty else { return .ignored }
|
||||
guard !selection.isEmpty else { return .ignored }
|
||||
store.deleteSelection()
|
||||
return .handled
|
||||
}
|
||||
@@ -716,7 +717,7 @@ struct BoardView: View {
|
||||
guard let origin = arrowOrigin() else { return seed(direction, mode) }
|
||||
return origin.isLaneDomain
|
||||
? laneArrow(direction, mode, from: origin.head)
|
||||
: cardArrow(direction, mode, from: origin.head, on: origin.side)
|
||||
: cardArrow(direction, mode, from: origin.head, in: origin.container)
|
||||
}
|
||||
|
||||
private static func direction(of key: KeyEquivalent) -> NavigationMath.Direction? {
|
||||
@@ -738,33 +739,32 @@ struct BoardView: View {
|
||||
/// Select All and a foreign reload leave the arrows somewhere sensible without any of them
|
||||
/// having to name a cursor.
|
||||
///
|
||||
/// The **trash's list is both kinds interleaved** (`TrashModel.entries`), because "arrows walk
|
||||
/// every trash entry in its sorted order — card and lane entries alike" (04 ▸ The trash). The
|
||||
/// per-kind lists are the *range*'s business, not the walk's.
|
||||
/// The **trash's list is its cards**, top to bottom — there are no lane entries to interleave
|
||||
/// any more (03-board-ui.md § Trash: "Cards only").
|
||||
///
|
||||
/// Both lists are the **filtered** board (04 § Search: "arrow nav … read[s] it"), so the
|
||||
/// fallback lands on the last *visible* member rather than on a card the query hid.
|
||||
private func arrowOrigin() -> (head: ItemID, side: Liveness, isLaneDomain: Bool)? {
|
||||
private func arrowOrigin() -> (head: ItemID, container: ItemContainer, isLaneDomain: Bool)? {
|
||||
let selection = store.selection
|
||||
guard !selection.isEmpty else { return nil }
|
||||
|
||||
let isLaneDomain: Bool
|
||||
let list: [ItemID]
|
||||
switch selection.liveness {
|
||||
case .live:
|
||||
switch selection.container {
|
||||
case .board:
|
||||
guard let kind = SelectionGrammar.kind(of: selection, in: store.snapshot) else { return nil }
|
||||
isLaneDomain = kind == .lane
|
||||
list = SelectionGrammar.order(of: kind, on: .live, in: store.snapshot, filter: store.searchFilter)
|
||||
case .trashed:
|
||||
list = SelectionGrammar.order(of: kind, in: .board, snapshot: store.snapshot, filter: store.searchFilter)
|
||||
case .trash:
|
||||
isLaneDomain = false
|
||||
list = trashEntries.map(\.id)
|
||||
list = trashCards
|
||||
}
|
||||
|
||||
if let head = store.transient.selectionHead, list.contains(head) {
|
||||
return (head, selection.liveness, isLaneDomain)
|
||||
return (head, selection.container, isLaneDomain)
|
||||
}
|
||||
guard let last = list.last(where: { selection.ids.contains($0) }) else { return nil }
|
||||
return (last, selection.liveness, isLaneDomain)
|
||||
return (last, selection.container, isLaneDomain)
|
||||
}
|
||||
|
||||
/// **An empty selection seeds at the first lane's first card** (04-interactions.md ▸ Grammar) —
|
||||
@@ -779,8 +779,8 @@ struct BoardView: View {
|
||||
if mode == .jump, direction == .left || direction == .right {
|
||||
return jumpToEndLane(direction)
|
||||
}
|
||||
guard let first = Self.firstCard(scanning: liveLanes, filter: store.searchFilter) else { return .handled }
|
||||
replaceSelection(with: first, on: .live)
|
||||
guard let first = Self.firstCard(scanning: boardLanes, filter: store.searchFilter) else { return .handled }
|
||||
replaceSelection(with: first, in: .board)
|
||||
return .handled
|
||||
}
|
||||
|
||||
@@ -790,7 +790,7 @@ struct BoardView: View {
|
||||
_ direction: NavigationMath.Direction,
|
||||
_ mode: ArrowMode,
|
||||
from head: ItemID,
|
||||
on side: Liveness
|
||||
in container: ItemContainer
|
||||
) -> KeyPress.Result {
|
||||
switch mode {
|
||||
case .step: step(direction, from: head)
|
||||
@@ -798,7 +798,7 @@ struct BoardView: View {
|
||||
case .jump:
|
||||
switch direction {
|
||||
case .left, .right: jumpToEndLane(direction)
|
||||
case .up, .down: jumpWithinContainer(direction, from: head, on: side)
|
||||
case .up, .down: jumpWithinContainer(direction, from: head, in: container)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -817,7 +817,7 @@ struct BoardView: View {
|
||||
),
|
||||
let next = marqueeTargets.targets[nextID]
|
||||
else { return .handled }
|
||||
replaceSelection(with: next.id, on: next.side)
|
||||
replaceSelection(with: next.id, in: next.container)
|
||||
return .handled
|
||||
}
|
||||
|
||||
@@ -837,7 +837,7 @@ struct BoardView: View {
|
||||
among: marqueeTargets.all
|
||||
),
|
||||
let next = marqueeTargets.targets[nextID],
|
||||
next.side == origin.side,
|
||||
next.container == origin.container,
|
||||
next.kind == origin.kind
|
||||
else { return .handled }
|
||||
|
||||
@@ -849,13 +849,13 @@ struct BoardView: View {
|
||||
from: anchor,
|
||||
to: next.id,
|
||||
kind: next.kind,
|
||||
on: next.side,
|
||||
in: store.snapshot,
|
||||
in: next.container,
|
||||
snapshot: store.snapshot,
|
||||
// The span is the *filtered* board's, so a range under a search collects exactly the
|
||||
// rows between the two endpoints that are on screen (04 § Search: "ranges … read it").
|
||||
// cards between the two endpoints that are on screen (04 § Search: "ranges … read it").
|
||||
filter: store.searchFilter
|
||||
) else { return .handled }
|
||||
store.select(ids, liveness: next.side, anchor: anchor, head: next.id)
|
||||
store.select(ids, in: next.container, anchor: anchor, head: next.id)
|
||||
return .handled
|
||||
}
|
||||
|
||||
@@ -869,30 +869,30 @@ struct BoardView: View {
|
||||
private func jumpWithinContainer(
|
||||
_ direction: NavigationMath.Direction,
|
||||
from head: ItemID,
|
||||
on side: Liveness
|
||||
in container: ItemContainer
|
||||
) -> KeyPress.Result {
|
||||
let container: [ItemID]
|
||||
let siblings: [ItemID]
|
||||
var lane: ItemID?
|
||||
switch side {
|
||||
case .trashed:
|
||||
container = trashEntries.map(\.id)
|
||||
case .live:
|
||||
switch container {
|
||||
case .trash:
|
||||
siblings = trashCards
|
||||
case .board:
|
||||
guard let home = store.snapshot.lanes.first(where: { lane in
|
||||
!lane.isDeleted && lane.cards.contains { $0.id == head && !$0.isDeleted }
|
||||
lane.cards.contains { $0.id == head }
|
||||
}) else { return .handled }
|
||||
lane = home.id
|
||||
// The container is what the lane is *showing*: a jump to "the lane's first card" under
|
||||
// a search means its first surviving card, not one the filter animated out.
|
||||
let filter = store.searchFilter
|
||||
container = home.cards.filter { !$0.isDeleted && filter.matches($0) }.map(\.id)
|
||||
siblings = home.cards.filter { filter.matches($0) }.map(\.id)
|
||||
}
|
||||
|
||||
guard let target = direction == .up ? container.first : container.last else { return .handled }
|
||||
guard let target = direction == .up ? siblings.first : siblings.last else { return .handled }
|
||||
if direction == .up, target == head, let lane, store.selection.ids == [head] {
|
||||
replaceSelection(with: lane, on: .live)
|
||||
replaceSelection(with: lane, in: .board)
|
||||
return .handled
|
||||
}
|
||||
replaceSelection(with: target, on: side)
|
||||
replaceSelection(with: target, in: container)
|
||||
return .handled
|
||||
}
|
||||
|
||||
@@ -904,11 +904,11 @@ struct BoardView: View {
|
||||
/// the jump falls through to the last lane. Empty lanes are scanned past in both directions —
|
||||
/// a jump that landed nowhere because the end lane happens to be empty would be a dead key.
|
||||
private func jumpToEndLane(_ direction: NavigationMath.Direction) -> KeyPress.Result {
|
||||
if direction == .right, isTrashVisible, let first = trashEntries.first {
|
||||
replaceSelection(with: first.id, on: .trashed)
|
||||
if direction == .right, isTrashVisible, let first = trashCards.first {
|
||||
replaceSelection(with: first, in: .trash)
|
||||
return .handled
|
||||
}
|
||||
let lanes = liveLanes
|
||||
let lanes = boardLanes
|
||||
let filter = store.searchFilter
|
||||
// A lane the search emptied is scanned past exactly as an empty one is — the jump lands on
|
||||
// the first lane that is *showing* a card, which is what the user can see.
|
||||
@@ -916,7 +916,7 @@ struct BoardView: View {
|
||||
? Self.firstCard(scanning: lanes.reversed(), filter: filter)
|
||||
: Self.firstCard(scanning: lanes, filter: filter)
|
||||
guard let target else { return .handled }
|
||||
replaceSelection(with: target, on: .live)
|
||||
replaceSelection(with: target, in: .board)
|
||||
return .handled
|
||||
}
|
||||
|
||||
@@ -937,7 +937,7 @@ struct BoardView: View {
|
||||
_ mode: ArrowMode,
|
||||
from head: ItemID
|
||||
) -> KeyPress.Result {
|
||||
let lanes = SelectionGrammar.liveLanes(in: store.snapshot)
|
||||
let lanes = SelectionGrammar.lanes(in: store.snapshot)
|
||||
guard let index = lanes.firstIndex(of: head) else { return .handled }
|
||||
|
||||
switch (direction, mode) {
|
||||
@@ -945,30 +945,30 @@ struct BoardView: View {
|
||||
let next = index + (direction == .left ? -1 : 1)
|
||||
guard lanes.indices.contains(next) else { return .handled }
|
||||
if mode == .step {
|
||||
replaceSelection(with: lanes[next], on: .live)
|
||||
replaceSelection(with: lanes[next], in: .board)
|
||||
} else {
|
||||
let anchor = store.transient.selectionAnchor ?? head
|
||||
guard let ids = SelectionGrammar.range(
|
||||
from: anchor,
|
||||
to: lanes[next],
|
||||
kind: .lane,
|
||||
on: .live,
|
||||
in: store.snapshot
|
||||
in: .board,
|
||||
snapshot: store.snapshot
|
||||
) else { return .handled }
|
||||
store.select(ids, liveness: .live, anchor: anchor, head: lanes[next])
|
||||
store.select(ids, in: .board, anchor: anchor, head: lanes[next])
|
||||
}
|
||||
|
||||
case (.left, .jump), (.right, .jump):
|
||||
guard let target = direction == .left ? lanes.first : lanes.last else { return .handled }
|
||||
replaceSelection(with: target, on: .live)
|
||||
replaceSelection(with: target, in: .board)
|
||||
|
||||
case (.down, .step), (.down, .jump):
|
||||
guard let lane = store.snapshot.lanes.first(where: { $0.id == head && !$0.isDeleted }) else {
|
||||
guard let lane = store.snapshot.lanes.first(where: { $0.id == head }) else {
|
||||
return .handled
|
||||
}
|
||||
let cards = lane.cards.filter { !$0.isDeleted }
|
||||
let cards = lane.cards
|
||||
guard let target = mode == .jump ? cards.last : cards.first else { return .handled }
|
||||
replaceSelection(with: target.id, on: .live)
|
||||
replaceSelection(with: target.id, in: .board)
|
||||
|
||||
case (.up, _), (.down, .extend):
|
||||
// Nothing above the lane domain, and no vertical range within it.
|
||||
@@ -980,8 +980,8 @@ struct BoardView: View {
|
||||
// MARK: Shared
|
||||
|
||||
/// A jump's and a plain step's shared landing: one item, both cursors on it.
|
||||
private func replaceSelection(with id: ItemID, on side: Liveness) {
|
||||
store.select([id], liveness: side, anchor: id, head: id)
|
||||
private func replaceSelection(with id: ItemID, in container: ItemContainer) {
|
||||
store.select([id], in: container, anchor: id, head: id)
|
||||
}
|
||||
|
||||
/// The first rendered card of the first lane that has one — the scan every "first/last lane"
|
||||
@@ -994,7 +994,7 @@ struct BoardView: View {
|
||||
filter: SearchFilter = .inactive
|
||||
) -> ItemID? {
|
||||
for lane in lanes {
|
||||
if let card = lane.cards.first(where: { !$0.isDeleted && filter.matches($0) }) { return card.id }
|
||||
if let card = lane.cards.first(where: { filter.matches($0) }) { return card.id }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -46,20 +46,6 @@ enum DragKind: String, Codable, Sendable, Equatable {
|
||||
/// stray drop into a text editor does something sane rather than nothing.
|
||||
struct DragPayload: Codable, Sendable, Equatable {
|
||||
|
||||
/// Which side of the live/tombstoned boundary the drag started on — a trash row's drag is a card
|
||||
/// drag from the trashed side, and 04-interactions.md ▸ The trash gives it its own rules
|
||||
/// (restore within the board, copy-out across boards).
|
||||
enum Side: String, Codable, Sendable, Equatable {
|
||||
case live
|
||||
case trashed
|
||||
|
||||
init(_ liveness: Liveness) {
|
||||
self = liveness == .live ? .live : .trashed
|
||||
}
|
||||
|
||||
var liveness: Liveness { self == .live ? .live : .trashed }
|
||||
}
|
||||
|
||||
/// One dragged item: its UUID, its folder on disk, and its title for the text representation.
|
||||
struct Item: Codable, Sendable, Equatable {
|
||||
var id: String
|
||||
@@ -71,7 +57,11 @@ struct DragPayload: Codable, Sendable, Equatable {
|
||||
var boardRoot: String
|
||||
|
||||
var kind: DragKind
|
||||
var side: Side
|
||||
|
||||
/// Which container the drag started in — a trash card's drag is a card drag from `.trash`, which
|
||||
/// is the whole of what makes its within-board drop a restore (04-interactions.md ▸ The trash).
|
||||
/// `ItemContainer` is `String`-backed and `Codable` precisely so it can ride a pasteboard.
|
||||
var container: ItemContainer
|
||||
|
||||
/// The dragged items **in flatten order** — "lane `order` first, then card `order`"
|
||||
/// (04-interactions.md ▸ Drag and drop). The drop commits trust this order rather than
|
||||
@@ -97,10 +87,10 @@ struct DragPayload: Codable, Sendable, Equatable {
|
||||
try? JSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
init(boardRoot: URL, kind: DragKind, side: Liveness, items: [Item]) {
|
||||
init(boardRoot: URL, kind: DragKind, container: ItemContainer, items: [Item]) {
|
||||
self.boardRoot = boardRoot.path
|
||||
self.kind = kind
|
||||
self.side = Side(side)
|
||||
self.container = container
|
||||
self.items = items
|
||||
}
|
||||
|
||||
|
||||
@@ -15,16 +15,17 @@ struct DropTarget: Equatable, Sendable {
|
||||
/// The three surfaces a drop can name, spelled as a sum so the impossible combinations cannot be
|
||||
/// written down at all.
|
||||
///
|
||||
/// The trash is a case rather than an id because **it has no id**: the quasi-lane is not in the
|
||||
/// snapshot — it is `TrashModel.entries` derived from it — so there is nothing to put in a
|
||||
/// `lane`, and its index is not a position the pointer chose either (see `TrashDrop`).
|
||||
/// The trash is a case rather than an id because **it has no id**: `.trash/` "holds card
|
||||
/// folders directly — same shape as a lane's children, no `index.md` of its own"
|
||||
/// (01-storage-format.md § Deletion), so there is nothing to put in a `lane`, and its index is
|
||||
/// not a position the pointer chose either (see `TrashDrop`).
|
||||
enum Container: Equatable, Sendable {
|
||||
/// The **lane strip**: the index counts live lanes with the dragged run removed.
|
||||
/// The **lane strip**: the index counts the board's lanes with the dragged run removed.
|
||||
case strip
|
||||
/// That lane's **masonry**: the index is a position in its logical card order
|
||||
/// (DRAG-REORDER.md § The card masonry).
|
||||
case lane(ItemID)
|
||||
/// The **trash quasi-lane**, which a live card drag proposes into to delete it
|
||||
/// The **trash column**, which a board card drag proposes into to delete it
|
||||
/// (04-interactions.md ▸ The trash, settled 2026-07-28). The index is always the topmost row.
|
||||
case trash
|
||||
}
|
||||
@@ -57,8 +58,9 @@ enum TrashDrop {
|
||||
|
||||
/// The row the shadow takes, always: **the topmost**.
|
||||
///
|
||||
/// Not arbitrary, and the sort is what makes it honest: the trash orders by `deleted`
|
||||
/// newest-first (03-board-ui.md § Trash), so a fresh tombstone genuinely lands on top. The drop
|
||||
/// Not arbitrary, and the ranks are what make it honest: "every trash arrival mints a rank
|
||||
/// above the current top" (04-interactions.md ▸ The trash), so a fresh delete genuinely lands on
|
||||
/// top. The drop
|
||||
/// therefore still lands exactly where the shadow shows — the one positional promise every other
|
||||
/// drop in this app makes — while being the only proposal on the board the *pointer* does not
|
||||
/// choose.
|
||||
@@ -70,7 +72,7 @@ enum TrashDrop {
|
||||
/// - **Lanes are not deliverable this way** — "a lane drag proposes only lane slots". (The strip's
|
||||
/// slot list has never contained the quasi-lane, so this is belt over braces; it is written down
|
||||
/// because a guard that is only true by construction is one refactor from being false.)
|
||||
/// - **A trash row is already there.** A `.trashed` session's vocabulary is restore and copy-out;
|
||||
/// - **A trash card is already there.** A `.trash` session's vocabulary is restore and copy-out;
|
||||
/// dropping it back where it came from writes nothing.
|
||||
/// - **Cross-board is refused.** "No move or paste ever targets the trash": a foreign card
|
||||
/// delivered *into* this board's trash would be a transfer-and-delete compound, an operation the
|
||||
@@ -82,14 +84,14 @@ enum TrashDrop {
|
||||
/// - **The mutating-gesture rule**, like every other write the pointer can start.
|
||||
static func accepts(
|
||||
kind: DragKind?,
|
||||
side: Liveness,
|
||||
container: ItemContainer,
|
||||
isWithinBoard: Bool,
|
||||
operation: TransferOperation,
|
||||
isTrashShown: Bool,
|
||||
acceptsMutations: Bool
|
||||
) -> Bool {
|
||||
guard isTrashShown, acceptsMutations else { return false }
|
||||
guard kind == .cards, side == .live, isWithinBoard else { return false }
|
||||
guard kind == .cards, container == .board, isWithinBoard else { return false }
|
||||
return operation == .move
|
||||
}
|
||||
}
|
||||
@@ -207,14 +209,14 @@ enum DragLocality {
|
||||
/// - **Lane drags never copy within their board.** ⌥ is simply ignored there: the drag stays a
|
||||
/// clean reorder and the badge never shows copy. The within-board lane duplicate exists, but
|
||||
/// its home is the clipboard (▸ Clipboard, Lane paste).
|
||||
/// - **A trash row's drag is copy-out grammar** (▸ The trash). Within its own board it is the
|
||||
/// restore — a move, no badge; across boards the default is the live copy that leaves the
|
||||
/// tombstoned original in place, exactly as ⌘C out of the trash behaves. ⌘ forces the true
|
||||
/// restore-move either way, and ⌥ forces the live copy either way ("⌘C, ⌥-drag, and the
|
||||
/// cross-board drag default always yield *live* copies").
|
||||
/// - **A trash card's drag is the restore** (▸ The trash). Within its own board it is "an
|
||||
/// ordinary move to the drop position"; across boards the default is the copy that leaves the
|
||||
/// original in the source trash, and "⌘-drag forces the true cross-board restore-move". Both
|
||||
/// fall out of the ordinary locality rule with no trash clause at all, which is the pivot's
|
||||
/// whole point.
|
||||
static func operation(
|
||||
kind: DragKind,
|
||||
side: Liveness,
|
||||
container: ItemContainer,
|
||||
isWithinBoard: Bool,
|
||||
modifiers: NSEvent.ModifierFlags
|
||||
) -> TransferOperation {
|
||||
@@ -227,7 +229,7 @@ enum DragLocality {
|
||||
|
||||
if forcesMove { return .move }
|
||||
if forcesCopy { return .copy }
|
||||
_ = side // the side changes which commit runs, never which operation the badge shows
|
||||
_ = container // the container changes which commit runs, never which operation the badge shows
|
||||
return isWithinBoard ? .move : .copy
|
||||
}
|
||||
}
|
||||
@@ -272,9 +274,9 @@ final class DragSession {
|
||||
/// means here rather than a separate flag.
|
||||
private(set) var kind: DragKind?
|
||||
|
||||
/// The side the drag started on. A trash row's drag is a `.cards` session on the `.trashed`
|
||||
/// side, and that is the whole of what makes it one (04-interactions.md ▸ The trash).
|
||||
private(set) var side: Liveness = .live
|
||||
/// The container the drag started in. A trash card's drag is a `.cards` session in `.trash`,
|
||||
/// and that is the whole of what makes it a restore (04-interactions.md ▸ The trash).
|
||||
private(set) var container: ItemContainer = .board
|
||||
|
||||
/// The dragged items in **flatten order** — the order they will land in.
|
||||
private(set) var members: [ItemID] = []
|
||||
@@ -358,8 +360,8 @@ final class DragSession {
|
||||
|
||||
/// The items to **leave out of the resting layout** on the board rooted at `root`.
|
||||
///
|
||||
/// Only the source board hides anything, and only for a live-side session: a trash row's drag
|
||||
/// carries items that render in the quasi-lane, not in any lane's masonry, so no lane loses a
|
||||
/// Only the source board hides anything, and only for a board-side session: a trash card's drag
|
||||
/// carries items that render in the trash column, not in any lane's masonry, so no lane loses a
|
||||
/// card to it.
|
||||
///
|
||||
/// **The dragged run is lifted out whatever the effective operation is** (DRAG-REORDER.md §
|
||||
@@ -368,7 +370,7 @@ final class DragSession {
|
||||
/// originals reappear when the write lands — the hold keeps them lifted for that round trip, so
|
||||
/// the arrangement on screen is the one the release proposed and stays still until the echo.
|
||||
func hiddenMembers(onBoardRooted root: URL) -> Set<ItemID> {
|
||||
guard isActive, side == .live, let sourceRoot,
|
||||
guard isActive, container == .board, let sourceRoot,
|
||||
DragLocality.isSameBoard(root, sourceRoot)
|
||||
else { return [] }
|
||||
return memberSet
|
||||
@@ -473,27 +475,27 @@ final class DragSession {
|
||||
|
||||
// MARK: Lifecycle
|
||||
|
||||
/// Begins a card session — live faces or trash rows.
|
||||
/// Begins a card session — board faces or trash cards.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - members: the dragged cards in flatten order (`SelectionGrammar.liveCards`, or the trash's
|
||||
/// own sorted order for a trash-row drag).
|
||||
/// - members: the dragged cards in flatten order (`SelectionGrammar.boardCards`, or the
|
||||
/// trash's own order for a trash-card drag).
|
||||
/// - heights: their measured heights, captured **before** the pickup transition starts.
|
||||
func beginCards(
|
||||
_ members: [ItemID],
|
||||
folders: [URL],
|
||||
heights: [CGFloat],
|
||||
side: Liveness,
|
||||
container: ItemContainer,
|
||||
source: BoardStore
|
||||
) {
|
||||
begin(kind: .cards, members: members, folders: folders, side: side, source: source)
|
||||
begin(kind: .cards, members: members, folders: folders, container: container, source: source)
|
||||
cardHeights = heights
|
||||
laneUnits = []
|
||||
}
|
||||
|
||||
/// Begins a lane session.
|
||||
func beginLanes(_ members: [ItemID], folders: [URL], units: [Int], source: BoardStore) {
|
||||
begin(kind: .lanes, members: members, folders: folders, side: .live, source: source)
|
||||
begin(kind: .lanes, members: members, folders: folders, container: .board, source: source)
|
||||
laneUnits = units
|
||||
cardHeights = []
|
||||
}
|
||||
@@ -502,7 +504,7 @@ final class DragSession {
|
||||
kind: DragKind,
|
||||
members: [ItemID],
|
||||
folders: [URL],
|
||||
side: Liveness,
|
||||
container: ItemContainer,
|
||||
source: BoardStore
|
||||
) {
|
||||
endHold()
|
||||
@@ -510,14 +512,14 @@ final class DragSession {
|
||||
self.members = members
|
||||
self.memberSet = Set(members)
|
||||
self.folders = folders
|
||||
self.side = side
|
||||
self.container = container
|
||||
self.sourceStore = source
|
||||
self.sourceRoot = source.rootURL
|
||||
self.proposal = nil
|
||||
self.operation = .move
|
||||
// The reload-resolved drag set: vanished members leave it silently, which is what
|
||||
// `survivors` reads and what "an emptied drag cancels itself" is stated in terms of.
|
||||
source.transient.dragMembers = ItemReferenceSet(ids: memberSet, liveness: side)
|
||||
source.transient.dragMembers = ItemReferenceSet(ids: memberSet, container: container)
|
||||
armWatchdog()
|
||||
}
|
||||
|
||||
@@ -538,7 +540,7 @@ final class DragSession {
|
||||
guard let kind, let sourceRoot else { return operation }
|
||||
let resolved = DragLocality.operation(
|
||||
kind: kind,
|
||||
side: side,
|
||||
container: container,
|
||||
isWithinBoard: DragLocality.isSameBoard(sourceRoot, destinationRoot),
|
||||
modifiers: NSEvent.modifierFlags
|
||||
)
|
||||
|
||||
@@ -126,7 +126,7 @@ struct LaneView: View {
|
||||
// the drag off until the pointer actually moves, so a click is never a drag.
|
||||
.onTapGesture {
|
||||
store.click(
|
||||
SelectionTarget(id: lane.id, kind: .lane, side: .live),
|
||||
SelectionTarget(id: lane.id, kind: .lane, container: .board),
|
||||
modifier: .current,
|
||||
togglesOnRepeat: true
|
||||
)
|
||||
@@ -232,17 +232,17 @@ struct LaneView: View {
|
||||
/// alone — standard macOS context-menu targeting (the pathfinder's `styleSelection`, kept).
|
||||
/// Right-clicking something outside the selection acts on what was clicked.
|
||||
private var styleTarget: StyleTarget {
|
||||
guard store.selection.liveness == .live, store.selection.ids.contains(lane.id) else {
|
||||
guard store.selection.container == .board, store.selection.ids.contains(lane.id) else {
|
||||
return .items([lane.id])
|
||||
}
|
||||
return .items(store.selection.ids)
|
||||
}
|
||||
|
||||
/// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
|
||||
/// because `store.delete(_:)` takes one directly (`TrashEntryRow.targetIDs`'s naming, reused here
|
||||
/// because `store.delete(_:)` takes one directly (the context-menu targeting rule, reused here
|
||||
/// on the live side).
|
||||
private var targetIDs: Set<ItemID> {
|
||||
guard store.selection.liveness == .live, store.selection.ids.contains(lane.id) else {
|
||||
guard store.selection.container == .board, store.selection.ids.contains(lane.id) else {
|
||||
return [lane.id]
|
||||
}
|
||||
return store.selection.ids
|
||||
@@ -352,20 +352,20 @@ struct LaneView: View {
|
||||
private func startLaneDrag() -> NSItemProvider {
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||
let selection = store.selection
|
||||
let ids: Set<ItemID> = selection.liveness == .live
|
||||
let ids: Set<ItemID> = selection.container == .board
|
||||
&& selection.ids.contains(lane.id)
|
||||
&& selection.ids.count > 1
|
||||
? selection.ids
|
||||
: [lane.id]
|
||||
|
||||
let members = store.snapshot.lanes.filter { !$0.isDeleted && ids.contains($0.id) }
|
||||
let members = store.snapshot.lanes.filter { ids.contains($0.id) }
|
||||
guard !members.isEmpty else { return NSItemProvider() }
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
boardRoot: root,
|
||||
kind: .lanes,
|
||||
side: .live,
|
||||
container: .board,
|
||||
items: members.map {
|
||||
DragPayload.Item(
|
||||
id: $0.id.rawValue,
|
||||
@@ -405,7 +405,7 @@ struct LaneView: View {
|
||||
|
||||
private var draggedLaneCount: Int {
|
||||
let selection = store.selection
|
||||
guard selection.liveness == .live, selection.ids.contains(lane.id) else { return 1 }
|
||||
guard selection.container == .board, selection.ids.contains(lane.id) else { return 1 }
|
||||
return selection.ids.count
|
||||
}
|
||||
|
||||
@@ -544,7 +544,7 @@ struct LaneView: View {
|
||||
// shares (04-interactions.md § Selection), and the modifier grammar on top of it.
|
||||
.onTapGesture {
|
||||
store.click(
|
||||
SelectionTarget(id: lane.id, kind: .lane, side: .live),
|
||||
SelectionTarget(id: lane.id, kind: .lane, container: .board),
|
||||
modifier: .current,
|
||||
togglesOnRepeat: true
|
||||
)
|
||||
@@ -552,7 +552,7 @@ struct LaneView: View {
|
||||
// The rubber band's first surface — "click-drag rubber-bands across lanes". Simultaneous
|
||||
// so the taps above stay instant; the band's own begin guard is what keeps a drag that
|
||||
// started on a card face out of it (`MarqueeControl`).
|
||||
.simultaneousGesture(marquee.gesture(side: .live))
|
||||
.simultaneousGesture(marquee.gesture(in: .board))
|
||||
// The same menu the header carries — "one menu, invoked on the header or lane empty
|
||||
// space alike" (03-board-ui.md § Lane, settled).
|
||||
.contextMenu { laneMenu }
|
||||
@@ -688,7 +688,7 @@ struct LaneView: View {
|
||||
renaming: ItemID?
|
||||
) -> [Card] {
|
||||
cards.filter { card in
|
||||
guard !card.isDeleted, !hidden.contains(card.id) else { return false }
|
||||
guard !hidden.contains(card.id) else { return false }
|
||||
return filter.matches(card) || card.id == renaming
|
||||
}
|
||||
}
|
||||
@@ -696,7 +696,7 @@ struct LaneView: View {
|
||||
// MARK: - Selection
|
||||
|
||||
private var isSelected: Bool {
|
||||
store.selection.liveness == .live && store.selection.ids.contains(lane.id)
|
||||
store.selection.container == .board && store.selection.ids.contains(lane.id)
|
||||
}
|
||||
|
||||
/// The selection treatment: a subtle whole-lane accent wash and stroke. Deliberately quiet —
|
||||
@@ -922,7 +922,7 @@ private struct CardFaceView: View {
|
||||
// Board ▸ Rename. The modifier grammar — plain replaces, ⌘ toggles, ⇧ ranges — is
|
||||
// `SelectionGrammar`'s, reached through the store's one funnel.
|
||||
.onTapGesture {
|
||||
store.click(SelectionTarget(id: card.id, kind: .card, side: .live), modifier: .current)
|
||||
store.click(SelectionTarget(id: card.id, kind: .card, container: .board), modifier: .current)
|
||||
}
|
||||
// "A fast double-click opens the card window (⌘↩'s pointer twin)" (04 ▸ Selection).
|
||||
//
|
||||
@@ -948,7 +948,7 @@ private struct CardFaceView: View {
|
||||
drops.registry.update(height: height, for: card.id)
|
||||
}
|
||||
.onDisappear { drops.registry.removeHeight(card.id) }
|
||||
.marqueeTarget(card.id, kind: .card, side: .live, in: marquee.registry)
|
||||
.marqueeTarget(card.id, kind: .card, container: .board, in: marquee.registry)
|
||||
.contextMenu { cardMenu }
|
||||
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
||||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||||
@@ -960,7 +960,7 @@ private struct CardFaceView: View {
|
||||
/// Begins the card's system drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order** —
|
||||
/// "lane `order` first, then card `order`", `SelectionGrammar.liveCards`' single definition of
|
||||
/// "lane `order` first, then card `order`", `SelectionGrammar.boardCards`' single definition of
|
||||
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
|
||||
///
|
||||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||||
@@ -969,7 +969,7 @@ private struct CardFaceView: View {
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||
let snapshot = store.snapshot
|
||||
let selection = store.selection
|
||||
let ids: Set<ItemID> = selection.liveness == .live
|
||||
let ids: Set<ItemID> = selection.container == .board
|
||||
&& selection.ids.contains(card.id)
|
||||
&& selection.ids.count > 1
|
||||
? selection.ids
|
||||
@@ -978,21 +978,21 @@ private struct CardFaceView: View {
|
||||
// Flatten order, and the lane each member currently lives in — the folder path's middle
|
||||
// component.
|
||||
var lanesByCard: [ItemID: ItemID] = [:]
|
||||
var titles: [ItemID: String?] = [:]
|
||||
for lane in snapshot.lanes where !lane.isDeleted {
|
||||
for member in lane.cards where !member.isDeleted && ids.contains(member.id) {
|
||||
var titles: [ItemID: String] = [:]
|
||||
for lane in snapshot.lanes {
|
||||
for member in lane.cards where ids.contains(member.id) {
|
||||
lanesByCard[member.id] = lane.id
|
||||
titles[member.id] = member.title.value
|
||||
}
|
||||
}
|
||||
let ordered = SelectionGrammar.liveCards(in: snapshot).filter { ids.contains($0) }
|
||||
let ordered = SelectionGrammar.boardCards(in: snapshot).filter { ids.contains($0) }
|
||||
guard !ordered.isEmpty else { return NSItemProvider() }
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
boardRoot: root,
|
||||
kind: .cards,
|
||||
side: .live,
|
||||
container: .board,
|
||||
items: ordered.compactMap { id in
|
||||
guard let laneID = lanesByCard[id] else { return nil }
|
||||
return DragPayload.Item(
|
||||
@@ -1001,7 +1001,7 @@ private struct CardFaceView: View {
|
||||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(id.rawValue, isDirectory: true)
|
||||
.path,
|
||||
title: titles[id] ?? nil
|
||||
title: titles[id]
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -1012,7 +1012,7 @@ private struct CardFaceView: View {
|
||||
// replica, and its lingering "last measured frame" would mis-size the shadow and the
|
||||
// span-cap (03-board-ui.md § Motion).
|
||||
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
|
||||
side: .live,
|
||||
container: .board,
|
||||
source: store
|
||||
)
|
||||
return payload.itemProvider()
|
||||
@@ -1021,7 +1021,7 @@ private struct CardFaceView: View {
|
||||
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
|
||||
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
|
||||
private var dragReplica: some View {
|
||||
let count = store.selection.liveness == .live && store.selection.ids.contains(card.id)
|
||||
let count = store.selection.container == .board && store.selection.ids.contains(card.id)
|
||||
? max(1, store.selection.ids.count)
|
||||
: 1
|
||||
return ZStack {
|
||||
@@ -1098,17 +1098,17 @@ private struct CardFaceView: View {
|
||||
/// alone — standard macOS context-menu targeting, shared by Style… (`styleTarget`) and Delete
|
||||
/// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked.
|
||||
private var styleTarget: StyleTarget {
|
||||
guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else {
|
||||
guard store.selection.container == .board, store.selection.ids.contains(card.id) else {
|
||||
return .items([card.id])
|
||||
}
|
||||
return .items(store.selection.ids)
|
||||
}
|
||||
|
||||
/// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
|
||||
/// because `store.delete(_:)` takes one directly (`TrashEntryRow.targetIDs`'s naming, reused here
|
||||
/// because `store.delete(_:)` takes one directly (the context-menu targeting rule, reused here
|
||||
/// on the live side).
|
||||
private var targetIDs: Set<ItemID> {
|
||||
guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else {
|
||||
guard store.selection.container == .board, store.selection.ids.contains(card.id) else {
|
||||
return [card.id]
|
||||
}
|
||||
return store.selection.ids
|
||||
@@ -1205,7 +1205,7 @@ private struct CardFaceView: View {
|
||||
// MARK: - Selection and rename plumbing
|
||||
|
||||
private var isSelected: Bool {
|
||||
store.selection.liveness == .live && store.selection.ids.contains(card.id)
|
||||
store.selection.container == .board && store.selection.ids.contains(card.id)
|
||||
}
|
||||
|
||||
/// Whether an external Finder file drag is hovering **this** card — the attach highlight
|
||||
@@ -1330,14 +1330,14 @@ private struct NewCardStubView: View {
|
||||
///
|
||||
/// The lane is read before the commit, because every discard path clears the overlay that holds
|
||||
/// it — and re-checked after, because one of those paths is *the lane vanished*, and selecting
|
||||
/// something that renders nowhere would break the homogeneous-by-liveness invariant until the
|
||||
/// something that renders nowhere would break the one-container invariant until the
|
||||
/// next reload swept it away.
|
||||
@discardableResult
|
||||
private func commit() -> ItemID? {
|
||||
let lane = store.transient.newCardPlaceholder?.laneID
|
||||
let id = store.commitPlaceholder()
|
||||
if let lane, store.snapshot.lanes.contains(where: { $0.id == lane && !$0.isDeleted }) {
|
||||
store.select([lane], liveness: .live)
|
||||
if let lane, store.snapshot.lanes.contains(where: { $0.id == lane }) {
|
||||
store.select([lane], in: .board)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ final class MarqueeSession {
|
||||
private(set) var current: CGPoint = .zero
|
||||
|
||||
/// The side of the live/trash boundary this band selects on, frozen at `begin`.
|
||||
private(set) var side: Liveness = .live
|
||||
private(set) var container: ItemContainer = .board
|
||||
|
||||
/// How far the pointer must travel before a drag on empty space becomes a band. Larger than the
|
||||
/// lane header's threshold because this gesture arms on *any* empty surface, and a click that
|
||||
@@ -51,10 +51,10 @@ final class MarqueeSession {
|
||||
)
|
||||
}
|
||||
|
||||
func begin(at point: CGPoint, side: Liveness) {
|
||||
func begin(at point: CGPoint, in container: ItemContainer) {
|
||||
origin = point
|
||||
current = point
|
||||
self.side = side
|
||||
self.container = container
|
||||
}
|
||||
|
||||
func update(to point: CGPoint) {
|
||||
@@ -81,9 +81,9 @@ final class MarqueeSession {
|
||||
/// is on screen. It is also what makes the band correct for free across a lane resize, a reorder in
|
||||
/// flight, and a foreign reload — the frames simply re-register.
|
||||
///
|
||||
/// **Lanes are never registered.** The band selects cards, and trash rows on the trash side; a lane
|
||||
/// has no entry here at all, which is 04-interactions.md § Selection's "click-drag rubber-bands
|
||||
/// across lanes" made structural rather than filtered.
|
||||
/// **Lanes are never registered.** The band selects cards — board cards or trash cards; a lane has
|
||||
/// no entry here at all, which is 04-interactions.md § Selection's "click-drag rubber-bands across
|
||||
/// lanes" made structural rather than filtered.
|
||||
///
|
||||
/// It is also the **begin guard's** universe: a drag that starts inside a registered frame belongs
|
||||
/// to that item's own gesture (a card drag, a drag out of the trash), never to the band. Deciding
|
||||
|
||||
@@ -41,7 +41,7 @@ enum NewCardTarget {
|
||||
/// the two can never disagree.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - selection: the board's current selection, liveness side included. A `.trashed` selection
|
||||
/// - selection: the board's current selection, container included. A `.trash` selection
|
||||
/// "never anchors creation" and is treated exactly as an empty one — the settled precedent
|
||||
/// 04 ▸ Clipboard cites for paste, applied here to its source rule ("a trashed card's live
|
||||
/// disk-lane never leaks in as 'the selected card's lane'").
|
||||
@@ -53,7 +53,7 @@ enum NewCardTarget {
|
||||
lastActiveLaneID: ItemID?,
|
||||
snapshot: BoardModel
|
||||
) -> Resolution? {
|
||||
let lanes = snapshot.lanes.filter { !$0.isDeleted }
|
||||
let lanes = snapshot.lanes
|
||||
guard !lanes.isEmpty else { return nil }
|
||||
|
||||
if let anchor = flattenAnchor(selection: selection, snapshot: snapshot) {
|
||||
@@ -86,21 +86,21 @@ enum NewCardTarget {
|
||||
/// which falls out of never looking at the trashed side at all), and a stale one whose ids name
|
||||
/// nothing the board renders.
|
||||
static func flattenAnchor(selection: ItemReferenceSet, snapshot: BoardModel) -> Resolution? {
|
||||
guard selection.liveness == .live, !selection.ids.isEmpty else { return nil }
|
||||
guard selection.container == .board, !selection.ids.isEmpty else { return nil }
|
||||
|
||||
// The snapshot's lanes and cards are already in display order, so the flatten order is one
|
||||
// walk, and the *last* hit is the anchor. Selection is homogeneous (cards XOR lanes), so only
|
||||
// one of the two branches ever fires within a walk; a sole selection is simply the degenerate
|
||||
// one-member case of the same rule.
|
||||
var anchor: Resolution?
|
||||
for lane in snapshot.lanes where !lane.isDeleted {
|
||||
for lane in snapshot.lanes {
|
||||
// A selected lane: creation appends at its bottom, Return consistency; paste lands after
|
||||
// the lane itself.
|
||||
if selection.ids.contains(lane.id) {
|
||||
anchor = Resolution(laneID: lane.id, anchorCardID: nil)
|
||||
}
|
||||
// A selected card: its lane, immediately after it — paste-anchor consistency.
|
||||
for card in lane.cards where !card.isDeleted && selection.ids.contains(card.id) {
|
||||
for card in lane.cards where selection.ids.contains(card.id) {
|
||||
anchor = Resolution(laneID: lane.id, anchorCardID: card.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,10 +50,10 @@ enum PasteTarget {
|
||||
lastActiveLaneID: lastActiveLaneID,
|
||||
snapshot: snapshot
|
||||
),
|
||||
let lane = snapshot.lanes.first(where: { $0.id == resolution.laneID && !$0.isDeleted })
|
||||
let lane = snapshot.lanes.first(where: { $0.id == resolution.laneID })
|
||||
else { return nil }
|
||||
|
||||
let rendered = lane.cards.filter { !$0.isDeleted }
|
||||
let rendered = lane.cards
|
||||
// `insertionIndex` answers `nil` for "append", which is `rendered.count` — the same position
|
||||
// said two ways, and the creation path's own degradation for an anchor that has since gone.
|
||||
let index = BoardStore.insertionIndex(after: resolution.anchorCardID, among: rendered) ?? rendered.count
|
||||
@@ -64,7 +64,7 @@ enum PasteTarget {
|
||||
/// included, because lane paste "stays enabled and lands at the board's right end" whatever the
|
||||
/// board holds. That is what makes it the other way out of a board with no lanes.
|
||||
static func lanes(selection: ItemReferenceSet, snapshot: BoardModel) -> Int {
|
||||
let lanes = snapshot.lanes.filter { !$0.isDeleted }
|
||||
let lanes = snapshot.lanes
|
||||
guard let anchor = NewCardTarget.flattenAnchor(selection: selection, snapshot: snapshot),
|
||||
let position = lanes.firstIndex(where: { $0.id == anchor.laneID })
|
||||
else {
|
||||
|
||||
@@ -47,24 +47,25 @@ struct MarqueeControl {
|
||||
/// loop simply keeps declining for the rest of that drag. Deciding this by frames rather than
|
||||
/// by gesture priority is what keeps the two from fighting, and it stays correct as the
|
||||
/// masonry reflows.
|
||||
/// - **The side is fixed at the origin** — 04-interactions.md ▸ The trash's rule, stored in the
|
||||
/// session so a band dragged across the boundary keeps its meaning.
|
||||
/// - **The container is fixed at the origin** — 04-interactions.md ▸ The trash's rule ("the
|
||||
/// rubber band stays on the side it started on"), stored in the session so a band dragged
|
||||
/// across the boundary keeps its meaning.
|
||||
/// - **Live-updating, not commit-on-release**: each sample recomputes the whole set from the
|
||||
/// band, so the selection follows the cursor both ways. An empty band clears rather than
|
||||
/// leaving the last non-empty one standing.
|
||||
/// - **Alive under the read-only lock**: selection is not a mutation (02-architecture.md § The
|
||||
/// lock's scope), and no `isEditingInline` guard either — a click-away mid-rename already
|
||||
/// commits through the field's own focus loss.
|
||||
func gesture(side: Liveness) -> some Gesture {
|
||||
func gesture(in container: ItemContainer) -> some Gesture {
|
||||
DragGesture(minimumDistance: MarqueeSession.minimumDistance, coordinateSpace: .named(BoardView.stripSpace))
|
||||
.onChanged { value in
|
||||
if !session.isActive {
|
||||
guard !registry.contains(value.startLocation) else { return }
|
||||
session.begin(at: value.startLocation, side: side)
|
||||
session.begin(at: value.startLocation, in: container)
|
||||
}
|
||||
session.update(to: value.location)
|
||||
guard let rect = session.rect else { return }
|
||||
let ids = MarqueeMath.selection(rect: rect, targets: registry.all, side: session.side)
|
||||
let ids = MarqueeMath.selection(rect: rect, targets: registry.all, in: session.container)
|
||||
if ids.isEmpty {
|
||||
store.clearSelection()
|
||||
} else {
|
||||
@@ -74,7 +75,7 @@ struct MarqueeControl {
|
||||
// Both are spelled out rather than defaulted, because `select`'s sole-member
|
||||
// default would otherwise pick one up the moment a band happened to sweep
|
||||
// exactly one card.
|
||||
store.select(ids, liveness: session.side, anchor: nil, head: nil)
|
||||
store.select(ids, in: session.container, anchor: nil, head: nil)
|
||||
}
|
||||
}
|
||||
.onEnded { _ in session.end() }
|
||||
@@ -111,7 +112,7 @@ extension View {
|
||||
func marqueeTarget(
|
||||
_ id: ItemID,
|
||||
kind: SelectionKind,
|
||||
side: Liveness,
|
||||
container: ItemContainer,
|
||||
in registry: MarqueeTargetRegistry
|
||||
) -> some View {
|
||||
// The space name is read here, on the main actor, rather than inside the measuring closure:
|
||||
@@ -120,7 +121,7 @@ extension View {
|
||||
return onGeometryChange(for: CGRect.self) { proxy in
|
||||
proxy.frame(in: .named(space))
|
||||
} action: { frame in
|
||||
registry.update(MarqueeTarget(id: id, kind: kind, side: side, frame: frame))
|
||||
registry.update(MarqueeTarget(id: id, kind: kind, container: container, frame: frame))
|
||||
}
|
||||
.onDisappear { registry.remove(id) }
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ final class TrashConfirmations {
|
||||
/// 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.
|
||||
/// 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 {
|
||||
@@ -30,20 +30,46 @@ final class TrashConfirmations {
|
||||
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.
|
||||
/// 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, store.purgeIsUnrecoverable else {
|
||||
store.deleteSelection()
|
||||
return
|
||||
}
|
||||
guard let prompt = TrashModel.purgePrompt(
|
||||
for: store.selection.ids,
|
||||
in: .trash,
|
||||
snapshot: store.snapshot,
|
||||
unrecoverable: true
|
||||
) else { return }
|
||||
pending = Pending(prompt: prompt, action: .deleteTrashCards(store.selection.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 two call sites.
|
||||
/// expression rather than three call sites.
|
||||
func requestPurge(of ids: Set<ItemID>, in store: BoardStore) {
|
||||
guard store.purgeIsUnrecoverable else {
|
||||
store.deleteImmediately(ids)
|
||||
@@ -51,7 +77,8 @@ final class TrashConfirmations {
|
||||
}
|
||||
guard let prompt = TrashModel.purgePrompt(
|
||||
for: ids,
|
||||
in: store.snapshot,
|
||||
in: store.selection.container,
|
||||
snapshot: store.snapshot,
|
||||
unrecoverable: true
|
||||
) else { return }
|
||||
pending = Pending(prompt: prompt, action: .purge(ids))
|
||||
@@ -73,6 +100,7 @@ final class TrashConfirmations {
|
||||
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()
|
||||
}
|
||||
@@ -96,22 +124,17 @@ extension FocusedValues {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File ▸ Delete / Put Back / Delete Immediately / Empty Trash…
|
||||
// MARK: - File ▸ Delete / Delete Immediately / Empty Trash…
|
||||
|
||||
/// The File menu's trash rows (11-command-nexus.md).
|
||||
///
|
||||
/// ### The ⌘⌫ chord twins
|
||||
/// ### One Delete, staged by place
|
||||
///
|
||||
/// 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.
|
||||
/// **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
|
||||
@@ -119,24 +142,18 @@ struct TrashCommands: View {
|
||||
|
||||
var body: some View {
|
||||
Button("Delete") {
|
||||
store?.deleteSelection()
|
||||
guard let store, let confirmations else { return }
|
||||
confirmations.requestDelete(in: store)
|
||||
}
|
||||
.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)
|
||||
.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(!canActOnTrash || confirmations == nil)
|
||||
.disabled(!canDeleteImmediately || confirmations == nil)
|
||||
|
||||
Button("Empty Trash…") {
|
||||
guard let store, let confirmations else { return }
|
||||
@@ -146,29 +163,29 @@ struct TrashCommands: View {
|
||||
.disabled(!canEmptyTrash)
|
||||
}
|
||||
|
||||
/// A live, non-empty selection on a board that accepts writes.
|
||||
/// 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 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 {
|
||||
/// 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.canActOnTrash(selection: store.selection, in: store.snapshot)
|
||||
return TrashModel.canDeleteImmediately(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").
|
||||
/// "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").
|
||||
///
|
||||
/// The visibility clause is 04-interactions.md's, stated for the whole quasi-lane: "hidden, it is
|
||||
/// The visibility clause is 04-interactions.md's, stated for the whole column: "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)
|
||||
return !store.snapshot.trash.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,12 +214,10 @@ struct ShowTrashCommand: View {
|
||||
|
||||
/// 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.
|
||||
/// **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
|
||||
@@ -237,7 +252,7 @@ extension BoardStore {
|
||||
// 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.liveness == .trashed {
|
||||
if !shown, selection.container == .trash {
|
||||
clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
+121
-177
@@ -4,63 +4,55 @@ import SwiftUI
|
||||
|
||||
// MARK: - TrashLaneView
|
||||
|
||||
/// The trash quasi-lane: the trailing, visually distinct column tombstoned items live in
|
||||
/// (03-board-ui.md § Trash).
|
||||
/// The trash column: the trailing, visually distinct column the board's `.trash/` cards live in
|
||||
/// (03-board-ui.md § Trash, resettled 2026-07-28 — the materialized trash).
|
||||
///
|
||||
/// ### A pure view, and a quasi-lane
|
||||
/// ### A rendering of `snapshot.trash`, 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:
|
||||
/// **Its contents are ordinary cards in a special place**, so there is nothing to derive: the column
|
||||
/// renders `store.snapshot.trash`, which the loader parsed with the same card parse the lanes use
|
||||
/// and sorted by `order` like any lane's children. Newest-first falls out of the ranks (every
|
||||
/// arrival mints one above the current top), so there is no timestamp sort and no entry type here at
|
||||
/// all. 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 drop proposal's slot list by construction, since `BoardView` builds that from the
|
||||
/// snapshot's live lanes;
|
||||
/// - it is **no destination for a move** — "no move or paste ever targets the trash"
|
||||
/// (04-interactions.md ▸ The trash). It is a destination for exactly one thing, below;
|
||||
/// snapshot's lanes;
|
||||
/// - it has **no new-card button**: nothing is created in the trash.
|
||||
///
|
||||
/// ### The one drop it takes: the delete gesture
|
||||
/// ### The drop it takes, and the drag it starts
|
||||
///
|
||||
/// **"Dropping a live card on the shown trash deletes it"** (04-interactions.md ▸ The trash, settled
|
||||
/// 2026-07-28) — "the drag becomes the pointer's delete gesture; release tombstones the dragged
|
||||
/// card(s), exactly the ⌫ tombstone". So the column does declare an `onDrop`
|
||||
/// (`TrashDropDelegate`), and it is the narrowest one on the board: a **live** card drag from **this**
|
||||
/// board, unmodified. Lanes are not deliverable this way, a foreign board's card is not (that would
|
||||
/// be a transfer-and-delete compound), ⌥ is not (copying into the trash is not a thing), and hidden
|
||||
/// the column is not rendered at all, so it has no region to enter. Every one of those refusals hands
|
||||
/// the session back to the strip's own logic, which is exactly what happened here before this target
|
||||
/// existed — so nothing about the column's behaviour changed except the gesture that is new
|
||||
/// (`TrashDrop`).
|
||||
/// **"Dropping a live card on the shown trash deletes it"** (04-interactions.md ▸ The trash) — the
|
||||
/// drag becomes the pointer's delete gesture, and release moves the dragged card(s) into `.trash/`.
|
||||
/// So the column declares an `onDrop` (`TrashDropDelegate`), and it is the narrowest one on the
|
||||
/// board: a board card drag from **this** board, unmodified. It diverges from every other drop in one
|
||||
/// way, and the ranks are what make the divergence honest: **the shadow always takes the topmost
|
||||
/// row**, because every arrival mints a rank above the current top.
|
||||
///
|
||||
/// It diverges from every other drop on the board in one way, and the sort is what makes the
|
||||
/// divergence honest: **the shadow always takes the topmost row**, because the trash orders by
|
||||
/// `deleted` newest-first and a fresh tombstone genuinely lands on top. The drop still lands exactly
|
||||
/// where the shadow shows.
|
||||
/// The drag *out* is the restore, and it is deliberately not special: a trash card's drag is an
|
||||
/// ordinary `.cards` session in the `.trash` container, and `BoardDropContext.commitDrop` hands it
|
||||
/// to the same `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an
|
||||
/// ordinary move out … there is no restore-specific machinery and no Put Back" (03 § Trash).
|
||||
///
|
||||
/// **Finder file drops stay inert** — "attachment import on tombstoned cards is inert" (▸ The trash)
|
||||
/// — and now say so directly: the delegate clears the file highlight over the column rather than
|
||||
/// relying on the strip resolving to no lane.
|
||||
/// **Finder file drops stay inert** — "Finder file drops on trash cards are inert" (▸ The trash) —
|
||||
/// and say so directly: the delegate clears the file highlight over the column.
|
||||
///
|
||||
/// ### 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.
|
||||
/// "No editing in the trash: trash cards don't open — double-click stops at selection; move it out
|
||||
/// first." There is deliberately no double-tap recogniser, no rename editor, and no Style… row.
|
||||
///
|
||||
/// ### What is still a later card's
|
||||
/// ### What is still phase 3's
|
||||
///
|
||||
/// **⌘C copy-out** is still owed. The **search filter** ("shown, it participates in the filter like
|
||||
/// any lane") arrived with m5 and is one line — see `entries`, which every other surface here reads
|
||||
/// through. The *pointer* grammar is here: a row's click runs the same `SelectionGrammar` the
|
||||
/// board does, and the column's empty space rubber-bands on the trashed side. So is the drop, with
|
||||
/// the target lane highlighted and the source row dimmed in place; the drag's replica is not. The
|
||||
/// **keyboard** reaches the column entirely through the frames the rows register — arrow walks in
|
||||
/// and out, ⇧-arrows inert at both the liveness and the kind boundary — so nothing in this file
|
||||
/// implements it beyond keeping every row drawn and registered (see `rows`).
|
||||
/// The column renders the materialized trash correctly and its selection, drag, drop, filter and
|
||||
/// context menu all speak the new container vocabulary — but its *visual* treatment is still the
|
||||
/// tombstone era's compact dimmed plate rather than the card face 03 now implies ("a trashed card is
|
||||
/// an ordinary card in a special place"). Reworking the plate into the ordinary face, and the
|
||||
/// accessibility labelling 10-accessibility.md asks for, is the trash's own phase-3 card.
|
||||
struct TrashLaneView: View {
|
||||
|
||||
let store: BoardStore
|
||||
@@ -71,13 +63,13 @@ struct TrashLaneView: View {
|
||||
/// does.
|
||||
let confirmations: TrashConfirmations
|
||||
|
||||
/// The board window's drop machinery — a row's drag is an ordinary card session on the
|
||||
/// **trashed** side (`DragSession`, 04-interactions.md ▸ The trash).
|
||||
/// The board window's drop machinery — a card's drag is an ordinary card session in the
|
||||
/// **trash** container (`DragSession`, 04-interactions.md ▸ The trash).
|
||||
let drops: BoardDropContext
|
||||
|
||||
/// The strip's rubber band. The column's empty space is its third surface, on the **trashed**
|
||||
/// side — "a rubber-band stays on the side of the boundary it started on" (04-interactions.md ▸
|
||||
/// The trash) — and every row registers its frame into the same registry.
|
||||
/// The strip's rubber band. The column's empty space is its third surface, in the **trash**
|
||||
/// container — "the rubber band stays on the side it started on" (04-interactions.md ▸ The
|
||||
/// trash) — and every row registers its frame into the same registry.
|
||||
let marquee: MarqueeControl
|
||||
|
||||
/// Reduce Motion, for the row transition below — 10-accessibility.md names the trash
|
||||
@@ -91,9 +83,9 @@ struct TrashLaneView: View {
|
||||
private let rowSpacing: CGFloat = 6
|
||||
|
||||
/// The height a shadow row holds open. A trash row's height is content-driven (one or two title
|
||||
/// lines, plus a lane entry's count line) and the cards being proposed have no row yet to be
|
||||
/// measured, so the shadow is drawn at the nominal single-line plate — `LaneDropRegistry`'s own
|
||||
/// answer to the same question, in this column's smaller idiom.
|
||||
/// lines) and the cards being proposed have no row yet to be measured, so the shadow is drawn at
|
||||
/// the nominal single-line plate — `LaneDropRegistry`'s own answer to the same question, in this
|
||||
/// column's smaller idiom.
|
||||
private let nominalRowHeight: CGFloat = 32
|
||||
|
||||
var body: some View {
|
||||
@@ -113,16 +105,16 @@ struct TrashLaneView: View {
|
||||
.onDrop(of: boardDropTypes, delegate: TrashDropDelegate(context: drops))
|
||||
}
|
||||
|
||||
/// The rows the column shows.
|
||||
/// The cards the column shows.
|
||||
///
|
||||
/// **The shown trash "participates in the filter like any lane"** (03-board-ui.md § Trash), so
|
||||
/// the search predicate narrows this collection exactly as it narrows `LaneView.renderedCards`
|
||||
/// — card rows and lane rows alike, each by its own title and body (`SearchFilter`) — and the
|
||||
/// count badge follows for free, because it reads this same value. Hidden, the column renders
|
||||
/// nothing and registers nothing, so "hidden trash is invisible to search" needs no code at all.
|
||||
private var entries: [TrashEntry] {
|
||||
/// **Shown, the trash's cards "participate in the filter exactly like any other card"**
|
||||
/// (03-board-ui.md § Trash — "the point of the pivot"), 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. Hidden, the column renders nothing and registers
|
||||
/// nothing, so "hidden trash is invisible to search" needs no code at all.
|
||||
private var cards: [Card] {
|
||||
let filter = store.searchFilter
|
||||
return TrashModel.entries(of: store.snapshot).filter { filter.matches($0) }
|
||||
return store.snapshot.trash.filter { filter.matches($0) }
|
||||
}
|
||||
|
||||
// MARK: - The delete gesture's landing
|
||||
@@ -140,7 +132,7 @@ struct TrashLaneView: View {
|
||||
/// keeps the arrangement the release proposed on screen for that round trip, exactly as every
|
||||
/// other container's does (`CommittedHold`).
|
||||
private var slots: [TrashSlot] {
|
||||
var result = entries.map(TrashSlot.entry)
|
||||
var result = cards.map(TrashSlot.card)
|
||||
guard let proposal else { return result }
|
||||
let run = (0..<drops.session.shadowCount).map(TrashSlot.shadow)
|
||||
result.insert(contentsOf: run, at: min(max(0, proposal), result.count))
|
||||
@@ -186,11 +178,10 @@ struct TrashLaneView: View {
|
||||
.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).
|
||||
/// The card count — the same collection the body renders, so the badge cannot disagree with
|
||||
/// what is on screen (`LaneView.countBadge`'s rule).
|
||||
private var countBadge: some View {
|
||||
Text("\(entries.count)")
|
||||
Text("\(cards.count)")
|
||||
.font(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -210,7 +201,7 @@ struct TrashLaneView: View {
|
||||
ScrollViewReader { proxy in
|
||||
scrollableRows
|
||||
.onChange(of: store.transient.selectionHead) { _, head in
|
||||
guard let head, entries.contains(where: { $0.id == head }) else { return }
|
||||
guard let head, cards.contains(where: { $0.id == head }) else { return }
|
||||
proxy.scrollTo(TrashSlot.identity(of: head))
|
||||
}
|
||||
}
|
||||
@@ -227,10 +218,10 @@ struct TrashLaneView: View {
|
||||
ForEach(slots) { slot in
|
||||
Group {
|
||||
switch slot {
|
||||
case let .entry(entry):
|
||||
TrashEntryRow(
|
||||
case let .card(card):
|
||||
TrashCardRow(
|
||||
store: store,
|
||||
entry: entry,
|
||||
card: card,
|
||||
confirmations: confirmations,
|
||||
drops: drops,
|
||||
registry: marquee.registry
|
||||
@@ -243,10 +234,10 @@ struct TrashLaneView: View {
|
||||
.frame(height: nominalRowHeight)
|
||||
}
|
||||
}
|
||||
// A row is a tombstoned item, so it arrives and leaves in the card's dialect —
|
||||
// a delete files one in, a Put Back or a purge takes one out, and both halves of
|
||||
// that pair should read alike from either side of the strip. The transaction is
|
||||
// the reload's, like the lanes' (`Motion.reloadAnimates`).
|
||||
// A row is a card, so it arrives and leaves in the card's dialect — a delete
|
||||
// files one in, a restore or a purge takes one out, and both halves of that pair
|
||||
// should read alike from either side of the strip. The transaction is the
|
||||
// reload's, like the lanes' (`Motion.reloadAnimates`).
|
||||
.transition(Motion.cardTransition(reduced: reduceMotion))
|
||||
// The scroll target — `LaneView`'s rule, and outermost for its reason.
|
||||
.id(slot.id)
|
||||
@@ -271,7 +262,7 @@ struct TrashLaneView: View {
|
||||
// as the board background allows on the live side — a drag begun on a row instead is that
|
||||
// row's drag-out, and the begin guard makes that geometric rather than a matter of
|
||||
// gesture priority (`MarqueeControl`).
|
||||
.simultaneousGesture(marquee.gesture(side: .trashed))
|
||||
.simultaneousGesture(marquee.gesture(in: .trash))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,13 +280,10 @@ private struct TrashRowPlate: View {
|
||||
|
||||
let symbol: String
|
||||
|
||||
/// The title as written, or `nil` for an untitled item — "Untitled" is a rendering, never a value
|
||||
/// The title as written, or `nil` for an untitled card — "Untitled" is a rendering, never a value
|
||||
/// (03-board-ui.md § Card face).
|
||||
let title: String?
|
||||
|
||||
/// A lane entry's returning-card count, and nothing else takes a second line.
|
||||
let subtitle: String?
|
||||
|
||||
var isSelected: Bool = false
|
||||
|
||||
private let cornerRadius: CGFloat = 6
|
||||
@@ -305,18 +293,11 @@ private struct TrashRowPlate: View {
|
||||
Image(systemName: symbol)
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.small)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title ?? "Untitled")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(title ?? "Untitled")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
@@ -336,22 +317,22 @@ private struct TrashRowPlate: View {
|
||||
/// echo reload reads as a *swap* or as a removal and an insertion.
|
||||
private enum TrashSlot: Identifiable {
|
||||
|
||||
/// A tombstone the snapshot already holds.
|
||||
case entry(TrashEntry)
|
||||
/// A card the snapshot's trash already holds.
|
||||
case card(Card)
|
||||
|
||||
/// One of the drag's N shadows, holding the topmost rows open (04-interactions.md ▸ The trash).
|
||||
case shadow(index: Int)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .entry(entry): Self.identity(of: entry.id)
|
||||
case let .card(card): Self.identity(of: card.id)
|
||||
// Constant per position in the run, so a run that grows or shrinks animates as slots rather
|
||||
// than blinking (`LaneSlot`'s rule).
|
||||
case let .shadow(index): "shadow:\(index)"
|
||||
}
|
||||
}
|
||||
|
||||
/// An entry slot's id, spelled once so `scrollTo` and the slot cannot disagree about what the
|
||||
/// A card slot's id, spelled once so `scrollTo` and the slot cannot disagree about what the
|
||||
/// scroll reader is looking for (`LaneSlot.identity(of:)`).
|
||||
static func identity(of item: ItemID) -> String { "entry:\(item.rawValue)" }
|
||||
}
|
||||
@@ -381,145 +362,113 @@ private struct DiagonalHatch: Shape {
|
||||
|
||||
// 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.
|
||||
/// One trash row: a compact, dimmed plate carrying the card's symbol and title.
|
||||
///
|
||||
/// **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…. 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 {
|
||||
/// than as a pile of `disabled` modifiers. (Making it the ordinary card face is phase 3's — see
|
||||
/// `TrashLaneView`.)
|
||||
private struct TrashCardRow: View {
|
||||
|
||||
let store: BoardStore
|
||||
let entry: TrashEntry
|
||||
let card: Card
|
||||
let confirmations: TrashConfirmations
|
||||
let drops: BoardDropContext
|
||||
|
||||
/// Where the rubber band looks up what it is sweeping — the card face's rule, on the trashed
|
||||
/// side (`View.marqueeTarget`).
|
||||
/// Where the rubber band looks up what it is sweeping — the card face's rule, in the trash
|
||||
/// container (`View.marqueeTarget`).
|
||||
let registry: MarqueeTargetRegistry
|
||||
|
||||
/// **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 rather than refused — no session, no image, no snap-back. A click
|
||||
/// still selects either way.
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
if entry.isLaneEntry {
|
||||
plate
|
||||
} else {
|
||||
plate.onDrag(startRowDrag, preview: { dragReplica })
|
||||
}
|
||||
plate.onDrag(startRowDrag, preview: { dragReplica })
|
||||
}
|
||||
|
||||
private var plate: some View {
|
||||
rowFace
|
||||
// The row being dragged out dims in place — the source stays visible in the trash,
|
||||
// because a restore is not a removal until the write lands.
|
||||
.opacity(drops.session.isDragging(entry.id) ? ClipboardTreatment.dimmedOpacity : 1)
|
||||
// The deferred cut wears the same dim wherever it lands, so the treatment is stated for
|
||||
// every surface a `pendingCut` could name rather than for two of the three. In practice
|
||||
// it never fires here: ⌘X is disabled on tombstoned selections (04-interactions.md ▸ The
|
||||
// trash), and a pending cut is homogeneous by liveness — a reload that tombstones a cut
|
||||
// card *ejects* it from the set rather than moving it to the other side.
|
||||
.cutTreatment(of: entry.id, in: store)
|
||||
.opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
|
||||
// The deferred cut wears the same dim wherever it lands. It genuinely fires here now:
|
||||
// "⌘X works — cut in the trash, paste into a lane is the keyboard-native restore"
|
||||
// (04-interactions.md ▸ The trash, resettled 2026-07-28).
|
||||
.cutTreatment(of: card.id, in: store)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { select() }
|
||||
.marqueeTarget(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry)
|
||||
.marqueeTarget(card.id, kind: .card, container: .trash, in: registry)
|
||||
.contextMenu { menu }
|
||||
}
|
||||
|
||||
/// This entry as the shared plate draws it — appearance only, no gesture, no context menu and
|
||||
/// This card as the shared plate draws it — appearance only, no gesture, no context menu and
|
||||
/// crucially no marquee registration, which is what makes it safe for the drag replica to render
|
||||
/// (see `TrashRowPlate`).
|
||||
private var rowFace: some View {
|
||||
TrashRowPlate(
|
||||
symbol: ItemSymbol.name(entry.icon, fallback: symbolFallback),
|
||||
title: entry.title,
|
||||
subtitle: laneSubtitle,
|
||||
symbol: ItemSymbol.name(card.icon, fallback: ItemSymbol.card),
|
||||
title: card.title.value,
|
||||
isSelected: isSelected
|
||||
)
|
||||
}
|
||||
|
||||
private var symbolFallback: String {
|
||||
entry.isLaneEntry ? ItemSymbol.lane : ItemSymbol.card
|
||||
}
|
||||
|
||||
/// "N cards" — what Put Back brings back with a tombstoned lane, not how many folders sit inside
|
||||
/// it (`TrashModel.entries`' returning-count rule). Card entries have no second line.
|
||||
private var laneSubtitle: String? {
|
||||
guard case let .lane(_, returning) = entry else { return nil }
|
||||
return "\(returning) card\(returning == 1 ? "" : "s")"
|
||||
}
|
||||
|
||||
// MARK: - Selection
|
||||
|
||||
private var isSelected: Bool {
|
||||
store.selection.liveness == .trashed && store.selection.ids.contains(entry.id)
|
||||
store.selection.container == .trash && store.selection.ids.contains(card.id)
|
||||
}
|
||||
|
||||
/// A click selects this row on the **trashed** side, through the same grammar the board's
|
||||
/// A click selects this card in the **trash** container, through the same grammar the board's
|
||||
/// surfaces use — plain replaces, ⌘ toggles, ⇧ ranges (`SelectionGrammar`).
|
||||
///
|
||||
/// The row's kind travels with the click, and that is what keeps the trash's second homogeneity
|
||||
/// axis true: a ⌘-click across the card/lane-entry boundary replaces rather than mixing, and a
|
||||
/// ⇧-range walks only its own kind's rows (04-interactions.md ▸ The trash). No `togglesOnRepeat`
|
||||
/// — click-again-to-unselect is the lane's behaviour, not a row's.
|
||||
/// The container travels with the click, and that is what keeps the one remaining homogeneity
|
||||
/// boundary true: a ⌘-click across it replaces rather than mixing (04-interactions.md ▸ The
|
||||
/// trash). There is no kind axis inside the trash any more — lanes are never trashed. No
|
||||
/// `togglesOnRepeat` — click-again-to-unselect is the lane's behaviour, not a card's.
|
||||
///
|
||||
/// **A double click is two of these and nothing more**: no editor, no card window, no timer.
|
||||
private func select() {
|
||||
store.click(
|
||||
SelectionTarget(id: entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed),
|
||||
SelectionTarget(id: card.id, kind: .card, container: .trash),
|
||||
modifier: .current
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Drag out
|
||||
|
||||
/// Begins the row's drag out of the trash — an ordinary **card session on the trashed side**,
|
||||
/// which is the whole of what makes it a restore rather than a move (04-interactions.md ▸ The
|
||||
/// trash; `DragLocality.operation`).
|
||||
/// Begins the card's drag out of the trash — an ordinary **card session in the trash container**,
|
||||
/// which is the whole of what makes it a restore (04-interactions.md ▸ The trash;
|
||||
/// `DragLocality.operation`).
|
||||
///
|
||||
/// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the
|
||||
/// destination lane's masonry, so "restores it at the drop position" is the same arithmetic every
|
||||
/// other card drop uses. What a release *means* differs by locality and modifier, and that lives
|
||||
/// in one place (`BoardDropContext.commitDrop`): within the board a restore, ⌥ a live copy-out,
|
||||
/// across boards a live copy with ⌘ forcing the true restore-move.
|
||||
/// destination lane's masonry, so "an ordinary move to the drop position" is the same arithmetic
|
||||
/// every other card drop uses, committed by the same `moveCards`.
|
||||
///
|
||||
/// **Multi-drag carries the whole trashed selection**, in the trash's own sorted order — the
|
||||
/// order the rows are drawn in, which is the only relative order a set of tombstones has.
|
||||
/// **Multi-drag carries the whole trash selection**, in the column's own order — the order the
|
||||
/// rows are drawn in, which is `order` ascending like any lane's.
|
||||
///
|
||||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||||
/// mutating gesture.
|
||||
private func startRowDrag() -> NSItemProvider {
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||
let selection = store.selection
|
||||
let ids: Set<ItemID> = selection.liveness == .trashed
|
||||
&& selection.ids.contains(entry.id)
|
||||
let ids: Set<ItemID> = selection.container == .trash
|
||||
&& selection.ids.contains(card.id)
|
||||
&& selection.ids.count > 1
|
||||
? selection.ids
|
||||
: [entry.id]
|
||||
: [card.id]
|
||||
|
||||
// Card entries only: a lane entry cannot be dragged at all, so one caught up in a mixed
|
||||
// selection is simply not carried. (The selection is homogeneous by kind anyway — this is
|
||||
// belt over braces.)
|
||||
let rows: [(id: ItemID, laneID: ItemID, title: String?)] = TrashModel.entries(of: store.snapshot)
|
||||
.compactMap { candidate in
|
||||
guard ids.contains(candidate.id), case let .card(card, laneID) = candidate else { return nil }
|
||||
return (card.id, laneID, card.title.value)
|
||||
}
|
||||
let rows = store.snapshot.trash.filter { ids.contains($0.id) }
|
||||
guard !rows.isEmpty else { return NSItemProvider() }
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
boardRoot: root,
|
||||
kind: .cards,
|
||||
side: .trashed,
|
||||
container: .trash,
|
||||
items: rows.map {
|
||||
DragPayload.Item(
|
||||
id: $0.id.rawValue,
|
||||
folder: TrashModel.ItemPath(laneID: $0.laneID, cardID: $0.id).folder(under: root).path,
|
||||
title: $0.title
|
||||
folder: ItemPath.trashCard($0.id).folder(under: root).path,
|
||||
title: $0.title.value
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -527,7 +476,7 @@ private struct TrashEntryRow: View {
|
||||
rows.map(\.id),
|
||||
folders: payload.folders,
|
||||
heights: rows.map { _ in LaneDropRegistry.nominalCardHeight },
|
||||
side: .trashed,
|
||||
container: .trash,
|
||||
source: store
|
||||
)
|
||||
return payload.itemProvider()
|
||||
@@ -536,7 +485,7 @@ private struct TrashEntryRow: View {
|
||||
/// The image under the cursor: the row as it is drawn, fanned with a count badge for a
|
||||
/// multi-drag — the card replica's treatment, at a trash row's size.
|
||||
private var dragReplica: some View {
|
||||
let count = store.selection.liveness == .trashed && store.selection.ids.contains(entry.id)
|
||||
let count = store.selection.container == .trash && store.selection.ids.contains(card.id)
|
||||
? max(1, store.selection.ids.count)
|
||||
: 1
|
||||
return ZStack {
|
||||
@@ -549,22 +498,18 @@ private struct TrashEntryRow: View {
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
// MARK: - The trash entry's context menu
|
||||
// MARK: - The trash card's context menu
|
||||
|
||||
/// Put Back, Delete Immediately, Reveal in Finder — the three rows 11-command-nexus.md gives a
|
||||
/// trash entry, and no others.
|
||||
/// Delete and Reveal in Finder — the two rows 11-command-nexus.md gives a trash card, and no
|
||||
/// others ("Trash cards | Delete (permanent — 03's recoverability confirm), Reveal in Finder").
|
||||
///
|
||||
/// **Put Back is gone** with the tombstone model: restoring is drag-out or ⌘X/⌘V (03 § Trash).
|
||||
/// 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.
|
||||
/// enabled on trash 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") {
|
||||
Button("Delete") {
|
||||
confirmations.requestPurge(of: targetIDs, in: store)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
@@ -578,17 +523,16 @@ private struct TrashEntryRow: View {
|
||||
|
||||
/// 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.
|
||||
/// header apply to Style….
|
||||
private var targetIDs: Set<ItemID> {
|
||||
guard store.selection.liveness == .trashed, store.selection.ids.contains(entry.id) else {
|
||||
return [entry.id]
|
||||
guard store.selection.container == .trash, store.selection.ids.contains(card.id) else {
|
||||
return [card.id]
|
||||
}
|
||||
return store.selection.ids
|
||||
}
|
||||
|
||||
private var targetFolders: [URL] {
|
||||
TrashModel.paths(of: targetIDs, on: .trashed, in: store.snapshot)
|
||||
ItemPath.resolve(targetIDs, in: .trash, snapshot: store.snapshot)
|
||||
.map { $0.folder(under: store.rootURL) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user