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:
@@ -295,6 +295,19 @@ public final class TransientBoardState {
|
||||
/// the same rule every other item reference gets.
|
||||
public private(set) var selectionAnchor: ItemID?
|
||||
|
||||
/// **Where the next arrow steps from** — the navigation cursor, AppKit's "lead": the item the
|
||||
/// last click or arrow named (04-interactions.md ▸ Grammar's spatial navigation).
|
||||
///
|
||||
/// **Distinct from the anchor, and the difference is the whole reason both exist.** A ⇧-gesture
|
||||
/// leaves the anchor exactly where it was — that is what makes successive extensions sweep out
|
||||
/// from one origin — while the *head* walks to whatever was just reached, because the next
|
||||
/// ⇧-arrow has to continue from there rather than from the origin. A plain click or arrow moves
|
||||
/// both; a ⇧-click or ⇧-arrow moves only this.
|
||||
///
|
||||
/// A memory of a gesture like the anchor, on the selection's side by construction, and re-grounded
|
||||
/// by `resolve(against:)` under the same universe rule.
|
||||
public private(set) var selectionHead: ItemID?
|
||||
|
||||
/// The items a drag is carrying — **empty when no drag is in flight**, which is what "no drag"
|
||||
/// means here rather than a separate flag.
|
||||
///
|
||||
@@ -391,27 +404,34 @@ public final class TransientBoardState {
|
||||
|
||||
// MARK: - Selection
|
||||
|
||||
/// Replaces the selection, and sets the anchor a subsequent ⇧-click ranges from.
|
||||
/// Replaces the selection, and sets the anchor a subsequent ⇧-click ranges from and the head a
|
||||
/// subsequent arrow steps from.
|
||||
///
|
||||
/// The grammar itself is `SelectionGrammar`'s — pure, testable, and the one funnel every click
|
||||
/// surface goes through (`BoardStore.click`). This is the storage half, and its only rule of its
|
||||
/// own is the **anchor default**: `nil` with a sole member anchors on that member, `nil` with
|
||||
/// any other count anchors on nothing. That makes the two callers that pass nothing behave
|
||||
/// exactly as they should — a one-item selection made by any route is a legitimate range origin,
|
||||
/// while a marquee or a Select All names no click and so leaves a ⇧-click acting plain.
|
||||
/// own is the **anchor default**, which the head shares: `nil` with a sole member takes that
|
||||
/// member, `nil` with any other count takes nothing. That makes the two callers that pass
|
||||
/// nothing behave exactly as they should — a one-item selection made by any route is a
|
||||
/// legitimate range origin *and* a legitimate place to arrow from, while a marquee or a Select
|
||||
/// All names no gesture and so leaves a ⇧-click acting plain and the arrows re-deriving a
|
||||
/// position from the set's last member.
|
||||
///
|
||||
/// Deliberately **not** filtered against the snapshot: a caller selects what it is rendering, and
|
||||
/// `resolve(against:)` on the next reload is what keeps the set honest over time.
|
||||
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil) {
|
||||
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil, head: ItemID? = nil) {
|
||||
selection = ItemReferenceSet(ids: ids, liveness: liveness)
|
||||
selectionAnchor = anchor ?? (ids.count == 1 ? ids.first : nil)
|
||||
let sole = ids.count == 1 ? ids.first : nil
|
||||
selectionAnchor = anchor ?? sole
|
||||
selectionHead = head ?? sole
|
||||
}
|
||||
|
||||
/// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar). The anchor goes
|
||||
/// with it: an empty selection has no origin to range from.
|
||||
/// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar). The anchor and
|
||||
/// the head go with it: an empty selection has no origin to range from and no cursor to step
|
||||
/// from — which is exactly the state the arrows' seed rule answers.
|
||||
public func clearSelection() {
|
||||
selection = .empty
|
||||
selectionAnchor = nil
|
||||
selectionHead = nil
|
||||
}
|
||||
|
||||
/// Records that `laneID` is where the user is working — a lane selected, or created into.
|
||||
@@ -597,15 +617,18 @@ public final class TransientBoardState {
|
||||
if let lane = lastActiveLaneID, !live.contains(lane) {
|
||||
lastActiveLaneID = nil
|
||||
}
|
||||
if let anchor = selectionAnchor {
|
||||
// The selection's side, because that is the side the anchor lives on by construction —
|
||||
// every route that sets it sets the selection to the same side in the same call. A
|
||||
// vanished or liveness-flipped anchor is gone, which is the rule every item reference
|
||||
// here gets: "a flip is a vanish from its side of the boundary".
|
||||
if selectionAnchor != nil || selectionHead != nil {
|
||||
// The selection's side, because that is the side both cursors live on by construction —
|
||||
// every route that sets either sets the selection to the same side in the same call. A
|
||||
// vanished or liveness-flipped cursor is gone, which is the rule every item reference
|
||||
// here gets: "a flip is a vanish from its side of the boundary". The head then re-derives
|
||||
// from the selection's last member on the next arrow, which is the same fallback an
|
||||
// anchorless ⇧-arrow already uses.
|
||||
let universe = selection.liveness == .live
|
||||
? live
|
||||
: ItemReferenceSet.idUniverse(of: snapshot, on: selection.liveness)
|
||||
if !universe.contains(anchor) { selectionAnchor = nil }
|
||||
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
|
||||
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user