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:
@@ -141,6 +141,25 @@ struct CardFaceView: View, Equatable {
|
||||
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
|
||||
let drops: BoardDropContext
|
||||
|
||||
/// Whether this card is in the selection **of its own container** — a parameter rather than a
|
||||
/// read off the store, and that is the whole of the fix RENDER-INSTRUMENTATION.md ▸ "Selection is
|
||||
/// O(board) in card bodies" asked for.
|
||||
///
|
||||
/// Observation tracks whole properties, so a body that reads `store.selection` is subscribed to
|
||||
/// *every* selection change on the board — 180 faces re-running per marquee sample on the 6×30
|
||||
/// fixture, 515 on a real board, and `.equatable()` powerless over any of it because a direct
|
||||
/// Observation invalidation never consults the gate. Taking selected-ness as a compared input
|
||||
/// instead moves the subscription up one level, to the parent that already holds it: the lane's
|
||||
/// body reads the selection once for its own header, hands each face the answer, and the gate
|
||||
/// below then re-runs exactly the faces whose flag actually flipped.
|
||||
let isSelected: Bool
|
||||
|
||||
/// The size of the selection this face belongs to — **1 when unselected**, which the parent
|
||||
/// computes and normalizes so the drag replica's fan and count badge need no store read of their
|
||||
/// own (`dragReplica`). Deliberately not `Int?`: "how many ride along" has an answer for every
|
||||
/// face, and one is it.
|
||||
let selectedCount: Int
|
||||
|
||||
/// The app-wide quick-style recents — see `LaneView`'s own note.
|
||||
@Environment(AppModel.self) private var appModel
|
||||
|
||||
@@ -179,16 +198,32 @@ struct CardFaceView: View, Equatable {
|
||||
|
||||
/// The whole of what this face is a function of **as far as its parent is concerned**: the card
|
||||
/// value (`Card` is `Equatable` down to its attachment names and its parsed document), which home
|
||||
/// it is drawn in (`CardFaceRole.isEquivalent(to:)`), and the three window-lived collaborators —
|
||||
/// the store by identity, the band and the drop machinery by their own equivalence tests, which
|
||||
/// exist because the strip rebuilds both structs, closures and all, on every body pass.
|
||||
/// it is drawn in (`CardFaceRole.isEquivalent(to:)`), the two selection figures the parent
|
||||
/// resolves for it, and the three window-lived collaborators — the store by identity, the band
|
||||
/// and the drop machinery by their own equivalence tests, which exist because the strip rebuilds
|
||||
/// both structs, closures and all, on every body pass.
|
||||
///
|
||||
/// **What the gate does not suppress is the point.** Everything this body reads through
|
||||
/// Observation — `store.selection`, `store.searchFilter`, `store.transient.pendingCut` and the
|
||||
/// rename editor, `drops.session.isDragging`, `appModel.styleRecents` — invalidates this view
|
||||
/// directly, and `.equatable()` has no say in that. The gate only stops the *parent* handing a
|
||||
/// face a new-but-identical set of inputs and re-running it for nothing, which during a card drag
|
||||
/// is what every proposal change does to every face in the lane.
|
||||
/// **Selection is a compared input now, and that is what makes the gate reach it.** It used to be
|
||||
/// an Observation read — `isSelected` off `store.selection` — which meant a click anywhere on the
|
||||
/// board invalidated every face on it *directly*, past the gate entirely, and the measured cost
|
||||
/// of a marquee sample was the whole board's worth of bodies. Selected-ness now rides down as
|
||||
/// `isSelected`/`selectedCount` on the parent's own subscription (`LaneView` already reads the
|
||||
/// selection for its header; `TrashLaneView` gained one hoisted read to match), so a selection
|
||||
/// change re-runs the lane bodies that were subscribed anyway plus exactly the faces whose flag
|
||||
/// flipped. See RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card bodies.
|
||||
///
|
||||
/// **What the gate still does not suppress is the point.** What remains of this body's own
|
||||
/// Observation reads — `store.transient.pendingCut` and the rename editor,
|
||||
/// `drops.session.isDragging`, `appModel.styleRecents` — invalidates this view directly, and
|
||||
/// `.equatable()` has no say in that. The gate only stops the *parent* handing a face a
|
||||
/// new-but-identical set of inputs and re-running it for nothing, which during a card drag is
|
||||
/// what every proposal change does to every face in the lane.
|
||||
///
|
||||
/// `store.searchFilter` is deliberately absent from that list: its only use here is `ownSlotSeed`,
|
||||
/// reached from `startBoardCardDrag`, which runs when a drag begins rather than when the body
|
||||
/// does — an event-time read subscribes nothing. `draggedIDs`, the context menus' `targetIDs` and
|
||||
/// the deferred `styleTarget` read the selection the same way, inside actions, which is why they
|
||||
/// stayed as they were.
|
||||
///
|
||||
/// Deliberately NOT compared: `@State` (per-identity, preserved across updates anyway),
|
||||
/// `@Environment` values (SwiftUI invalidates on those itself), and the `.board` role's
|
||||
@@ -196,6 +231,8 @@ struct CardFaceView: View, Equatable {
|
||||
nonisolated static func == (lhs: CardFaceView, rhs: CardFaceView) -> Bool {
|
||||
lhs.card == rhs.card
|
||||
&& lhs.role.isEquivalent(to: rhs.role)
|
||||
&& lhs.isSelected == rhs.isSelected
|
||||
&& lhs.selectedCount == rhs.selectedCount
|
||||
&& lhs.store === rhs.store
|
||||
&& lhs.marquee.isEquivalent(to: rhs.marquee)
|
||||
&& lhs.drops.isEquivalent(to: rhs.drops)
|
||||
@@ -505,10 +542,15 @@ struct CardFaceView: View, Equatable {
|
||||
|
||||
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
|
||||
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
|
||||
///
|
||||
/// The count is `selectedCount`, not a fresh read of the selection: `.onDrag(_:preview:)`'s
|
||||
/// preview builder is **non-escaping**, so it evaluates while the body does, and a `store.selection`
|
||||
/// read here would have kept every face on the board subscribed no matter what the rest of this
|
||||
/// view did. The parent already normalizes the figure to 1 for an unselected face, so the branch
|
||||
/// that used to compute it is gone rather than moved (see `isSelected`'s note); the `max` is belt
|
||||
/// over those braces, because a zero here would draw a badge reading nothing.
|
||||
private var dragReplica: some View {
|
||||
let count = store.selection.container == role.container && store.selection.ids.contains(card.id)
|
||||
? max(1, store.selection.ids.count)
|
||||
: 1
|
||||
let count = max(1, selectedCount)
|
||||
return ZStack {
|
||||
if count > 2 { replicaFace.offset(x: 10, y: 10).opacity(0.45) }
|
||||
if count > 1 { replicaFace.offset(x: 5, y: 5).opacity(0.7) }
|
||||
@@ -578,7 +620,12 @@ struct CardFaceView: View, Equatable {
|
||||
Button("Rename") { beginRename() }
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
|
||||
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
|
||||
// The target is **deferred**, and that is not a style choice: `.contextMenu`'s builder is
|
||||
// non-escaping, so it runs while this body does, and computing a `StyleTarget` eagerly meant
|
||||
// reading `store.selection` at body time — the O(board) subscription this view was rebuilt to
|
||||
// shed (`isSelected`). Passed as a closure, `styleTarget` evaluates when a row *acts*, which
|
||||
// is where every other menu target here is already read from (`deleteTargets`).
|
||||
StyleMenuItems(store: store, recents: appModel.styleRecents, target: { styleTarget })
|
||||
|
||||
Divider()
|
||||
|
||||
@@ -779,10 +826,6 @@ struct CardFaceView: View, Equatable {
|
||||
|
||||
// MARK: - Selection and rename plumbing
|
||||
|
||||
private var isSelected: Bool {
|
||||
store.selection.container == role.container && store.selection.ids.contains(card.id)
|
||||
}
|
||||
|
||||
/// What the plate's edge is painted with: the accent when this card is selected or a Finder file
|
||||
/// drag is hovering it, a separator hairline under Increase Contrast, and nothing otherwise.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user