Lanes delete into the trash — rendering, grammar, drag, clipboard, a11y
Phase 2 completes the lanes-in-trash card. TrashEntry merges the trash's two kinds by rank in exactly ONE place (ItemPath.resolve's own merge deleted in favor of it — the three-merge-points finding shrinks instead of growing). TrashLaneRowView renders the opaque row — tertiary plate, level-default lane glyph never the lane's own icon, title + card count, no accents, no expansion; the column badge counts rendered rows. Selection grammar: kind-homogeneous trash selections — ranges skip the other kind, ⇧-extension stops at the kind boundary, plain arrows walk the merged order, marquee stays card-only (now load-bearing: rows register frames for arrows), Select All card-scoped; successor-on-purge crosses kinds like navigation as the interim for open Gap 7b5cbc90. Drag: TrashDrop accepts lane sessions (drop on shown trash deletes), restoreLanes routes a trash-sourced strip drop as an arrival-ranked within-board move with an undo step. Clipboard: ⌘X/⌘V lane restore via opaque lane subjects; fixed boardRoot(ofLaneFolder:) returning .trash as the root — a same-board restore looked like an import and would have reminted the lane it was restoring (pinned by test). A11y: row = one flattened "title, deleted lane, N cards" element with Delete/Reveal actions; BoardDiff crossings read lanes as deleted/restored, shown-trash churn digested at row level. Agent guide stays v7 — the literal already teaches lanes-trash-by-move and kind stamping; drift-guard pins those lines. README trash paragraph notes lanes. Both schemes 1893 tests / 322 suites green. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -496,15 +496,22 @@ struct BoardView: View {
|
||||
store.transient.isTrashVisible
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// The trash's **rows** as the column is showing them, both kinds in rank order — the shown
|
||||
/// trash's contents "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 rows).
|
||||
///
|
||||
/// **Rows and not cards, because this is navigation** (04-interactions.md ▸ The trash: "inside,
|
||||
/// plain arrows walk every row, card and lane row alike (navigation crosses kinds)", and ▸
|
||||
/// Grammar gives ⌥→ "the shown non-empty trash — its first *entry*"). The kind-scoped lists are
|
||||
/// the *ranging* grammar's (`SelectionGrammar.order`), which is what stops a ⇧-arrow at the kind
|
||||
/// boundary while a plain one crosses it.
|
||||
///
|
||||
/// 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 card the
|
||||
/// order list, ⌥↑/⌥↓'s container, and ⌥→'s jump — so none of them can walk onto a row the
|
||||
/// filter took away.
|
||||
private var trashCards: [ItemID] {
|
||||
SelectionGrammar.trashCards(in: store.snapshot, filter: store.searchFilter)
|
||||
private var trashRows: [ItemID] {
|
||||
SelectionGrammar.trashRows(in: store.snapshot, filter: store.searchFilter)
|
||||
}
|
||||
|
||||
// MARK: - The drag
|
||||
@@ -795,11 +802,15 @@ 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 its cards**, top to bottom — there are no lane entries to interleave
|
||||
/// any more (03-board-ui.md § Trash: "Cards only").
|
||||
/// The **trash's list is its rows**, top to bottom, both kinds interleaved by rank (lanes
|
||||
/// rejoined 2026-07-29) — and the trash side is **never the lane domain**, whatever the
|
||||
/// selection's kind: the lane domain is the strip's grammar (←/→ walk lanes, ↓ descends into
|
||||
/// cards, ⌘←/⌘→ move one), and a trashed lane row is a row in a column, not a lane on the board
|
||||
/// ("The trash lane itself is never selectable *as a lane*" — 04 ▸ The trash). Its arrows are the
|
||||
/// spatial ones every row gets.
|
||||
///
|
||||
/// 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.
|
||||
/// fallback lands on the last *visible* member rather than on a row the query hid.
|
||||
private func arrowOrigin() -> (head: ItemID, container: ItemContainer, isLaneDomain: Bool)? {
|
||||
let selection = store.selection
|
||||
guard !selection.isEmpty else { return nil }
|
||||
@@ -813,7 +824,7 @@ struct BoardView: View {
|
||||
list = SelectionGrammar.order(of: kind, in: .board, snapshot: store.snapshot, filter: store.searchFilter)
|
||||
case .trash:
|
||||
isLaneDomain = false
|
||||
list = trashCards
|
||||
list = trashRows
|
||||
}
|
||||
|
||||
if let head = store.transient.selectionHead, list.contains(head) {
|
||||
@@ -915,13 +926,16 @@ struct BoardView: View {
|
||||
return .handled
|
||||
}
|
||||
|
||||
/// **⌥↑/⌥↓ jump to the current container's first/last card** — the lane's, or the trash
|
||||
/// trash column's when that is where the cursor is.
|
||||
/// **⌥↑/⌥↓ jump to the current container's first/last row** — the lane's cards, or the trash
|
||||
/// column's rows when that is where the cursor is.
|
||||
///
|
||||
/// **⌥↑ escalates into the lane domain** (04 ▸ Grammar, settled — "the keyboard's one entry to
|
||||
/// lane selection"): with the lane's first card already the sole selection, the next ⌥↑ selects
|
||||
/// the *lane* itself. The trash deliberately never escalates: it "is never selectable as a lane",
|
||||
/// so a second ⌥↑ there is simply inert.
|
||||
///
|
||||
/// **In the trash the jump crosses kinds**, like every other navigation there: the ends it names
|
||||
/// are the column's, so ⌥↑ from a card can land on a lane row sitting above it.
|
||||
private func jumpWithinContainer(
|
||||
_ direction: NavigationMath.Direction,
|
||||
from head: ItemID,
|
||||
@@ -931,7 +945,7 @@ struct BoardView: View {
|
||||
var lane: ItemID?
|
||||
switch container {
|
||||
case .trash:
|
||||
siblings = trashCards
|
||||
siblings = trashRows
|
||||
case .board:
|
||||
guard let home = store.snapshot.lanes.first(where: { lane in
|
||||
lane.cards.contains { $0.id == head }
|
||||
@@ -956,11 +970,12 @@ struct BoardView: View {
|
||||
/// first card, since ⌥↑ is the one keyboard entry to lane selection.
|
||||
///
|
||||
/// **⌥→ reaches the shown trash** first (04 ▸ The trash: "the shown trash is the last container
|
||||
/// for card navigation, and ⌥→ jumps to it"); an empty or hidden column is not a destination, so
|
||||
/// for card navigation, and ⌥→ jumps to it" — ▸ Grammar names the landing as "its first
|
||||
/// **entry**", which is a row of either kind); an empty or hidden column is not a destination, so
|
||||
/// 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 = trashCards.first {
|
||||
if direction == .right, isTrashVisible, let first = trashRows.first {
|
||||
replaceSelection(with: first, in: .trash)
|
||||
return .handled
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user