Selected-ness rides down as a compared parameter — a marquee crossing repaints its faces, not the board

A selection change re-ran every CardFaceView on the board (180 bodies ≈ 85 ms on
the 6×30 fixture, 515 ≈ 233 ms on a real 515-card board, debug): the face's body
read store.selection in three places — isSelected, the drag replica's count, and
the context menu's styleTarget — and Observation invalidates every reader of the
property, past the equatable gate entirely. The band overlay stayed cheap, which
is why the marquee tracked the cursor while the highlight lagged ~0.4 s behind.

Now LaneView and TrashLaneView hoist one selection read per body and hand each
face isSelected/selectedCount as compared parameters; StyleMenuItems takes its
target as a deferred closure; TrashLaneRowView gains the same treatment plus the
Equatable gate it never needed before. Select-one-card: 180 bodies → 1. A
growing band costs the selection's own running size; the real board's crossing
fell 233 → 112 ms — the remainder is lane bodies re-measuring their masonry, a
separate lane-level finding recorded in RENDER-INSTRUMENTATION.md.

Also: select() gains defaultsSoleMember — the marquee's explicit nils never
avoided the sole-member default, so a one-card band acquired a selectionHead and
could scroll the lane out from under its own drag.

MarqueeRenderCostTests pins the shape: redundant samples cost zero bodies, a
growing band pays per crossing, and selectionStillRepaints holds a ≤8 budget.
This commit is contained in:
2026-08-07 15:10:35 -04:00
parent 5779da2b6c
commit 9c857ae0cc
14 changed files with 795 additions and 79 deletions
+22 -7
View File
@@ -389,17 +389,32 @@ public final class TransientBoardState {
/// 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**, 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.
/// member, `nil` with any other count takes nothing. That makes the 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.
///
/// **The default is opt-out, because one caller genuinely names no gesture.** A Select All over a
/// one-card board is still a selection the user pointed at, but a rubber band is not: it names no
/// click to range from and no item to arrow from *whatever* it happens to enclose, and passing
/// `nil` cannot say so `nil` is what asks for the default. So `defaultsSoleMember: false` takes
/// `anchor` and `head` verbatim, `nil` included, and a band that sweeps exactly one card leaves
/// both cursors empty instead of quietly acquiring them (`MarqueeControl.gesture`; a
/// `selectionHead` set mid-band would additionally fire `LaneView.cardStack`'s scroll-to under the
/// user's own drag).
///
/// 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>, in container: ItemContainer, anchor: ItemID? = nil, head: ItemID? = nil) {
///
/// - Parameter defaultsSoleMember: whether a `nil` anchor or head falls back to a sole member.
public func select(
_ ids: Set<ItemID>,
in container: ItemContainer,
anchor: ItemID? = nil,
head: ItemID? = nil,
defaultsSoleMember: Bool = true
) {
selection = ItemReferenceSet(ids: ids, container: container)
let sole = ids.count == 1 ? ids.first : nil
let sole = defaultsSoleMember && ids.count == 1 ? ids.first : nil
selectionAnchor = anchor ?? sole
selectionHead = head ?? sole
}