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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user