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