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