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:
@@ -1051,6 +1051,16 @@ struct LaneView: View, Equatable {
|
||||
// per card. A face cannot resolve its own path — it knows its card and its container, not
|
||||
// where the card sits — and finding it from the snapshot would be a board walk per face.
|
||||
let cardsFolder = store.rootURL.appendingPathComponent(lane.id.rawValue, isDirectory: true)
|
||||
// **The card menu's Navigation inputs, hoisted the same way** (2026-08-09, card 06322636):
|
||||
// one `store.snapshot` read for the lane's own neighbours (`CardMoveTarget.hasNeighbor`) and
|
||||
// one `Set` comparison over the selection already read above for the multi-lane-spread
|
||||
// refusal (`CardMoveTarget.selectionSpansOtherLanes`) — never a per-face read, `selectedIDs`'
|
||||
// own reason. `store.snapshot` costs this body nothing new: `headerInk` already subscribes
|
||||
// the whole lane to it.
|
||||
let hasLeftNeighbor = CardMoveTarget.hasNeighbor(of: lane.id, delta: -1, in: store.snapshot)
|
||||
let hasRightNeighbor = CardMoveTarget.hasNeighbor(of: lane.id, delta: 1, in: store.snapshot)
|
||||
let selectionSpansLanes = CardMoveTarget.selectionSpansOtherLanes(
|
||||
selectedIDs, laneCardIDs: Set(lane.cards.map(\.id)))
|
||||
return 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
|
||||
@@ -1071,7 +1081,10 @@ struct LaneView: View, Equatable {
|
||||
// 1 for an unselected face: the replica's fan and count badge want
|
||||
// "how many ride along", and a card outside the selection drags
|
||||
// alone (`CardFaceView.draggedIDs`).
|
||||
selectedCount: selectedIDs.contains(card.id) ? selectedCount : 1
|
||||
selectedCount: selectedIDs.contains(card.id) ? selectedCount : 1,
|
||||
hasLeftNeighbor: hasLeftNeighbor,
|
||||
hasRightNeighbor: hasRightNeighbor,
|
||||
selectionSpansLanes: selectionSpansLanes
|
||||
)
|
||||
// **The value gate** (`CardFaceView.==`) — the lane's own, one level
|
||||
// down: this body re-runs on every proposal change while a drag is over
|
||||
|
||||
Reference in New Issue
Block a user