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