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
+60 -17
View File
@@ -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.
///
+21 -2
View File
@@ -343,7 +343,9 @@ struct LaneView: View, Equatable {
Divider()
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
// Deferred, `CardFaceView`'s reason (`StyleMenuItems`): a non-escaping menu builder makes an
// eagerly computed target a body-time selection read.
StyleMenuItems(store: store, recents: appModel.styleRecents, target: { styleTarget })
Divider()
@@ -734,6 +736,18 @@ struct LaneView: View, Equatable {
// the session's hidden-member resolution) once per element O(n²) per lane body, which
// a drag pickup's synchronous whole-board layout multiplied into a visible stall.
let slots = self.slots
// **The board-side selection, read once for the whole lane.** This body is already subscribed
// to it the header's own `isSelected` reads it so hoisting the set here costs nothing new
// and lets every face take its selected-ness as a *compared parameter* instead of reading the
// store itself. That is the difference between a selection change re-running one body per
// lane and re-running one per card on the board (`CardFaceView.isSelected`,
// RENDER-INSTRUMENTATION.md Selection is O(board) in card bodies).
//
// Hoisted for `slots`' reason too: reading it inside the `ForEach` closure would re-derive
// the container branch and re-count the set once per element.
let selection = store.selection
let selectedIDs = selection.container == .board ? selection.ids : []
let selectedCount = max(1, selectedIDs.count)
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
@@ -748,7 +762,12 @@ struct LaneView: View, Equatable {
card: card,
role: .board(openCard: openCard),
marquee: marquee,
drops: drops
drops: drops,
isSelected: selectedIDs.contains(card.id),
// 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
)
// **The value gate** (`CardFaceView.==`) the lane's own, one level
// down: this body re-runs on every proposal change while a drag is over
+10 -4
View File
@@ -122,10 +122,16 @@ struct MarqueeControl {
// Neither cursor: a band names no click to range from and no item to arrow from,
// so a -click after one acts plain and an arrow re-derives a position from the
// set's last member (`TransientBoardState.selectionAnchor`, `selectionHead`).
// Both are spelled out rather than defaulted, because `select`'s sole-member
// default would otherwise pick one up the moment a band happened to sweep
// exactly one card.
store.select(ids, in: session.container, anchor: nil, head: nil)
//
// **`defaultsSoleMember: false` is what says that, and the explicit `nil`s never
// did.** `nil` *is* the default, so a band that swept exactly one card used to
// pick up both cursors anyway harmless for the anchor, not for the head:
// `LaneView.cardStack` watches `selectionHead` and scrolls the lane to it, which
// means a one-card band could scroll the board out from under the drag that was
// drawing it. The flag is the only way to spell "no gesture named these".
store.select(
ids, in: session.container, anchor: nil, head: nil, defaultsSoleMember: false
)
}
}
.onEnded { _ in session.end() }
+45 -8
View File
@@ -36,7 +36,7 @@ import SwiftUI
/// `renameTarget`/`boardStyleTarget`/`LaneWidthCommands`, all of which require a `.board`
/// selection.
/// - **No Finder file drop** the column's own delegate clears the file highlight (`TrashDrop`).
struct TrashLaneRowView: View {
struct TrashLaneRowView: View, Equatable {
let store: BoardStore
let lane: TrashedLane
@@ -56,6 +56,16 @@ struct TrashLaneRowView: View {
/// geometry and the band's begin guard (`MarqueeTargetRegistry`).
let marquee: MarqueeControl
/// Whether this row is in the trash-side selection a parameter for `CardFaceView.isSelected`'s
/// reason, and resolved by the same hoisted read in `TrashLaneView.scrollableCards` that feeds
/// the faces beside it, so a row and a card in one column can never disagree about what is
/// selected.
let isSelected: Bool
/// The size of the selection this row belongs to **1 when unselected**, normalized by the
/// parent. The drag replica's fan and count badge are its only reader (`dragReplica`).
let selectedCount: Int
/// Increase Contrast, for the plate's borders `CardFaceView`'s rule, so a selected row and a
/// selected card wear the same ring at the same strength.
@Environment(\.colorSchemeContrast) private var contrast
@@ -74,6 +84,34 @@ struct TrashLaneRowView: View {
/// This row's drawn width the drag replica's, measured for `CardFaceView`'s reason.
@State private var measuredWidth: CGFloat = 0
/// The row's rebuild gate `CardFaceView.==`'s twin, one level over: the lane value (a
/// `TrashedLane` is `Equatable` down to its title and held-card count), the two selection figures
/// the column resolves, the store and the confirmation host by identity, and the band and the
/// drop machinery by their own equivalence tests, which exist because the window rebuilds both
/// structs closures and all on every body pass.
///
/// **This row had no gate until the selection came down as a parameter, and that is the reason it
/// has one now.** Before, the column's body re-ran only when the trash's contents changed, so
/// there was nothing worth suppressing; hoisting the selection read into
/// `TrashLaneView.scrollableCards` made that body re-run on every selection change on the trash
/// side, and without a gate here every row would rebuild on each one the very shape the change
/// exists to remove (`CardFaceView.isSelected`). Applied through `.equatable()` at the call site,
/// exactly as the card face beside it is.
///
/// Deliberately NOT compared: `@State` (per-identity, preserved across updates anyway) and
/// `@Environment` values (SwiftUI invalidates on those itself). And, as with the face, the gate
/// has no say over this body's remaining Observation reads `store.transient.pendingCut`,
/// `drops.session.isDragging` which invalidate it directly.
nonisolated static func == (lhs: TrashLaneRowView, rhs: TrashLaneRowView) -> Bool {
lhs.lane == rhs.lane
&& lhs.isSelected == rhs.isSelected
&& lhs.selectedCount == rhs.selectedCount
&& lhs.store === rhs.store
&& lhs.confirmations === rhs.confirmations
&& lhs.marquee.isEquivalent(to: rhs.marquee)
&& lhs.drops.isEquivalent(to: rhs.drops)
}
var body: some View {
row
.contextMenu { menu }
@@ -247,10 +285,13 @@ struct TrashLaneRowView: View {
/// The image under the cursor: this row at its drawn width, fanned when the whole selection
/// rides along `CardFaceView.dragReplica`'s treatment, so a restore looks like every other
/// drag on the board.
///
/// Off `selectedCount` rather than the store, for the face's reason exactly: `.onDrag`'s preview
/// builder is non-escaping, so a read here happens at body time and would keep this row
/// subscribed to every selection change on the board. The `max` is belt over the parent's braces,
/// `CardFaceView.dragReplica`'s note again.
private var dragReplica: some View {
let count = store.selection.container == .trash && store.selection.ids.contains(lane.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) }
@@ -342,10 +383,6 @@ struct TrashLaneRowView: View {
// MARK: - Selection treatment
private var isSelected: Bool {
store.selection.container == .trash && store.selection.ids.contains(lane.id)
}
/// The accent ring when selected, a separator hairline under Increase Contrast, nothing
/// otherwise `CardFaceView.plateStroke`'s three-way branch, minus the file-drop hover the
/// trash never has.
+22 -3
View File
@@ -327,7 +327,18 @@ struct TrashLaneView: View {
}
private var scrollableCards: some View {
ScrollView(.vertical) {
// **The trash-side selection, read once for the whole column and this is a new subscription,
// deliberately.** `LaneView` was already reading the selection for its header, so hoisting it
// there was free; this body was not, and now is. The trade is the point: one column body per
// selection change, in place of one body per *row* in it, which is what every face and row
// here cost while each read `store.selection` for itself (`CardFaceView.isSelected`,
// RENDER-INSTRUMENTATION.md Selection is O(board) in card bodies). A trash holds one board's
// deletions, so even the bad side of that trade is small but the shape is what matters, and
// the shape is now O(1) bodies rather than O(rows).
let selection = store.selection
let selectedIDs = selection.container == .trash ? selection.ids : []
let selectedCount = max(1, selectedIDs.count)
return ScrollView(.vertical) {
// **`MasonryLayout` at one column, and a plain `VStack` deliberately not.** The trash is
// one width unit, so its masonry is a single column but it is the *same* layout the
// lanes use, which is what makes the drag's make-room reflow read as positional slides
@@ -348,7 +359,9 @@ struct TrashLaneView: View {
card: card,
role: .trash(confirmations: confirmations),
marquee: marquee,
drops: drops
drops: drops,
isSelected: selectedIDs.contains(card.id),
selectedCount: selectedIDs.contains(card.id) ? selectedCount : 1
)
// The value gate, `LaneView`'s rule on the trash side (`CardFaceView.==`).
.equatable()
@@ -361,8 +374,14 @@ struct TrashLaneView: View {
lane: lane,
confirmations: confirmations,
drops: drops,
marquee: marquee
marquee: marquee,
isSelected: selectedIDs.contains(lane.id),
selectedCount: selectedIDs.contains(lane.id) ? selectedCount : 1
)
// The row's own gate (`TrashLaneRowView.==`) worth having now that the
// column's body re-runs on every selection change rather than only on a
// reload.
.equatable()
case .shadow:
// The delete gesture's shadow, holding the topmost row open
// (04-interactions.md The trash). At the nominal card height: the cards
+15 -5
View File
@@ -656,15 +656,21 @@ private struct StyleWellGrid: View {
/// One view for both menus because the entries are identical on a card and on a lane: only the
/// *target* differs, and that is the caller's to compute (the clicked item, or the selection it
/// belongs to).
///
/// **The target arrives as a closure, deliberately.** `.contextMenu`'s builder is non-escaping, so a
/// caller's menu is assembled while its own body runs and a target computed eagerly is a
/// `store.selection` read at body time, which under Observation subscribes that body to every
/// selection change on the board. Deferring it means a card face's context menu costs its face
/// nothing until a row actually acts (`CardFaceView.isSelected` has the measurement).
struct StyleMenuItems: View {
let store: BoardStore
let recents: StyleRecents
let target: StyleTarget
let target: () -> StyleTarget
var body: some View {
Button("Style…") {
store.transient.beginStyleEditor(for: target)
store.transient.beginStyleEditor(for: target())
}
.disabled(!store.acceptsBoardMutations)
@@ -683,11 +689,15 @@ struct StyleMenuItems: View {
///
/// **Absent until it has something to offer.** A brand-new install has no recents, and an empty
/// picker in a context menu is a row that looks broken.
///
/// The target is deferred for `StyleMenuItems`' reason, and this row is where the deferral has to
/// hold: the `Binding` below is read when the menu shows a checkmark and written when a dot is
/// picked, both of them the picker's own doing rather than the enclosing card face's body.
struct QuickStyleRow: View {
let store: BoardStore
let recents: StyleRecents
let target: StyleTarget
let target: () -> StyleTarget
/// A sentinel for "the current value is not one of these", so a mixed batch or a background
/// that has aged out of the recents leaves the row unchecked rather than checking the wrong
@@ -717,13 +727,13 @@ struct QuickStyleRow: View {
private var selection: Binding<Choice> {
Binding(
get: {
let state = StyleFieldState.resolve(store.styleSubjects(of: target).map(\.background))
let state = StyleFieldState.resolve(store.styleSubjects(of: target()).map(\.background))
guard case let .uniform(value) = state, recents.backgrounds.contains(value) else { return .other }
return .value(value)
},
set: { picked in
guard case let .value(name) = picked else { return }
StyleCommand.apply(background: .set(name), to: target, in: store, recents: recents)
StyleCommand.apply(background: .set(name), to: target(), in: store, recents: recents)
}
)
}