Drag instrumentation — render counters, hot-path signposts, and the invariants that prove the gates
BoardRenderMetrics (DEBUG-only, the pathfinder's counter bag plus a strip-body discriminator that tells a failed gate from a direct Observation invalidation) counted at BoardView/LaneView/CardFaceView/ TrashLaneView bodies and MasonryLayout's callbacks. DragSignposts wraps dropUpdated, retargetCards, commitDrop, and the commit-to-covering- snapshot release pause; input latency reports honestly against NSApp.currentEvent's mach base or labels itself base=none — no event timestamp rides the drop path. BoardRenderPerformanceTests hosts the real BoardView off-screen: a value-equal reload runs zero lane and zero card bodies, a one-card edit repaints one card of 180. The lane-body budget is <= laneCount with the headerInk chain documented and a two-way tripwire that fails when the fix lands. Methodology in RENDER-INSTRUMENTATION.md. Release build proves it all compiles out. Drag-perf card a450ad09. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -416,6 +416,12 @@ struct BoardDropContext {
|
||||
/// event time is the geometry that genuinely moves under a stationary cursor: the grid, which the
|
||||
/// autoscroll driver scrolls on purpose, and `DropSlotMath`'s own containment arithmetic.
|
||||
func retargetCards(inLane laneID: ItemID) {
|
||||
// `DragSignposts` — begin/end with a `defer` rather than a scoped closure, so the guards
|
||||
// below keep their early returns and this reads as instrumentation rather than a rewrite.
|
||||
#if DEBUG
|
||||
let span = DragSignposts.beginRetargetCards()
|
||||
defer { DragSignposts.endRetargetCards(span) }
|
||||
#endif
|
||||
guard session.isDraggingCards, let cursor = globalCursor() else { return }
|
||||
guard let resting = session.restingLayouts.layout(
|
||||
inLane: laneID,
|
||||
@@ -732,6 +738,12 @@ struct BoardDropContext {
|
||||
/// from proposing to committed and keeps drawing the arrangement it was showing — the shadows at
|
||||
/// their landing slots, the originals lifted out — until this store's echo reload lands.
|
||||
func commitDrop() -> Bool {
|
||||
// The synchronous half of the release, every refusing branch included (`DragSignposts`); the
|
||||
// *pause* the user feels starts inside, at `DragSession.commit(into:)`, and outlives this.
|
||||
#if DEBUG
|
||||
let span = DragSignposts.beginCommitDrop()
|
||||
defer { DragSignposts.endCommitDrop(span) }
|
||||
#endif
|
||||
guard session.isActive, let kind = session.kind, let sourceRoot = session.sourceRoot else {
|
||||
return false
|
||||
}
|
||||
@@ -944,6 +956,13 @@ struct LaneDropDelegate: DropDelegate {
|
||||
func dropEntered(info: DropInfo) { retarget(info) }
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
// The per-event span, and the input sample (`DragSignposts`). One delegate sees any one
|
||||
// event — single-target dispatch has no fall-through — so the cadence is not double-counted.
|
||||
#if DEBUG
|
||||
let span = DragSignposts.beginDropUpdated()
|
||||
DragSignposts.sampleInput()
|
||||
defer { DragSignposts.endDropUpdated(span) }
|
||||
#endif
|
||||
retarget(info)
|
||||
return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
|
||||
}
|
||||
@@ -1000,6 +1019,11 @@ struct StripDropDelegate: DropDelegate {
|
||||
func dropEntered(info: DropInfo) { retarget(info) }
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
#if DEBUG
|
||||
let span = DragSignposts.beginDropUpdated()
|
||||
DragSignposts.sampleInput()
|
||||
defer { DragSignposts.endDropUpdated(span) }
|
||||
#endif
|
||||
retarget(info)
|
||||
return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
|
||||
}
|
||||
@@ -1055,6 +1079,11 @@ struct TrashDropDelegate: DropDelegate {
|
||||
func dropEntered(info: DropInfo) { retarget(info) }
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
#if DEBUG
|
||||
let span = DragSignposts.beginDropUpdated()
|
||||
DragSignposts.sampleInput()
|
||||
defer { DragSignposts.endDropUpdated(span) }
|
||||
#endif
|
||||
retarget(info)
|
||||
return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import SwiftUI
|
||||
|
||||
#if DEBUG
|
||||
/// **Render-cost counters for the board strip — DEBUG only, and the only way a test can tell "one
|
||||
/// card repainted" from "the whole board rebuilt".**
|
||||
///
|
||||
/// Ported from the pathfinder (`Kanban/Views/MasonryLayout.swift`'s `BoardRenderMetrics`), which is
|
||||
/// where the shape comes from: a flat bag of counters, a `reset()`, and one `count…` call at the top
|
||||
/// of each body being watched. What is new here is the masonry's **column** vocabulary and the
|
||||
/// trash column's own counter — Lanework's strip has three body levels, not two.
|
||||
///
|
||||
/// ### What it is for
|
||||
///
|
||||
/// The 2026-07-31 drag-performance work shipped four gates — `LaneView.==` / `CardFaceView.==`
|
||||
/// applied through `.equatable()` (84f909a), the resting-layout cache (a51ad75), the marquee's
|
||||
/// de-observation (f6105d4) and the resize hold (409f430) — and every one of them is a claim about
|
||||
/// *how many bodies run*. A claim like that is unfalsifiable from the outside: the board looks the
|
||||
/// same either way, only the frame rate differs, and a frame rate is a machine fact rather than a
|
||||
/// test. These counters are what turn each gate into an assertion (`BoardRenderPerformanceTests`).
|
||||
///
|
||||
/// The reading half is RENDER-INSTRUMENTATION.md at the repo root, beside DRAG-REORDER.md: the
|
||||
/// Instruments 26 SwiftUI template, the Hitches thresholds, and how the drag signposts
|
||||
/// (`DragSignposts`) line up against these numbers.
|
||||
///
|
||||
/// ### Why the storage is what it is
|
||||
///
|
||||
/// `nonisolated(unsafe) static var`, exactly as the pathfinder has it. In practice every write
|
||||
/// happens on the main actor — SwiftUI runs body evaluation *and* `Layout` callbacks there — but the
|
||||
/// `Layout` conformance itself is `nonisolated`, so a main-actor-isolated counter would not be
|
||||
/// reachable from `MasonryLayout.sizeThatFits`. The unsafety is real and deliberately accepted: this
|
||||
/// type does not exist in a release build, and the alternative is a lock on the layout hot path of a
|
||||
/// board the whole point is to keep fast.
|
||||
///
|
||||
/// **Nothing in the app reads these.** They are written by the bodies below and read by tests. A
|
||||
/// counter that branched would be instrumentation that changed the thing it measures.
|
||||
enum BoardRenderMetrics {
|
||||
|
||||
/// Every `subview.sizeThatFits(…)` `MasonryLayout` actually performed — the number its
|
||||
/// measurement cache exists to drive down.
|
||||
nonisolated(unsafe) static var masonryMeasurements = 0
|
||||
|
||||
/// Measurement requests served from that cache instead.
|
||||
nonisolated(unsafe) static var masonryCacheHits = 0
|
||||
|
||||
/// `MasonryLayout.sizeThatFits` calls — SwiftUI probes a layout more than once per pass, which
|
||||
/// is the multiplier the cache absorbs.
|
||||
nonisolated(unsafe) static var masonrySizeThatFitsCalls = 0
|
||||
|
||||
/// `MasonryLayout.placeSubviews` calls.
|
||||
nonisolated(unsafe) static var masonryPlaceCalls = 0
|
||||
|
||||
/// `CardFaceView.body` evaluations — both homes, board and trash, since it is one view.
|
||||
nonisolated(unsafe) static var cardBodyEvaluations = 0
|
||||
|
||||
/// `BoardView.body` evaluations — the strip itself.
|
||||
///
|
||||
/// Not one of the pathfinder's counters, and it earns its place by being the **discriminator**:
|
||||
/// "a lane re-ran" means one of two completely different things depending on whether the strip
|
||||
/// re-ran with it. Strip **and** lanes is a parent pass whose `.equatable()` gate did not
|
||||
/// suppress; lanes with the strip *still* is a direct Observation invalidation, which no gate has
|
||||
/// any say over (`LaneView.==`'s own note). Without this number the two are indistinguishable
|
||||
/// from a test, and the first diagnosis this instrumentation produced turned on exactly that
|
||||
/// distinction (RENDER-INSTRUMENTATION.md ▸ What the first run found).
|
||||
nonisolated(unsafe) static var stripBodyEvaluations = 0
|
||||
|
||||
/// `LaneView.body` evaluations (the pathfinder's `columnBodyEvaluations`, renamed to Lanework's
|
||||
/// vocabulary: a lane is the kanban column, and a *column* is an interior masonry track).
|
||||
nonisolated(unsafe) static var laneBodyEvaluations = 0
|
||||
|
||||
/// `TrashLaneView.body` evaluations — counted separately from the lanes because the column is
|
||||
/// not one: it renders only while shown, and it re-runs for reasons the lanes do not have.
|
||||
nonisolated(unsafe) static var trashLaneBodyEvaluations = 0
|
||||
|
||||
/// Every container body the strip draws — what "≤ N container bodies" is asserted against, so a
|
||||
/// regression cannot hide by moving from one counter to the other.
|
||||
static var containerBodyEvaluations: Int {
|
||||
laneBodyEvaluations + trashLaneBodyEvaluations
|
||||
}
|
||||
|
||||
static func reset() {
|
||||
masonryMeasurements = 0
|
||||
masonryCacheHits = 0
|
||||
masonrySizeThatFitsCalls = 0
|
||||
masonryPlaceCalls = 0
|
||||
cardBodyEvaluations = 0
|
||||
stripBodyEvaluations = 0
|
||||
laneBodyEvaluations = 0
|
||||
trashLaneBodyEvaluations = 0
|
||||
}
|
||||
|
||||
static func countStripBody() { stripBodyEvaluations += 1 }
|
||||
static func countCardBody() { cardBodyEvaluations += 1 }
|
||||
static func countLaneBody() { laneBodyEvaluations += 1 }
|
||||
static func countTrashLaneBody() { trashLaneBodyEvaluations += 1 }
|
||||
}
|
||||
#endif
|
||||
@@ -122,6 +122,11 @@ struct BoardView: View {
|
||||
private var spacing: CGFloat { BoardMetrics.stripGap(bodyPointSize: BoardMetrics.bodyPointSize) }
|
||||
|
||||
var body: some View {
|
||||
// The strip's own body count (`BoardRenderMetrics`) — DEBUG only, and the discriminator
|
||||
// between "the gate did not suppress" and "Observation invalidated the lane directly".
|
||||
#if DEBUG
|
||||
let _ = BoardRenderMetrics.countStripBody()
|
||||
#endif
|
||||
GeometryReader { proxy in
|
||||
// The strip's slots: the lanes the strip should *show*, with the drag's N contiguous
|
||||
// shadows opened at the proposal. Recomputed on every render, so a foreign reload
|
||||
|
||||
@@ -198,6 +198,11 @@ struct CardFaceView: View, Equatable {
|
||||
/// gestures that fire and refuse (`CardFaceRole`).
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
// The lane's gate, observed (`BoardRenderMetrics`) — DEBUG only, and a `let _` because a
|
||||
// `@ViewBuilder` body takes statements as views and a bare call would be one.
|
||||
#if DEBUG
|
||||
let _ = BoardRenderMetrics.countCardBody()
|
||||
#endif
|
||||
switch role {
|
||||
case let .board(openCard):
|
||||
face
|
||||
|
||||
@@ -577,6 +577,10 @@ final class DragSession {
|
||||
source: BoardStore,
|
||||
mixesKinds: Bool
|
||||
) {
|
||||
// A new drag's first `sinceLastMs` must not be the gap since the previous drag's last sample.
|
||||
#if DEBUG
|
||||
DragSignposts.resetInputSampling()
|
||||
#endif
|
||||
endHold()
|
||||
restingLayouts.clear()
|
||||
self.kind = kind
|
||||
@@ -664,6 +668,12 @@ final class DragSession {
|
||||
/// that guard reads.
|
||||
func commit(into store: BoardStore) {
|
||||
guard isActive else { return }
|
||||
// **The release pause starts here** (`DragSignposts`): this is the one path that arms a hold,
|
||||
// so every begin has an end — a refused release opens nothing. Closed at `handOff`,
|
||||
// `expire`, or `endHold`, each with its own outcome.
|
||||
#if DEBUG
|
||||
DragSignposts.beginReleasePause()
|
||||
#endif
|
||||
sourceStore?.transient.dragMembers = .empty
|
||||
watchdog?.cancel()
|
||||
watchdog = nil
|
||||
@@ -688,6 +698,9 @@ final class DragSession {
|
||||
/// already been handed off — or replaced by a second drag's — is not this one's to end.
|
||||
func expire(_ hold: CommittedHold) {
|
||||
guard self.hold == hold else { return }
|
||||
#if DEBUG
|
||||
DragSignposts.endReleasePause(outcome: "timeout")
|
||||
#endif
|
||||
withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { end() }
|
||||
}
|
||||
|
||||
@@ -697,10 +710,20 @@ final class DragSession {
|
||||
/// the board it belongs to — a reload on some other board says nothing about this one.
|
||||
func handOff(root: BoardRootKey, generation: Int) {
|
||||
guard let hold, hold.isRetired(byRoot: root, generation: generation) else { return }
|
||||
// The span the release-pause signpost exists for: commit → the covering snapshot.
|
||||
#if DEBUG
|
||||
DragSignposts.endReleasePause(outcome: "echo")
|
||||
#endif
|
||||
end()
|
||||
}
|
||||
|
||||
private func endHold() {
|
||||
// A no-op for the two paths above, which have already closed their interval with a more
|
||||
// specific outcome; the backstop for every other teardown (`end()` from the watchdog, a
|
||||
// cancel, a second drag beginning).
|
||||
#if DEBUG
|
||||
DragSignposts.endReleasePause(outcome: "cleared")
|
||||
#endif
|
||||
hold = nil
|
||||
holdTimeoutTask?.cancel()
|
||||
holdTimeoutTask = nil
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
#if DEBUG
|
||||
import AppKit
|
||||
import QuartzCore
|
||||
import os.signpost
|
||||
|
||||
/// **The drag hot path, as `OSSignposter` intervals — DEBUG only, fire-and-forget, no branches.**
|
||||
///
|
||||
/// Three questions the 2026-07-31 drag-performance work left unmeasurable from inside the app, and
|
||||
/// one it can only half-answer. Each is an interval on the `dev.rzen.indie.Kanban` subsystem's
|
||||
/// `drag` category, so Instruments' os_signpost lane graphs them beside the SwiftUI template's
|
||||
/// Update Groups and the Hitches instrument's commit/render split (RENDER-INSTRUMENTATION.md).
|
||||
///
|
||||
/// | Signpost | Span |
|
||||
/// | --- | --- |
|
||||
/// | `drop-updated` | one `dropUpdated(info:)` — the per-event retarget **and** the returned `DropProposal` |
|
||||
/// | `retarget-cards` | one `BoardDropContext.retargetCards(inLane:)` — resting layout + `DropSlotMath.cardSlot` |
|
||||
/// | `drop-commit` | one `BoardDropContext.commitDrop()` — the synchronous write bracket the release blocks on |
|
||||
/// | `drop-release-pause` | the committed-overlay hold: `DragSession.commit(into:)` → the snapshot that retires it |
|
||||
/// | `drag-input-latency` | *event*, not interval — see below |
|
||||
///
|
||||
/// ### The release pause, and why it begins where it does
|
||||
///
|
||||
/// `drop-release-pause` is the number a user calls "the board freezes when I let go": the write
|
||||
/// goes out, the overlay keeps drawing the arrangement, and the shadows only dissolve when the echo
|
||||
/// reload lands (`CommittedHold`, DRAG-REORDER.md § The committed-overlay hold). It therefore has to
|
||||
/// span two callbacks — `performDrop` on one side, `BoardView`'s `snapshotGeneration` watch on the
|
||||
/// other — which is the one span here that needs retained state rather than a `defer`.
|
||||
///
|
||||
/// It begins at `DragSession.commit(into:)` rather than at `commitDrop()`'s first line, deliberately:
|
||||
/// `commit(into:)` is the *only* path that arms a hold, so every begin has an end, and a refused
|
||||
/// release (a vanished lane, an emptied run, a mixed-kind trash drag) never opens an interval that
|
||||
/// nothing would close. The handful of guards above it are covered by `drop-commit`, which wraps the
|
||||
/// whole of `commitDrop()` including the branches that return `false`. The end carries an
|
||||
/// `outcome` — `echo`, `timeout` (`DragSession.expire`) or `cleared` (any other teardown) — because a
|
||||
/// pause that ended on the watchdog rather than on a snapshot is a different bug from a slow one.
|
||||
///
|
||||
/// ### Input latency: what is measurable, and the gap
|
||||
///
|
||||
/// "The shadow lags the cursor" wants `now − (the time the OS stamped the mouse event)`. **The
|
||||
/// SwiftUI drop path never surfaces that stamp.** `DropInfo` carries a `location` and item providers
|
||||
/// and nothing else; `NSDraggingInfo` underneath it carries a dragging sequence number, a location
|
||||
/// and a pasteboard, and no timestamp either; and Lanework's own drop code reads only *static*
|
||||
/// `NSEvent` class properties (`mouseLocation`, `modifierFlags`, `pressedMouseButtons`) — never an
|
||||
/// `NSEvent` instance (`BoardDropContext.globalCursor`). There is no event object in hand to ask.
|
||||
///
|
||||
/// So this samples the one base that can exist — `NSApp.currentEvent`, the event AppKit is currently
|
||||
/// dispatching — and **says which base it got** rather than inventing one. `NSEvent.timestamp` and
|
||||
/// `CACurrentMediaTime()` are both seconds since boot off `mach_absolute_time`, so where a mouse
|
||||
/// event *is* current the subtraction is unit-consistent and is the real figure. Where it is not —
|
||||
/// a drag driven entirely by the drag server, a Finder file drag with no local mouse event at all —
|
||||
/// the emitted event says `base=none` and carries only `sinceLastMs`, the interval since the previous
|
||||
/// sample. That number is honest (it is the callback cadence, which is what a laggy drag actually
|
||||
/// degrades) and it is not latency; the trace must not be read as though it were.
|
||||
///
|
||||
/// ### Strict concurrency
|
||||
///
|
||||
/// `OSSignposter` is `Sendable` and its `emit`/`beginInterval`/`endInterval` are safe from anywhere.
|
||||
/// The one piece of retained state — the open release interval — lives on the main actor, which is
|
||||
/// where every caller already is (drop delegates, `DragSession`, `BoardView`'s `onChange`).
|
||||
@MainActor
|
||||
enum DragSignposts {
|
||||
|
||||
/// The subsystem every `Logger` in this app already uses, so a trace and a log line filter the
|
||||
/// same way. `category: "drag"` keeps the intervals out of the store's and the window's noise.
|
||||
static let signposter = OSSignposter(subsystem: "dev.rzen.indie.Kanban", category: "drag")
|
||||
|
||||
// MARK: - Per-event spans
|
||||
|
||||
/// `dropUpdated(info:)` — the whole callback, retarget and proposal alike.
|
||||
///
|
||||
/// Returned rather than scoped, so the call site can pair it with a `defer` and leave the
|
||||
/// existing early returns exactly as they are.
|
||||
static func beginDropUpdated() -> OSSignpostIntervalState {
|
||||
signposter.beginInterval("drop-updated", id: .exclusive)
|
||||
}
|
||||
|
||||
static func endDropUpdated(_ state: OSSignpostIntervalState) {
|
||||
signposter.endInterval("drop-updated", state)
|
||||
}
|
||||
|
||||
static func beginRetargetCards() -> OSSignpostIntervalState {
|
||||
signposter.beginInterval("retarget-cards", id: .exclusive)
|
||||
}
|
||||
|
||||
static func endRetargetCards(_ state: OSSignpostIntervalState) {
|
||||
signposter.endInterval("retarget-cards", state)
|
||||
}
|
||||
|
||||
static func beginCommitDrop() -> OSSignpostIntervalState {
|
||||
signposter.beginInterval("drop-commit", id: .exclusive)
|
||||
}
|
||||
|
||||
static func endCommitDrop(_ state: OSSignpostIntervalState) {
|
||||
signposter.endInterval("drop-commit", state)
|
||||
}
|
||||
|
||||
// MARK: - The release pause
|
||||
|
||||
/// The open `drop-release-pause` interval, or `nil` when no hold is standing.
|
||||
///
|
||||
/// One at a time by construction: `DragSession` holds one `CommittedHold` and a second drag
|
||||
/// cannot begin while it stands. A begin that finds one open closes it as `superseded` anyway,
|
||||
/// so a mismatched pair can never leak an interval that never ends.
|
||||
private static var releasePause: OSSignpostIntervalState?
|
||||
|
||||
/// The write is out and the committed overlay is holding — the pause has started.
|
||||
static func beginReleasePause() {
|
||||
if releasePause != nil { endReleasePause(outcome: "superseded") }
|
||||
releasePause = signposter.beginInterval("drop-release-pause", id: .exclusive)
|
||||
}
|
||||
|
||||
/// The overlay dissolved. `outcome` distinguishes the snapshot landing from the two ways a hold
|
||||
/// can end without one.
|
||||
static func endReleasePause(outcome: StaticString) {
|
||||
guard let state = releasePause else { return }
|
||||
releasePause = nil
|
||||
signposter.endInterval("drop-release-pause", state, "\(outcome)")
|
||||
}
|
||||
|
||||
// MARK: - Input latency
|
||||
|
||||
/// When the previous `dropUpdated` sample was taken — the fallback figure's base, and the only
|
||||
/// one that is available unconditionally.
|
||||
private static var lastSample: CFTimeInterval?
|
||||
|
||||
/// Emits one `drag-input-latency` event per drop callback.
|
||||
///
|
||||
/// See the type's note for why this is best-effort: `base=event` is a true input-to-callback
|
||||
/// latency, `base=none` is a cadence sample wearing the same name, and the emitted field says
|
||||
/// which. Nothing here is retained beyond one `CFTimeInterval` and nothing branches on it.
|
||||
static func sampleInput() {
|
||||
let now = CACurrentMediaTime()
|
||||
let sinceLast = lastSample.map { (now - $0) * 1000 } ?? -1
|
||||
lastSample = now
|
||||
|
||||
let event = NSApp.currentEvent
|
||||
let stamp: CFTimeInterval? = switch event?.type {
|
||||
case .some(.mouseMoved), .some(.leftMouseDragged), .some(.rightMouseDragged),
|
||||
.some(.otherMouseDragged), .some(.leftMouseUp), .some(.leftMouseDown):
|
||||
event?.timestamp
|
||||
default:
|
||||
nil
|
||||
}
|
||||
|
||||
if let stamp {
|
||||
signposter.emitEvent(
|
||||
"drag-input-latency", id: .exclusive,
|
||||
"base=event latencyMs=\((now - stamp) * 1000, privacy: .public) sinceLastMs=\(sinceLast, privacy: .public)"
|
||||
)
|
||||
} else {
|
||||
signposter.emitEvent(
|
||||
"drag-input-latency", id: .exclusive,
|
||||
"base=none latencyMs=-1 sinceLastMs=\(sinceLast, privacy: .public)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets the cadence base — a new drag's first sample must not report the gap since the
|
||||
/// previous drag's last one.
|
||||
static func resetInputSampling() {
|
||||
lastSample = nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -146,6 +146,11 @@ struct LaneView: View, Equatable {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// The strip's gate, observed (`BoardRenderMetrics`) — DEBUG only, and a `let _` because
|
||||
// `body` is a `@ViewBuilder` and a bare `Void` call is not a view.
|
||||
#if DEBUG
|
||||
let _ = BoardRenderMetrics.countLaneBody()
|
||||
#endif
|
||||
// `spacing: 0` and the padding moved inside: the accent band is **full-width** along the
|
||||
// lane's top edge, so it must sit outside the content inset rather than in it.
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
|
||||
@@ -210,6 +210,10 @@ struct MasonryLayout: Layout {
|
||||
// change, which is the only way a card's measured height can change at a fixed column width
|
||||
// (its content changed → its view tree was rebuilt → the layout's content is new). Clearing
|
||||
// there means the cache never outlives the content it measured.
|
||||
//
|
||||
// Both halves are counted in DEBUG builds (`BoardRenderMetrics.masonryMeasurements` /
|
||||
// `masonryCacheHits`), which is what makes "the cache absorbs SwiftUI's repeat probes" a number
|
||||
// a test can read rather than a claim — see RENDER-INSTRUMENTATION.md.
|
||||
struct Cache {
|
||||
var columnWidth: CGFloat = .nan
|
||||
var heights: [CGFloat] = []
|
||||
@@ -229,8 +233,14 @@ struct MasonryLayout: Layout {
|
||||
cache.heights = [CGFloat](repeating: .nan, count: subviews.count)
|
||||
}
|
||||
if !cache.heights[index].isNaN {
|
||||
#if DEBUG
|
||||
BoardRenderMetrics.masonryCacheHits += 1
|
||||
#endif
|
||||
return cache.heights[index]
|
||||
}
|
||||
#if DEBUG
|
||||
BoardRenderMetrics.masonryMeasurements += 1
|
||||
#endif
|
||||
let height = subviews[index].sizeThatFits(ProposedViewSize(width: column, height: nil)).height
|
||||
cache.heights[index] = height
|
||||
return height
|
||||
@@ -248,6 +258,9 @@ struct MasonryLayout: Layout {
|
||||
}
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
|
||||
#if DEBUG
|
||||
BoardRenderMetrics.masonrySizeThatFitsCalls += 1
|
||||
#endif
|
||||
let width = proposal.width ?? 0
|
||||
let placement = placement(width: width, origin: .zero)
|
||||
let heights = measuredHeights(of: subviews, at: placement.columnWidth, cache: &cache)
|
||||
@@ -255,6 +268,9 @@ struct MasonryLayout: Layout {
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
|
||||
#if DEBUG
|
||||
BoardRenderMetrics.masonryPlaceCalls += 1
|
||||
#endif
|
||||
let placement = placement(width: bounds.width, origin: bounds.origin)
|
||||
let heights = measuredHeights(of: subviews, at: placement.columnWidth, cache: &cache)
|
||||
for (index, frame) in placement.frames(heights: heights).enumerated() {
|
||||
|
||||
@@ -126,6 +126,11 @@ struct TrashLaneView: View {
|
||||
private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }
|
||||
|
||||
var body: some View {
|
||||
// The strip's third body level, observed (`BoardRenderMetrics`) — DEBUG only, and a
|
||||
// `let _` because `body` is a `@ViewBuilder` and a bare `Void` call is not a view.
|
||||
#if DEBUG
|
||||
let _ = BoardRenderMetrics.countTrashLaneBody()
|
||||
#endif
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
cards
|
||||
|
||||
Reference in New Issue
Block a user