Navigation's Move Left/Right learn a real card — the context menu's disabled rows compute a per-card cross-lane destination and land it through the drop's own rank machinery

CardMoveTarget (BoardCommands.swift), LaneMoveTarget's cousin: validates
the clicked card's own current lane against the live lane order rather
than the board's live selection, refuses a target that reaches outside
that lane (no coherent left for a spread), and answers an index —
the clicked card's own position in its lane, clamped — for the
adjacent live lane in either direction. Trash is never a candidate
(not a Lane); a collapsed lane is a fine landing (a fold hides cards,
it doesn't close the lane).

CardFaceView's Navigation rows now call moveCardAcrossLane(by:), which
hands CardMoveTarget's answer straight to BoardStore.moveCards(_:toLane:at:)
— the exact call a released drag makes, so rank-minting, the undo step,
the watcher echo and the banner all come free. Targeting is Copy/Cut's
own widening (targetIDs): the clicked card, or the live selection when
the clicked card is a member of it.

Enablement stays render-safe the way isSelected/selectedCount already
are: three new CardFaceView parameters (hasLeftNeighbor, hasRightNeighbor,
selectionSpansLanes) are hoisted once per lane in LaneView.scrollableCards
and handed down as compared parameters, never read from inside a card
face's own .disabled. Caught and fixed a real regression here during
development: an early cut of the multi-lane-spread check answered true
for any lane that simply didn't contain the selected card, which
flipped a compared parameter for most of the board on an ordinary
single-card select and defeated CardFaceView's equality gate wholesale
(BoardRenderPerformanceTests.selectionStillRepaints caught it at 151 of
180 card faces).

Tests: CardMoveTargetTests (KeyboardGrammarTests.swift) pins the pure
predicate — leftmost/rightmost lane, single lane, index clamping, a
widened group anchoring on the clicked member rather than its own
extent, the multi-lane-spread refusal, and an integration test feeding
the answer straight through moveCards. CardFaceViewEquatableTests
gains a case pinning the three new compared parameters.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 10:37:55 -04:00
parent c27cc93ec1
commit 085a84abb2
5 changed files with 519 additions and 43 deletions
+115
View File
@@ -362,6 +362,121 @@ struct MoveLaneCommands: View {
}
}
// MARK: - Card context menu Navigation (cross-lane card move)
/// **The pure predicate behind the card context menu's Navigation Move Left / Move Right**
/// (`CardFaceView`, 2026-08-09 "Give the card context menu's Navigation rows real card-move
/// behavior", card 06322636) `LaneMoveTarget`'s cousin, not its reuse: that predicate answers `nil`
/// for a card id unconditionally ("a card id is in no lane order" its own doc comment), because it
/// validates the **board's live selection** against the *lane* order. This one validates a *card's*
/// own current lane against the same order, which is the whole difference between "move the selected
/// lane one slot" and "move this card into the neighbouring lane".
///
/// **Targeting is Copy/Cut's own widening** (`CardFaceView.targetIDs`, `.clipboardTarget`): the
/// clicked card alone, widened to the live selection when the clicked card is a member of it. Passed
/// in as `targeting` rather than re-derived here this type never reads `store.selection`, so the
/// same function serves a render-safe `.disabled` predicate (`CardFaceView`'s hoisted
/// `hasLeftNeighbor`/`hasRightNeighbor`/`selectionSpansLanes`) and the action underneath it without
/// either one re-deriving the other's answer.
///
/// **Destination is the adjacent *live* lane** owner ruling: "skip nothing lanes are lanes; the
/// trash is not a lane and is never a destination; collapsed lanes ARE valid destinations". That is
/// exactly `SelectionGrammar.lanes(in:)`'s own list: every lane in `snapshot.lanes`, board order,
/// unfiltered by fold state, and the trash never in it because it is not a `Lane` at all
/// (`BoardModel.trash`/`trashedLanes` are separate arrays). Reused rather than re-walked, the same
/// list `LaneMoveTarget` steps by `delta`.
///
/// **Position is index-preserving** owner ruling: "preserve the moved group's internal order and
/// land at the clicked card's relative visual index in the destination lane, clamped". The *clicked*
/// card's index in its own lane's display order (`Lane.cards`, `moveCards`'s own counting space) is
/// the anchor not the group's leading or trailing edge clamped to the destination lane's card
/// count exactly as `moveCards` clamps any index handed to it (`DropSlotMath.applied`'s convention,
/// restated at the call site once more so the clamp never depends on `moveCards` catching an
/// out-of-range value silently). **Internal order** is `moveCards`' own: it walks `snapshot.lanes` in
/// display order and takes members as it finds them (`BoardStore.draggedCards`), so a `targeting` set
/// entirely inside one lane always arrives at `moveCards` already in that lane's own order, whatever
/// order the `Set` iterates in nothing here has to re-sort it.
///
/// **A multi-lane spread answers `nil`** owner ruling: "no coherent 'left' for a spread". `targeting`
/// must be non-empty and entirely inside the clicked card's own lane, or this refuses; there is no
/// widened-but-not-really-this-lane fallback.
enum CardMoveTarget {
/// The live lane one step from `laneID`, board order, or `nil` at a wall or for an id
/// `snapshot.lanes` no longer holds the shared half of `hasNeighbor(of:delta:in:)` and
/// `destination(clicked:targeting:snapshot:delta:)`.
private static func adjacentLane(of laneID: ItemID, delta: Int, in snapshot: BoardModel) -> ItemID? {
let lanes = SelectionGrammar.lanes(in: snapshot)
guard let from = lanes.firstIndex(of: laneID) else { return nil }
let to = from + delta
guard lanes.indices.contains(to) else { return nil }
return lanes[to]
}
/// Whether `laneID` has a live neighbour one step in `delta`'s direction `hasLeftNeighbor` /
/// `hasRightNeighbor`'s pure half, meant to be called **once per lane** at the arrangement level
/// (`LaneView.scrollableCards`) and handed to every card face in it as a compared parameter, the
/// exact `isSelected`/`selectedCount` pattern: cheap (`O(lane count)`, not `O(board cards)`), and
/// never read from inside a card face's own `.disabled` (`CardFaceView`'s own render-safety note).
static func hasNeighbor(of laneID: ItemID, delta: Int, in snapshot: BoardModel) -> Bool {
adjacentLane(of: laneID, delta: delta, in: snapshot) != nil
}
/// Whether `selectedIDs` the live board selection, already narrowed to `.board`'s container by
/// the caller (`LaneView`'s own `selectedIDs` hoist) has **both** a member inside `laneCardIDs`,
/// this lane's own cards, **and** a member outside it: a genuine spread, not merely "the selection
/// exists and this happens to be some other lane". The other half of the enablement fold,
/// `hasNeighbor`'s twin: also lane-hoisted, also free of any `store.selection`/`store.snapshot`
/// read of its own (the caller already did the one read this needs).
///
/// **The `inLane` guard is load-bearing, not a shortcut.** A selection with no member in this lane
/// at all must answer `false` here every card in an untouched lane has no `isSelected` face to
/// gate in the first place, so the *value* has to stay `false` (and therefore unchanged) whenever
/// the live selection moves entirely outside this lane, or every face in every other lane on the
/// board becomes a compared difference on a selection change that has nothing to do with them
/// (`CardFaceView.==`'s gate would stop suppressing rebuilds it exists to suppress caught by
/// `BoardRenderPerformanceTests.selectionStillRepaints`, which asserts a selection change costs a
/// handful of faces, not the board).
///
/// Only matters when the clicked card is itself selected an unselected clicked card's target is
/// always `{card.id}` alone, trivially confined but is cheap enough to hoist unconditionally
/// rather than gate on `isSelected` a second time.
static func selectionSpansOtherLanes(_ selectedIDs: Set<ItemID>, laneCardIDs: Set<ItemID>) -> Bool {
guard !selectedIDs.isEmpty, selectedIDs.contains(where: { laneCardIDs.contains($0) }) else {
return false
}
return selectedIDs.contains { !laneCardIDs.contains($0) }
}
/// Where `targeting` would land one lane over, or `nil` when it cannot move at all: `clicked` is
/// not a live board card, `targeting` is empty or reaches outside `clicked`'s own lane (the
/// multi-lane-spread refusal), or that lane has no live neighbour in `delta`'s direction.
///
/// The index is `clicked`'s own position in its lane's display order, clamped to the destination
/// lane's card count see the type comment's "Position is index-preserving". The caller hands the
/// answer straight to `BoardStore.moveCards(_:toLane:at:)`, unmodified: rank-minting, the undo
/// step, the watcher echo and the banner all come from that one call, exactly as they do for a
/// released drag.
static func destination(
clicked: ItemID,
targeting: Set<ItemID>,
snapshot: BoardModel,
delta: Int
) -> (laneID: ItemID, index: Int)? {
guard let sourceLane = snapshot.lanes.first(where: { lane in lane.cards.contains { $0.id == clicked } }),
let clickedIndex = sourceLane.cards.firstIndex(where: { $0.id == clicked }),
!targeting.isEmpty,
targeting.allSatisfy({ id in sourceLane.cards.contains { $0.id == id } })
else { return nil }
guard let destinationLaneID = adjacentLane(of: sourceLane.id, delta: delta, in: snapshot)
else { return nil }
let destinationCount = snapshot.lanes.first(where: { $0.id == destinationLaneID })?.cards.count ?? 0
return (laneID: destinationLaneID, index: min(clickedIndex, destinationCount))
}
}
// MARK: - Creation items
/// File New Card (N) and File New Lane (N) 11-command-nexus.md's two creation rows.