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
@@ -441,6 +441,42 @@ struct TransientBoardStateTests {
#expect(store.transient.renameEditor == RenameEditor(targetID: lane1, draftTitle: "To d"))
}
// MARK: The cursors' sole-member default
@Test("The sole-member default is opt-out — a band that sweeps one card acquires no cursors")
func theSoleMemberDefaultIsOptOut() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// **The default, unchanged**: a one-item selection made by any ordinary route is a legitimate
// range origin and a legitimate place to arrow from, so `nil` takes the sole member.
store.transient.select([card1], in: .board)
#expect(store.transient.selectionAnchor == card1)
#expect(store.transient.selectionHead == card1)
// **The opt-out**: a rubber band 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. `MarqueeControl.gesture` passes the flag, and this is what it buys: a band
// narrowed onto exactly one card leaves both cursors empty, so a -click after it acts plain
// and `LaneView.cardStack`'s scroll-to has no head to fire on mid-drag.
store.transient.select([card2], in: .board, anchor: nil, head: nil, defaultsSoleMember: false)
#expect(store.transient.selection.ids == [card2], "the selection itself still lands")
#expect(store.transient.selectionAnchor == nil)
#expect(store.transient.selectionHead == nil)
// The flag defaults nothing away: an explicit cursor is still taken verbatim.
store.transient.select([card2], in: .board, anchor: card2, head: card2, defaultsSoleMember: false)
#expect(store.transient.selectionAnchor == card2)
#expect(store.transient.selectionHead == card2)
// And the store's funnel forwards it the marquee calls `BoardStore.select`, not the
// transient's directly.
store.select([card1], in: .board, anchor: nil, head: nil, defaultsSoleMember: false)
#expect(store.transient.selectionAnchor == nil)
#expect(store.transient.selectionHead == nil)
}
// MARK: The last-active lane
@Test("Selecting a lane or one of its cards marks it active; clearing the selection does not forget it")