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:
@@ -313,7 +313,23 @@ struct LaneView: View {
|
||||
/// The card stack. Its empty space is a click target in its own right (04 ▸ Selection): one
|
||||
/// click selects the lane or, when it is already the selection, clears it; a double click
|
||||
/// creates a card at the bottom with its title editor focused.
|
||||
///
|
||||
/// **"Selection scrolls into view"** (04-interactions.md ▸ Grammar): the reader watches the
|
||||
/// navigation head — the cursor the arrows move, not the whole selection — and scrolls only when
|
||||
/// the head names a card *this* lane renders, so exactly one lane responds to any one press.
|
||||
/// Deliberately unwrapped by `withAnimation`: 03-board-ui.md § Motion has selection follow
|
||||
/// "whatever transaction is active rather than easing on its own".
|
||||
private var cardStack: some View {
|
||||
ScrollViewReader { proxy in
|
||||
scrollableCards
|
||||
.onChange(of: store.transient.selectionHead) { _, head in
|
||||
guard let head, let card = renderedCards.first(where: { $0.id == head }) else { return }
|
||||
proxy.scrollTo(LaneSlot.identity(of: card.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var scrollableCards: some View {
|
||||
ScrollView(.vertical) {
|
||||
// Cards stay standard width whatever the lane spans: at a slot width of
|
||||
// `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly
|
||||
@@ -340,6 +356,10 @@ struct LaneView: View {
|
||||
// decided upstream — at the reload for the real cards (`Motion.reloadAnimates`),
|
||||
// at the gesture for the placeholder, which touches no disk.
|
||||
.transition(Motion.cardTransition(reduced: reduceMotion))
|
||||
// The scroll target. `ForEach` already carries this identity, but `scrollTo`
|
||||
// resolves against an explicit `.id`, and it goes outermost so the transition
|
||||
// above stays inside the identified view rather than around it.
|
||||
.id(slot.id)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
@@ -447,12 +467,16 @@ private enum LaneSlot: Identifiable {
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .card(card): "card:\(card.id.rawValue)"
|
||||
case let .card(card): Self.identity(of: card.id)
|
||||
// Constant, because there is only ever one placeholder in one lane at a time and it must
|
||||
// keep its identity — and therefore its keyboard focus — while the user types.
|
||||
case .placeholder: "placeholder"
|
||||
}
|
||||
}
|
||||
|
||||
/// A card slot's id, spelled once so the scroll-into-view call and the slot itself cannot
|
||||
/// disagree about what `scrollTo` is looking for.
|
||||
static func identity(of card: ItemID) -> String { "card:\(card.rawValue)" }
|
||||
}
|
||||
|
||||
// MARK: - Card face
|
||||
|
||||
Reference in New Issue
Block a user