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:
@@ -1,5 +1,7 @@
|
||||
**August 2026**
|
||||
|
||||
The rubber band now highlights cards the moment it touches them, instead of lagging behind on large boards.
|
||||
|
||||
The app's appearance can now be set to Light, Dark, or Auto from the View menu or the toolbar's new Appearance item.
|
||||
|
||||
A board can now wear a background image, painted across the whole window with a frosted strip keeping the title bar legible.
|
||||
|
||||
@@ -4545,8 +4545,19 @@ public final class BoardStore: HealHost {
|
||||
/// for "the lane that most recently held selection or a creation", and a *card* selection is
|
||||
/// its lane holding selection just as much as the lane's own header click is — so both are
|
||||
/// noted here, and creation notes itself in `beginPlaceholder`.
|
||||
public func select(_ ids: Set<ItemID>, in container: ItemContainer, anchor: ItemID? = nil, head: ItemID? = nil) {
|
||||
transient.select(ids, in: container, anchor: anchor, head: head)
|
||||
///
|
||||
/// - Parameter defaultsSoleMember: forwarded verbatim — see `TransientBoardState.select`, whose
|
||||
/// sole-member default the rubber band opts out of.
|
||||
public func select(
|
||||
_ ids: Set<ItemID>,
|
||||
in container: ItemContainer,
|
||||
anchor: ItemID? = nil,
|
||||
head: ItemID? = nil,
|
||||
defaultsSoleMember: Bool = true
|
||||
) {
|
||||
transient.select(
|
||||
ids, in: container, anchor: anchor, head: head, defaultsSoleMember: defaultsSoleMember
|
||||
)
|
||||
transient.noteActiveLane(Self.lane(holding: ids, in: snapshot))
|
||||
}
|
||||
|
||||
|
||||
@@ -389,17 +389,32 @@ public final class TransientBoardState {
|
||||
/// The grammar itself is `SelectionGrammar`'s — pure, testable, and the one funnel every click
|
||||
/// surface goes through (`BoardStore.click`). This is the storage half, and its only rule of its
|
||||
/// own is the **anchor default**, which the head shares: `nil` with a sole member takes that
|
||||
/// member, `nil` with any other count takes nothing. That makes the two callers that pass
|
||||
/// nothing behave exactly as they should — a one-item selection made by any route is a
|
||||
/// legitimate range origin *and* a legitimate place to arrow from, while a marquee or a Select
|
||||
/// All names no gesture and so leaves a ⇧-click acting plain and the arrows re-deriving a
|
||||
/// position from the set's last member.
|
||||
/// member, `nil` with any other count takes nothing. That makes the callers that pass nothing
|
||||
/// behave exactly as they should — a one-item selection made by any route is a legitimate range
|
||||
/// origin *and* a legitimate place to arrow from.
|
||||
///
|
||||
/// **The default is opt-out, because one caller genuinely names no gesture.** A Select All over a
|
||||
/// one-card board is still a selection the user pointed at, but a rubber band is not: it 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. So `defaultsSoleMember: false` takes
|
||||
/// `anchor` and `head` verbatim, `nil` included, and a band that sweeps exactly one card leaves
|
||||
/// both cursors empty instead of quietly acquiring them (`MarqueeControl.gesture`; a
|
||||
/// `selectionHead` set mid-band would additionally fire `LaneView.cardStack`'s scroll-to under the
|
||||
/// user's own drag).
|
||||
///
|
||||
/// Deliberately **not** filtered against the snapshot: a caller selects what it is rendering, and
|
||||
/// `resolve(against:)` on the next reload is what keeps the set honest over time.
|
||||
public func select(_ ids: Set<ItemID>, in container: ItemContainer, anchor: ItemID? = nil, head: ItemID? = nil) {
|
||||
///
|
||||
/// - Parameter defaultsSoleMember: whether a `nil` anchor or head falls back to a sole member.
|
||||
public func select(
|
||||
_ ids: Set<ItemID>,
|
||||
in container: ItemContainer,
|
||||
anchor: ItemID? = nil,
|
||||
head: ItemID? = nil,
|
||||
defaultsSoleMember: Bool = true
|
||||
) {
|
||||
selection = ItemReferenceSet(ids: ids, container: container)
|
||||
let sole = ids.count == 1 ? ids.first : nil
|
||||
let sole = defaultsSoleMember && ids.count == 1 ? ids.first : nil
|
||||
selectionAnchor = anchor ?? sole
|
||||
selectionHead = head ?? sole
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -389,12 +389,20 @@ struct BoardRenderPerformanceTests {
|
||||
// run. The other half of the gate: too strict a `==` would show up here as a zero.
|
||||
#expect(selected.cards > 0, "selecting a card repainted nothing")
|
||||
|
||||
// What it actually costs is **every face on the board**, and that is the design rather than a
|
||||
// defect: `CardFaceView.body` reads `store.selection` (`isSelected`), so a selection change
|
||||
// invalidates all of them directly — the Observation half the gates explicitly do not cover.
|
||||
// Recorded here as a number rather than asserted as a budget: narrowing it would mean each
|
||||
// face taking its own selected-ness as a compared parameter, which is a design change and not
|
||||
// this card's. See RENDER-INSTRUMENTATION.md ▸ What the first run found.
|
||||
// **And it costs a handful of faces, not the board.** This used to be "every face on the
|
||||
// board" — `CardFaceView.body` read `store.selection` for its own `isSelected`, so under
|
||||
// Observation's property-level tracking one click invalidated all \(laneCount * cardsPerLane)
|
||||
// of them *directly*, past the gate entirely (RENDER-INSTRUMENTATION.md ▸ Selection is
|
||||
// O(board) in card bodies). Selected-ness is a compared parameter now — the lane hoists the
|
||||
// selection once and hands each face its answer — so what re-runs is the lane bodies that
|
||||
// were subscribed anyway plus the faces whose flag actually flipped.
|
||||
//
|
||||
// The budget is the one-card-edit budget above and it is loose for the same reason: SwiftUI
|
||||
// evaluates a body more than once per update, so a single flipped face is worth several
|
||||
// counts. What it rules out is the old shape, which was two orders of magnitude over this.
|
||||
#expect(selected.cards <= 8,
|
||||
"selecting one card re-rendered \(selected.cards) of \(laneCount * cardsPerLane) card faces")
|
||||
|
||||
#expect(selected.strips >= 1, "the strip did not re-run for a selection change")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **What a marquee drag sample costs the renderer** — the regression suite for the selection fix
|
||||
/// (2026-08-07), grown out of the investigation that found it.
|
||||
///
|
||||
/// `MarqueeControl.gesture`'s `onChanged` calls `store.select(ids, …)` on EVERY mouse sample,
|
||||
/// whether or not the swept set changed, and `TransientBoardState.select` writes its `@Observable`
|
||||
/// properties unconditionally — Observation notifies on every set, equal or not. So a band drag is a
|
||||
/// stream of selection changes at pointer rate, and whatever one selection change costs the board,
|
||||
/// the band pays it sixty times a second.
|
||||
///
|
||||
/// The investigation measured that cost at **180 card bodies and ~85 ms per sample** on the 6×30
|
||||
/// fixture below (515 bodies, ~233 ms on a real 515-card board, debug): every face on the board,
|
||||
/// every sample, because `CardFaceView.body` read `store.selection` for its own `isSelected` and
|
||||
/// Observation tracks whole properties. `.equatable()` could not help — a direct Observation
|
||||
/// invalidation never consults the gate (RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card
|
||||
/// bodies).
|
||||
///
|
||||
/// The fix made selected-ness a **compared parameter**: `LaneView` and `TrashLaneView` hoist one
|
||||
/// selection read per body and hand each face `isSelected`/`selectedCount`, so a selection change
|
||||
/// re-runs the lane bodies that were subscribed anyway plus the faces whose flag actually flipped.
|
||||
/// These tests pin that shape — a growing band pays per *crossing*, not per card on the board — and
|
||||
/// the redundant streams pin the other half: a sample that changes nothing costs nothing.
|
||||
///
|
||||
/// The prints stay: a budget says whether the shape held, and the numbers beside it say by how much.
|
||||
|
||||
// MARK: - Fixture (BoardRenderPerformanceTests' shape)
|
||||
|
||||
private let laneCount = 6
|
||||
private let cardsPerLane = 30
|
||||
|
||||
private func laneName(_ lane: Int) -> String {
|
||||
String(format: "1%07d-1111-4111-8111-111111111111", lane)
|
||||
}
|
||||
|
||||
private func cardName(_ lane: Int, _ card: Int) -> String {
|
||||
String(format: "2%03d%04d-2222-4222-8222-222222222222", lane, card)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeFixture() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.board(title: "Marquee Cost Board")
|
||||
for lane in 0..<laneCount {
|
||||
try fixture.lane(laneName(lane), order: "\((lane + 1) * 1024)", title: "Lane \(lane)")
|
||||
for card in 0..<cardsPerLane {
|
||||
try fixture.card(
|
||||
cardName(lane, card),
|
||||
in: laneName(lane),
|
||||
order: "\((card + 1) * 1024)",
|
||||
title: "Card \(lane)-\(card)",
|
||||
body: "Body text for card \(lane)-\(card)."
|
||||
)
|
||||
}
|
||||
}
|
||||
return fixture
|
||||
}
|
||||
|
||||
// MARK: - Hosting (BoardRenderPerformanceTests' harness)
|
||||
|
||||
private struct ZoomedBoard: View {
|
||||
let store: BoardStore
|
||||
let window: @MainActor () -> NSWindow?
|
||||
let confirmations: TrashConfirmations
|
||||
let openCard: @MainActor (ItemID) -> Void
|
||||
let search: BoardSearchPresentation
|
||||
|
||||
@Environment(AppModel.self) private var appModel
|
||||
|
||||
var body: some View {
|
||||
BoardView(
|
||||
store: store,
|
||||
window: window,
|
||||
confirmations: confirmations,
|
||||
openCard: openCard,
|
||||
search: search
|
||||
)
|
||||
.environment(\.boardZoom, appModel.zoom.context)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class HostedBoard {
|
||||
let store: BoardStore
|
||||
let appModel: AppModel
|
||||
let window: NSWindow
|
||||
let view: NSView
|
||||
private let scratch: URL
|
||||
private let preferencesDomain: String
|
||||
|
||||
init(store: BoardStore, scratch: URL) {
|
||||
self.store = store
|
||||
self.scratch = scratch
|
||||
preferencesDomain = "dev.rzen.indie.Kanban.marquee-cost.\(UUID().uuidString)"
|
||||
appModel = AppModel(
|
||||
registryStorageURL: scratch.appendingPathComponent("board-registry.json"),
|
||||
clipboardStagingRoot: scratch.appendingPathComponent("Clipboard", isDirectory: true),
|
||||
preferences: UserDefaults(suiteName: preferencesDomain)!
|
||||
)
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 1600, height: 1000),
|
||||
styleMask: [.titled], backing: .buffered, defer: false
|
||||
)
|
||||
self.window = window
|
||||
let root = ZoomedBoard(
|
||||
store: store,
|
||||
window: { [weak window] in window },
|
||||
confirmations: TrashConfirmations(),
|
||||
openCard: { _ in },
|
||||
search: BoardSearchPresentation()
|
||||
)
|
||||
.environment(appModel)
|
||||
let hosting = NSHostingView(rootView: root)
|
||||
hosting.frame = NSRect(x: 0, y: 0, width: 1600, height: 1000)
|
||||
view = hosting
|
||||
window.contentView = hosting
|
||||
window.orderBack(nil)
|
||||
settle()
|
||||
}
|
||||
|
||||
deinit {
|
||||
window.orderOut(nil)
|
||||
window.contentView = nil
|
||||
try? FileManager.default.removeItem(at: scratch)
|
||||
UserDefaults.standard.removePersistentDomain(forName: preferencesDomain)
|
||||
}
|
||||
|
||||
func settle(turns: Int = 6) {
|
||||
for _ in 0..<turns {
|
||||
RunLoop.main.run(until: Date().addingTimeInterval(0.02))
|
||||
view.layoutSubtreeIfNeeded()
|
||||
window.displayIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
/// One drag-sample's worth of settling: a single runloop turn and display pass, which is what
|
||||
/// the real event stream gets between two mouseDragged deliveries.
|
||||
func settleOnce() {
|
||||
RunLoop.main.run(until: Date().addingTimeInterval(0.001))
|
||||
view.layoutSubtreeIfNeeded()
|
||||
window.displayIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func host(_ fixture: WriterFixture) throws -> HostedBoard {
|
||||
let scratch = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("MarqueeCost-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true)
|
||||
return HostedBoard(store: try BoardStore(rootURL: fixture.root), scratch: scratch)
|
||||
}
|
||||
|
||||
// MARK: - The measurements
|
||||
|
||||
@MainActor
|
||||
@Suite("Marquee drag-sample render cost", .serialized)
|
||||
struct MarqueeRenderCostTests {
|
||||
|
||||
@Test("A stream of redundant selects — the marquee's steady state between card crossings")
|
||||
func redundantSelectStream() throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let board = try host(fixture)
|
||||
let store = board.store
|
||||
|
||||
// The band is sweeping ten cards and the cursor is moving inside the same footprint —
|
||||
// every sample recomputes the same set, exactly as MarqueeControl.gesture does today.
|
||||
let swept = Set((0..<10).map { ItemID(rawValue: cardName(1, $0)) })
|
||||
|
||||
// First select: the legitimate change. Not measured here.
|
||||
store.select(swept, in: .board, anchor: nil, head: nil)
|
||||
board.settle()
|
||||
|
||||
let samples = 30
|
||||
BoardRenderMetrics.reset()
|
||||
let t0 = CACurrentMediaTime()
|
||||
for _ in 0..<samples {
|
||||
store.select(swept, in: .board, anchor: nil, head: nil)
|
||||
board.settleOnce()
|
||||
}
|
||||
let elapsed = (CACurrentMediaTime() - t0) * 1000
|
||||
|
||||
let cards = BoardRenderMetrics.cardBodyEvaluations
|
||||
let containers = BoardRenderMetrics.containerBodyEvaluations
|
||||
let strips = BoardRenderMetrics.stripBodyEvaluations
|
||||
print(String(
|
||||
format: "── %d redundant selects — %d card bodies, %d containers, %d strips, %.1f ms total (%.2f ms/sample)",
|
||||
samples, cards, containers, strips, elapsed, elapsed / Double(samples)
|
||||
))
|
||||
|
||||
// **Zero, measured — and the fix must not regress it.** A redundant select assigns the same
|
||||
// value, so every face's `isSelected`/`selectedCount` comes back identical and the gate
|
||||
// suppresses the lot; the lane bodies re-run (they are subscribed to the selection) and stop
|
||||
// there. This was already 0 before the change, for a different reason — the faces were
|
||||
// invalidated directly but SwiftUI found their output unchanged — so it is a tripwire on the
|
||||
// steady state rather than a new win.
|
||||
#expect(cards == 0, "a stream of redundant selects re-ran \(cards) card bodies")
|
||||
}
|
||||
|
||||
@Test("A stream of growing selects — the band crossing one card per sample")
|
||||
func growingSelectStream() throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let board = try host(fixture)
|
||||
let store = board.store
|
||||
|
||||
let samples = 20
|
||||
var swept: Set<ItemID> = [ItemID(rawValue: cardName(1, 0))]
|
||||
store.select(swept, in: .board, anchor: nil, head: nil)
|
||||
board.settle()
|
||||
|
||||
BoardRenderMetrics.reset()
|
||||
let t0 = CACurrentMediaTime()
|
||||
for i in 1...samples {
|
||||
swept.insert(ItemID(rawValue: cardName(1, i % cardsPerLane)))
|
||||
store.select(swept, in: .board, anchor: nil, head: nil)
|
||||
board.settleOnce()
|
||||
}
|
||||
let elapsed = (CACurrentMediaTime() - t0) * 1000
|
||||
|
||||
let cards = BoardRenderMetrics.cardBodyEvaluations
|
||||
print(String(
|
||||
format: "── %d growing selects — %d card bodies, %.1f ms total (%.2f ms/sample)",
|
||||
samples, cards, elapsed, elapsed / Double(samples)
|
||||
))
|
||||
|
||||
// Something has to repaint: each sample adds one card to the band, and that card's face has
|
||||
// to grow an accent ring.
|
||||
#expect(cards > 0, "a growing band repainted nothing")
|
||||
|
||||
// **The regression pin, and what it is a budget *of*.** A sample re-runs the faces whose
|
||||
// parameters moved, and a growing band moves two things: the newcomer's `isSelected`, and
|
||||
// `selectedCount` for everyone already in the band — the drag replica's fan and count badge
|
||||
// are drawn from it, so a card that now travels with five others is genuinely a different
|
||||
// face than one that travelled with four (`CardFaceView.dragReplica`). So the floor is the
|
||||
// **selection's own running size, summed over the stream** — 2 members after the first
|
||||
// sample, \(samples + 1) after the last — and not one flip per sample.
|
||||
//
|
||||
// That is the shape the fix bought: the cost follows what the user has selected, not what the
|
||||
// board holds. Before it, every sample re-ran all \(laneCount * cardsPerLane) faces on the
|
||||
// board whatever the band had swept — 3,600 bodies over this stream, an order of magnitude
|
||||
// over the budget below and independent of the selection entirely.
|
||||
//
|
||||
// The ×3 is `BoardRenderPerformanceTests`' slack rationale: SwiftUI evaluates a body more
|
||||
// than once per update, so a changed face is worth more than one count. Measured at exactly
|
||||
// the floor today.
|
||||
let selectionWork = (2...(samples + 1)).reduce(0, +)
|
||||
#expect(cards <= selectionWork * 3,
|
||||
"\(samples) growing selects cost \(cards) card bodies — floor \(selectionWork), budget \(selectionWork * 3)")
|
||||
}
|
||||
|
||||
@Test("A stream of redundant clears — the band sweeping empty space")
|
||||
func redundantClearStream() throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let board = try host(fixture)
|
||||
let store = board.store
|
||||
|
||||
store.clearSelection()
|
||||
board.settle()
|
||||
|
||||
let samples = 30
|
||||
BoardRenderMetrics.reset()
|
||||
let t0 = CACurrentMediaTime()
|
||||
for _ in 0..<samples {
|
||||
store.clearSelection()
|
||||
board.settleOnce()
|
||||
}
|
||||
let elapsed = (CACurrentMediaTime() - t0) * 1000
|
||||
|
||||
let cards = BoardRenderMetrics.cardBodyEvaluations
|
||||
print(String(
|
||||
format: "── %d redundant clears — %d card bodies, %.1f ms total (%.2f ms/sample)",
|
||||
samples, cards, elapsed, elapsed / Double(samples)
|
||||
))
|
||||
|
||||
// Zero, measured — `redundantSelectStream`'s rule over an empty selection, which is the band
|
||||
// sweeping the gutter between two lanes.
|
||||
#expect(cards == 0, "a stream of redundant clears re-ran \(cards) card bodies")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -3,9 +3,9 @@ import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The board strip's two rebuild gates — `LaneView.==` and `CardFaceView.==`, applied through
|
||||
/// `.equatable()` at `BoardView.laneSlot`, `LaneView.scrollableCards` and
|
||||
/// `TrashLaneView.scrollableCards`.
|
||||
/// The board strip's three rebuild gates — `LaneView.==`, `CardFaceView.==` and
|
||||
/// `TrashLaneRowView.==`, applied through `.equatable()` at `BoardView.laneSlot`,
|
||||
/// `LaneView.scrollableCards` and `TrashLaneView.scrollableCards`.
|
||||
///
|
||||
/// They exist for the drag: `BoardView`'s body reads the drag session, so **every drop-proposal
|
||||
/// change re-evaluates the whole strip**, and a lane's body reads it too, so every proposal change
|
||||
@@ -15,11 +15,21 @@ import Testing
|
||||
/// What these tests pin is the *comparison list*, because that is where a gate goes wrong in either
|
||||
/// direction. Too strict and the gate does nothing — the strip rebuilds the drop context and the
|
||||
/// card opener on every body pass, so any view comparing closures is unequal every time. Too loose
|
||||
/// and the board stops repainting — an edited card must make its lane a different value.
|
||||
/// and the board stops repainting — an edited card must make its lane a different value, and a
|
||||
/// newly selected card must make its face one.
|
||||
///
|
||||
/// They do **not** pin the Observation half, and cannot: `store.selection`, `store.searchFilter`,
|
||||
/// `drops.session`'s proposal and the rest invalidate these views directly, and `.equatable()` has
|
||||
/// no say in that. That is the design — the gate only suppresses *parent-driven* re-evaluation.
|
||||
/// **Selection is on the compared side now, and these tests are where that is held.** It used to be
|
||||
/// the headline example of the Observation half these gates deliberately do not cover: every card
|
||||
/// face read `store.selection` for itself, so one click invalidated all of them directly and
|
||||
/// `.equatable()` never got a say (measured at 180 bodies per marquee sample on the 6×30 fixture —
|
||||
/// RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card bodies). A face now takes `isSelected`
|
||||
/// and `selectedCount` from its parent, which means the gate is what decides whether a selection
|
||||
/// change repaints a given face — so it has to be unequal exactly when the flags differ, and
|
||||
/// `MarqueeRenderCostTests` measures the consequence.
|
||||
///
|
||||
/// They still do **not** pin the rest of the Observation half, and cannot: `store.transient`'s
|
||||
/// pending cut and rename editor, `drops.session`'s proposal and the rest invalidate these views
|
||||
/// directly. That is the design — the gate only suppresses *parent-driven* re-evaluation.
|
||||
///
|
||||
/// Boards are real loads off real temp trees, `SearchFilterTests`' reason: a hand-assembled `Lane`
|
||||
/// would be comparing something `BoardLoader` can never produce, and "an edit makes the lane
|
||||
@@ -251,18 +261,58 @@ struct CardFaceViewEquatableTests {
|
||||
card: card,
|
||||
role: .board(openCard: { _ in }),
|
||||
marquee: marquee,
|
||||
drops: makeDrops(store: store, session: session, registry: registry)
|
||||
drops: makeDrops(store: store, session: session, registry: registry),
|
||||
isSelected: false,
|
||||
selectedCount: 1
|
||||
)
|
||||
let after = CardFaceView(
|
||||
store: store,
|
||||
card: card,
|
||||
role: .board(openCard: { _ in Issue.record("the gate must not care which opener it holds") }),
|
||||
marquee: marquee,
|
||||
drops: makeDrops(store: store, session: session, registry: registry)
|
||||
drops: makeDrops(store: store, session: session, registry: registry),
|
||||
isSelected: false,
|
||||
selectedCount: 1
|
||||
)
|
||||
#expect(before == after)
|
||||
}
|
||||
|
||||
@Test("Selected-ness is a compared input — the whole reason the faces stopped reading the store")
|
||||
func selectednessIsADifference() throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let card = try firstCard(fixture.snapshot())
|
||||
let marquee = MarqueeControl(
|
||||
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
|
||||
)
|
||||
let drops = makeDrops(store: store, session: DragSession(), registry: LaneDropRegistry())
|
||||
|
||||
let unselected = CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1
|
||||
)
|
||||
|
||||
// The gate is now the *only* thing standing between a click and this face's repaint: nothing
|
||||
// in this body reads `store.selection` any more, so a gate that swallowed the flag would
|
||||
// leave a selected card wearing no accent ring at all.
|
||||
#expect(unselected != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: true, selectedCount: 1
|
||||
))
|
||||
|
||||
// And the count, which the drag replica's fan and count badge are drawn from: a card that is
|
||||
// still selected but now travels with four others has a different image under the cursor.
|
||||
let alone = CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: true, selectedCount: 1
|
||||
)
|
||||
#expect(alone != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: true, selectedCount: 5
|
||||
))
|
||||
}
|
||||
|
||||
@Test("An edited card is unequal — the gate never withholds a repaint")
|
||||
func anEditedCardIsADifference() throws {
|
||||
let fixture = try makeFixture()
|
||||
@@ -281,18 +331,18 @@ struct CardFaceViewEquatableTests {
|
||||
#expect(before != after)
|
||||
|
||||
#expect(CardFaceView(store: store, card: before, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops)
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
|
||||
!= CardFaceView(store: store, card: after, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops))
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1))
|
||||
|
||||
// And a different card, which is the ordinary within-lane case.
|
||||
let sibling = try #require(try firstLane(fixture.snapshot()).cards.first {
|
||||
$0.id == ItemID(rawValue: Ident.card2)
|
||||
})
|
||||
#expect(CardFaceView(store: store, card: after, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops)
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
|
||||
!= CardFaceView(store: store, card: sibling, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops))
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1))
|
||||
}
|
||||
|
||||
@Test("The two homes are never equal, and the trash's confirmation host is compared by identity")
|
||||
@@ -310,19 +360,21 @@ struct CardFaceViewEquatableTests {
|
||||
let confirmations = TrashConfirmations()
|
||||
|
||||
let board = CardFaceView(store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops)
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
|
||||
let trash = CardFaceView(store: store, card: card, role: .trash(confirmations: confirmations),
|
||||
marquee: marquee, drops: drops)
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
|
||||
// The role decides which container a click selects in, whether the face has an Open gesture
|
||||
// at all, and whether Delete is the permanent one — never a difference to swallow.
|
||||
#expect(board != trash)
|
||||
#expect(trash == CardFaceView(store: store, card: card,
|
||||
role: .trash(confirmations: confirmations),
|
||||
marquee: marquee, drops: drops))
|
||||
marquee: marquee, drops: drops,
|
||||
isSelected: false, selectedCount: 1))
|
||||
// Window-lived state, so identity is meaningful as well as cheap.
|
||||
#expect(trash != CardFaceView(store: store, card: card,
|
||||
role: .trash(confirmations: TrashConfirmations()),
|
||||
marquee: marquee, drops: drops))
|
||||
marquee: marquee, drops: drops,
|
||||
isSelected: false, selectedCount: 1))
|
||||
}
|
||||
|
||||
@Test("The window-lived collaborators are compared by identity, the strip's gap by value")
|
||||
@@ -339,31 +391,196 @@ struct CardFaceViewEquatableTests {
|
||||
let marquee = MarqueeControl(session: bandSession, registry: bandRegistry, store: store)
|
||||
let drops = makeDrops(store: store, session: session, registry: registry)
|
||||
let base = CardFaceView(store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops)
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
|
||||
|
||||
#expect(base != CardFaceView(store: other, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops))
|
||||
marquee: marquee, drops: drops,
|
||||
isSelected: false, selectedCount: 1))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
|
||||
drops: makeDrops(store: store, session: DragSession(), registry: registry)
|
||||
drops: makeDrops(store: store, session: DragSession(), registry: registry),
|
||||
isSelected: false, selectedCount: 1
|
||||
))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
|
||||
drops: makeDrops(store: store, session: session, registry: LaneDropRegistry())
|
||||
drops: makeDrops(store: store, session: session, registry: LaneDropRegistry()),
|
||||
isSelected: false, selectedCount: 1
|
||||
))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
|
||||
drops: makeDrops(store: store, session: session, registry: registry, gap: 20)
|
||||
drops: makeDrops(store: store, session: session, registry: registry, gap: 20),
|
||||
isSelected: false, selectedCount: 1
|
||||
))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: MarqueeControl(session: MarqueeSession(), registry: bandRegistry, store: store),
|
||||
drops: drops
|
||||
drops: drops, isSelected: false, selectedCount: 1
|
||||
))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: MarqueeControl(session: bandSession, registry: MarqueeTargetRegistry(), store: store),
|
||||
drops: drops
|
||||
drops: drops, isSelected: false, selectedCount: 1
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The trash column's gate
|
||||
|
||||
/// `TrashLaneRowView.==` — the card face's gate at the other level, and new with the selection
|
||||
/// change: the column's body did not read the selection until the rows stopped reading it for
|
||||
/// themselves, so until then there was nothing here worth suppressing
|
||||
/// (`TrashLaneView.scrollableCards`).
|
||||
@MainActor
|
||||
@Suite("TrashLaneRowView — the trash column's rebuild gate")
|
||||
struct TrashLaneRowViewEquatableTests {
|
||||
|
||||
/// The opaque unit the column draws — hand-assembled rather than loaded, because a trashed lane
|
||||
/// is exactly the row's whole input and `BoardLoader` needs a real deletion to produce one.
|
||||
private func makeRow(title: String, heldCards: Int = 5) -> TrashedLane {
|
||||
TrashedLane(
|
||||
id: ItemID(rawValue: Ident.lane3),
|
||||
schema: 1,
|
||||
title: .valid(title),
|
||||
modified: .missing,
|
||||
order: 1024,
|
||||
heldCards: heldCards,
|
||||
document: FrontmatterDocument(body: "")
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeView(
|
||||
store: BoardStore,
|
||||
lane: TrashedLane,
|
||||
confirmations: TrashConfirmations,
|
||||
drops: BoardDropContext,
|
||||
marquee: MarqueeControl,
|
||||
isSelected: Bool = false,
|
||||
selectedCount: Int = 1
|
||||
) -> TrashLaneRowView {
|
||||
TrashLaneRowView(
|
||||
store: store,
|
||||
lane: lane,
|
||||
confirmations: confirmations,
|
||||
drops: drops,
|
||||
marquee: marquee,
|
||||
isSelected: isSelected,
|
||||
selectedCount: selectedCount
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Identical inputs compare equal, a freshly rebuilt drop context included")
|
||||
func identicalInputsAreEqual() throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = DragSession()
|
||||
let registry = LaneDropRegistry()
|
||||
let marquee = MarqueeControl(
|
||||
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
|
||||
)
|
||||
let confirmations = TrashConfirmations()
|
||||
let lane = makeRow(title: "Retired")
|
||||
|
||||
// Two body passes of the window: same collaborators, three brand-new closures in the drop
|
||||
// context each time — a gate that compared any of them would never suppress anything.
|
||||
#expect(makeView(
|
||||
store: store, lane: lane, confirmations: confirmations,
|
||||
drops: makeDrops(store: store, session: session, registry: registry), marquee: marquee
|
||||
) == makeView(
|
||||
store: store, lane: lane, confirmations: confirmations,
|
||||
drops: makeDrops(store: store, session: session, registry: registry), marquee: marquee
|
||||
))
|
||||
}
|
||||
|
||||
@Test("A different lane value is unequal — the gate never withholds a repaint")
|
||||
func aDifferentLaneValueIsADifference() throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let marquee = MarqueeControl(
|
||||
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
|
||||
)
|
||||
let drops = makeDrops(store: store, session: DragSession(), registry: LaneDropRegistry())
|
||||
let confirmations = TrashConfirmations()
|
||||
let base = makeView(store: store, lane: makeRow(title: "Retired"),
|
||||
confirmations: confirmations, drops: drops, marquee: marquee)
|
||||
|
||||
// The row draws exactly two things — the title and the held-card count — so both have to be
|
||||
// differences, and `TrashedLane` being `Equatable` is what makes them one comparison.
|
||||
#expect(base != makeView(store: store, lane: makeRow(title: "Retired lanes"),
|
||||
confirmations: confirmations, drops: drops, marquee: marquee))
|
||||
#expect(base != makeView(store: store, lane: makeRow(title: "Retired", heldCards: 6),
|
||||
confirmations: confirmations, drops: drops, marquee: marquee))
|
||||
}
|
||||
|
||||
@Test("Selected-ness and the selection's size are compared, the card face's rule")
|
||||
func selectednessIsADifference() throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let marquee = MarqueeControl(
|
||||
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
|
||||
)
|
||||
let drops = makeDrops(store: store, session: DragSession(), registry: LaneDropRegistry())
|
||||
let confirmations = TrashConfirmations()
|
||||
let lane = makeRow(title: "Retired")
|
||||
let base = makeView(store: store, lane: lane, confirmations: confirmations,
|
||||
drops: drops, marquee: marquee)
|
||||
|
||||
#expect(base != makeView(store: store, lane: lane, confirmations: confirmations,
|
||||
drops: drops, marquee: marquee, isSelected: true))
|
||||
#expect(makeView(store: store, lane: lane, confirmations: confirmations, drops: drops,
|
||||
marquee: marquee, isSelected: true, selectedCount: 1)
|
||||
!= makeView(store: store, lane: lane, confirmations: confirmations, drops: drops,
|
||||
marquee: marquee, isSelected: true, selectedCount: 4))
|
||||
}
|
||||
|
||||
@Test("The window-lived collaborators are compared by identity, the strip's gap by value")
|
||||
func theCollaboratorsAreComparedByIdentity() throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let other = try BoardStore(rootURL: fixture.root)
|
||||
let session = DragSession()
|
||||
let registry = LaneDropRegistry()
|
||||
let bandSession = MarqueeSession()
|
||||
let bandRegistry = MarqueeTargetRegistry()
|
||||
let marquee = MarqueeControl(session: bandSession, registry: bandRegistry, store: store)
|
||||
let drops = makeDrops(store: store, session: session, registry: registry)
|
||||
let confirmations = TrashConfirmations()
|
||||
let lane = makeRow(title: "Retired")
|
||||
let base = makeView(store: store, lane: lane, confirmations: confirmations,
|
||||
drops: drops, marquee: marquee)
|
||||
|
||||
#expect(base != makeView(store: other, lane: lane, confirmations: confirmations,
|
||||
drops: drops, marquee: marquee))
|
||||
// Window-lived state the row's permanent Delete goes through — identity is meaningful here
|
||||
// for `CardFaceRole.isEquivalent(to:)`'s reason exactly.
|
||||
#expect(base != makeView(store: store, lane: lane, confirmations: TrashConfirmations(),
|
||||
drops: drops, marquee: marquee))
|
||||
#expect(base != makeView(
|
||||
store: store, lane: lane, confirmations: confirmations,
|
||||
drops: makeDrops(store: store, session: DragSession(), registry: registry),
|
||||
marquee: marquee
|
||||
))
|
||||
#expect(base != makeView(
|
||||
store: store, lane: lane, confirmations: confirmations,
|
||||
drops: makeDrops(store: store, session: session, registry: LaneDropRegistry()),
|
||||
marquee: marquee
|
||||
))
|
||||
#expect(base != makeView(
|
||||
store: store, lane: lane, confirmations: confirmations,
|
||||
drops: makeDrops(store: store, session: session, registry: registry, gap: 20),
|
||||
marquee: marquee
|
||||
))
|
||||
#expect(base != makeView(
|
||||
store: store, lane: lane, confirmations: confirmations, drops: drops,
|
||||
marquee: MarqueeControl(session: MarqueeSession(), registry: bandRegistry, store: store)
|
||||
))
|
||||
#expect(base != makeView(
|
||||
store: store, lane: lane, confirmations: confirmations, drops: drops,
|
||||
marquee: MarqueeControl(session: bandSession, registry: MarqueeTargetRegistry(), store: store)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,15 @@ LaneView.body → header → .boardTextInk(headerInk) → headerInk
|
||||
|
||||
Observation tracks whole **properties**. Reading `.background` off `store.snapshot` subscribes that body to the entire snapshot, so a reload that changes one card anywhere invalidates every lane on the board directly — and `.equatable()` has no say over a direct invalidation. The fix is to resolve the board's ink once in `BoardView` and pass it down as a compared parameter, the way `slotWidth` and `columns` already are. Until then the container budget in the test is written to the number the tree actually produces, with the reason, and `theLaneCostFollowsTheBoard` is a tripwire in both directions: it fails if the number goes **down**, which is the fix landing.
|
||||
|
||||
**Selection is O(board) in card bodies.** Selecting one card re-runs all 180 faces, because `CardFaceView.body` reads `store.selection` through `isSelected`. This is the Observation half the gates explicitly do not cover, and narrowing it would mean each face taking its own selected-ness as a compared parameter — a design change, not a gate.
|
||||
**Selection is O(board) in card bodies** — *fixed 2026-08-07; kept for the history of the table above.* Selecting one card re-ran all 180 faces, because `CardFaceView.body` read `store.selection` through `isSelected`. This was the Observation half the gates explicitly do not cover, and narrowing it meant exactly what the next section describes: each face taking its own selected-ness as a compared parameter.
|
||||
|
||||
### The selection storm, measured and fixed (2026-08-07)
|
||||
|
||||
What surfaced it: drag-selecting felt sluggish — ~0.4 s between the marquee reaching a card and its highlight. `KanbanTests/MarqueeRenderCostTests.swift` (now the regression suite) measured a marquee sample two ways. A sample whose swept set is **unchanged** was already free — SwiftUI prunes equal-value `@Observable` writes before any body runs, so no dedupe guard was ever needed. A sample that **changes** the set cost the whole board: 180 bodies ≈ 85 ms on the 6×30 fixture, 515 bodies ≈ 233 ms on a copy of the real 515-card Redesign board (debug builds). The asymmetry the user feels is exactly that split: the band overlay is cheap and tracks the cursor, while the highlight waits for the full-board pass. The face's body reached `store.selection` in **three** places — `isSelected`, the drag replica's count (`.onDrag`'s preview builder is non-escaping), and the context menu's `styleTarget` (`.contextMenu`'s builder likewise) — and all three had to be re-sourced.
|
||||
|
||||
The fix: `LaneView`/`TrashLaneView` hoist one selection read per body and hand each face `isSelected`/`selectedCount` as compared parameters; `StyleMenuItems`' target became a deferred closure. After: selecting one card re-runs **1** face; the growing-band stream costs the selection's own running size (the count parameter genuinely changes for every swept face) instead of the board — 230 bodies over 20 samples where it was 3,600.
|
||||
|
||||
**What remains, and where the next fix lives.** Wall-clock per crossing only roughly halved — 85 → 46 ms on the fixture, 233 → 112 ms on the 515-card board — because every lane body still re-runs per selection change (headers legitimately read the selection) and each lane pass re-measures its masonry: ~1,030 fresh `masonryMeasurements` (+~520 cache hits) per sample on the real board. The bodies are fixed; the residue is *layout*. The levers are the `headerInk` hoist above (stop lane bodies re-running for board-level reads) and the masonry measurement cache not surviving a lane body re-run — both lane-level, neither touched by the card-face change.
|
||||
|
||||
### The zoom pair (2026-08-03)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user