Implement the keyboard grammar and full command map
The board's fixed grammar keys and the menu-backed chords of 04-interactions.md § Keyboard, per the Command Nexus inventory: - Spatial arrow navigation (NavigationMath.nearest over the marquee registry's frames — one geometry source), walking across interior masonry columns, lanes, and into the shown trash; ⇧-arrows extend via the same range function as ⇧-click and go inert at the liveness and kind boundaries; ⌥-jumps with the ⌥↑ lane-domain escalation and ↓ descent; the empty selection seeds at the first lane's first card; selection scrolls into view. - selectionHead — the navigation cursor beside the anchor, set by every click, moved by every arrow, dropped by the reload vanish rule. - Board ▸ Open Card ⌘↩ (the one command enabled mid-edit: commits the placeholder or rename and opens), Move Up/Move Down ⌥⌘↑/⌥⌘↓ (within-lane sort, gather-then-step, rank-permuting writes in one bracket), Move Left/Move Right ⌘←/⌘→ (sole lane, one slot, never the trash) — all validating and acting off one shared answer. - Delete now selects the Finder-style successor sibling from the pre-write snapshot, so repeated ⌫ walks down a lane; external vanishing still only shrinks the selection. - handleReturn rejects modified Returns; the trash column renders eagerly so every row stays registered for navigation and the marquee. 686 unit tests (27 new). Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -93,12 +93,13 @@ final class TrashDragSession {
|
||||
///
|
||||
/// ### What is still a later card's
|
||||
///
|
||||
/// The **search filter** ("shown, it participates in the filter like any lane") and the **keyboard**
|
||||
/// grammar — arrow walks into and out of the column, ⇧-arrows that go inert at both the liveness and
|
||||
/// the kind boundary, ⌘C copy-out — are still owed. 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 **search filter** ("shown, it participates in the filter like any lane") and **⌘C copy-out**
|
||||
/// are still owed. 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`).
|
||||
struct TrashLaneView: View {
|
||||
|
||||
let store: BoardStore
|
||||
@@ -205,9 +206,29 @@ struct TrashLaneView: View {
|
||||
|
||||
// MARK: - Rows
|
||||
|
||||
/// The rows, scrollable, with the navigation head kept in view.
|
||||
///
|
||||
/// **"Selection scrolls into view"** (04-interactions.md ▸ Grammar), watching the head rather
|
||||
/// than the whole selection so exactly one column responds to any one arrow — `LaneView`'s rule,
|
||||
/// on the trash side.
|
||||
private var rows: some View {
|
||||
ScrollViewReader { proxy in
|
||||
scrollableRows
|
||||
.onChange(of: store.transient.selectionHead) { _, head in
|
||||
guard let head, entries.contains(where: { $0.id == head }) else { return }
|
||||
proxy.scrollTo(head)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var scrollableRows: some View {
|
||||
ScrollView(.vertical) {
|
||||
LazyVStack(alignment: .leading, spacing: rowSpacing) {
|
||||
// **A plain `VStack`, deliberately not lazy.** Every row must keep its drawn frame
|
||||
// registered in `MarqueeTargetRegistry` — the rubber band sweeps those frames and the
|
||||
// arrows navigate by them (`NavigationMath`) — and a lazy stack only builds the rows it
|
||||
// has scrolled to, so an unbuilt row is invisible to both. The constraint is affordable
|
||||
// because a trash is small: it holds one board's tombstones, and Empty Trash… exists.
|
||||
VStack(alignment: .leading, spacing: rowSpacing) {
|
||||
ForEach(entries) { entry in
|
||||
TrashEntryRow(
|
||||
store: store,
|
||||
@@ -222,6 +243,8 @@ struct TrashLaneView: View {
|
||||
// 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(entry.id)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
|
||||
Reference in New Issue
Block a user