Card drag preview renders at the source face's laid-out width

The replica framed to a font-derived nominal width, so faces in wide
lanes' interior masonry columns and narrow lanes dragged a preview of
the wrong size and the cursor could sit over empty space beside it.
CardFaceView's existing onGeometryChange now reports size, not height
alone - height still feeds LaneDropRegistry, width feeds the replica -
and BoardMetrics.cardReplicaWidth(measured:bodyPointSize:) keeps the
old figure as the documented not-yet-laid-out fallback. Same row, same
paddings, same lineLimit at the same width means the same height, so
the preview is now an exact overlay of the face it left.

Manual verification pending alongside the lane grab-point card: 3x vs
1x lanes, interior masonry columns, window re-divide, trash rows,
large text sizes, drag-at-creation fallback.

3 tests. 1661 green on both schemes.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-29 13:02:57 -04:00
parent f83385e79f
commit 71b112d04c
3 changed files with 84 additions and 13 deletions
+18 -2
View File
@@ -164,8 +164,24 @@ enum BoardMetrics {
// MARK: - The drag replica // MARK: - The drag replica
/// The width the card drag's replica is drawn at a card at a representative lane width, since /// The width the card drag's replica is drawn at: **the width of the face it was lifted from**.
/// the image under the cursor has no lane to measure itself against. ///
/// A face is as wide as the interior masonry column it sits in a function of its lane's slot
/// width and its lane's column count (`LaneLayoutMath`, `MasonryLayout`) so it is not a figure
/// this file can derive at all, only one the live face can report (`CardFaceView` measures it
/// alongside the height the drop model already takes). Drawn at anything else, the image under
/// the cursor is a card the board does not contain, and the pointer sits beside it rather than on
/// it.
///
/// `measured` is that width, and the fallback below is for the face that has not reported one
/// yet.
static func cardReplicaWidth(measured: CGFloat, bodyPointSize: CGFloat) -> CGFloat {
guard measured.isFinite, measured > 0 else { return cardReplicaWidth(bodyPointSize: bodyPointSize) }
return measured
}
/// The replica's fallback width a card at a representative lane width, for the face that has
/// not laid out yet and so has no width of its own to give.
static func cardReplicaWidth(bodyPointSize: CGFloat) -> CGFloat { static func cardReplicaWidth(bodyPointSize: CGFloat) -> CGFloat {
em(17, bodyPointSize: bodyPointSize) em(17, bodyPointSize: bodyPointSize)
} }
+30 -11
View File
@@ -129,6 +129,13 @@ struct CardFaceView: View {
/// treatment shootout). /// treatment shootout).
private var stripeWidth: CGFloat { BoardMetrics.cardStripeWidth(bodyPointSize: pointSize) } private var stripeWidth: CGFloat { BoardMetrics.cardStripeWidth(bodyPointSize: pointSize) }
/// This face's drawn width **the replica's** (`replicaFace`). Measured rather than derived,
/// because a face is as wide as the interior masonry column its lane gives it, and that is a
/// function of the lane's slot width and its column count rather than of the font
/// (`LaneLayoutMath`, `MasonryLayout`). Zero until the first layout, which is what the metric's
/// fallback is for.
@State private var measuredWidth: CGFloat = 0
/// **The role's three absences, as a branch rather than as disabled modifiers.** Everything both /// **The role's three absences, as a branch rather than as disabled modifiers.** Everything both
/// sides share is in `face`; what the board has and the trash does not is attached here, so the /// sides share is in `face`; what the board has and the trash does not is attached here, so the
/// trash's no-Open/no-Rename/no-Style is expressed by code that is not written rather than by /// trash's no-Open/no-Rename/no-Style is expressed by code that is not written rather than by
@@ -251,13 +258,18 @@ struct CardFaceView: View {
// beside the tap recognisers above is the click-versus-drag split, the system's own: it holds // beside the tap recognisers above is the click-versus-drag split, the system's own: it holds
// the session off until the pointer really moves, so selecting and opening stay instant. // the session off until the pointer really moves, so selecting and opening stay instant.
.onDrag(startDrag, preview: { dragReplica }) .onDrag(startDrag, preview: { dragReplica })
// The card's height, for the drop model's analytic resting grid. A height is content-driven // The card's drawn size. Its **height** goes to the drop model's analytic resting grid a
// and does not animate under the reflow only positions do, and those are never measured // height is content-driven and does not animate under the reflow, only positions do, and
// (`LaneDropRegistry`). The trash side registers too: a trash card dragged out is an // those are never measured (`LaneDropRegistry`). The trash side registers too: a trash card
// ordinary card session, and the shadow it opens in the destination lane should be its real // dragged out is an ordinary card session, and the shadow it opens in the destination lane
// footprint rather than the nominal guess. // should be its real footprint rather than the nominal guess.
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in //
drops.registry.update(height: height, for: card.id) // Its **width** stays here, for the drag replica: a face is as wide as its lane's interior
// column, which no metric can derive and only the laid-out face can report
// (`BoardMetrics.cardReplicaWidth(measured:bodyPointSize:)`).
.onGeometryChange(for: CGSize.self) { $0.size } action: { size in
drops.registry.update(height: size.height, for: card.id)
measuredWidth = size.width
} }
.onDisappear { drops.registry.removeHeight(card.id) } .onDisappear { drops.registry.removeHeight(card.id) }
.marqueeTarget(card.id, kind: .card, container: role.container, in: marquee.registry) .marqueeTarget(card.id, kind: .card, container: role.container, in: marquee.registry)
@@ -414,10 +426,17 @@ struct CardFaceView: View {
} }
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize)) .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
.padding(.leading, stripeWidth) .padding(.leading, stripeWidth)
// The replica has no lane to measure itself against, so it takes a representative card // **The size of the face it was lifted from**, taken from that face's own measurement rather
// width font-derived like everything else here, so the image under the cursor is the size // than from a representative figure: a card is as wide as its lane's interior column, so a
// the cards on the board actually are at this text size (`BoardMetrics`). // replica drawn at a nominal width is visibly a different card from the one under the cursor,
.frame(width: BoardMetrics.cardReplicaWidth(bodyPointSize: pointSize), alignment: .leading) // and since the system centres a preview on the view the drag started from leaves the
// pointer sitting beside the image instead of on it. The width is the only frame this needs:
// the replica lays the same row out with the same paddings and the same `lineLimit`, so at
// the face's width it comes out at the face's height (`BoardMetrics`).
.frame(
width: BoardMetrics.cardReplicaWidth(measured: measuredWidth, bodyPointSize: pointSize),
alignment: .leading
)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary)) .background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
.overlay(alignment: .leading) { accentStripe } .overlay(alignment: .leading) { accentStripe }
} }
+36
View File
@@ -0,0 +1,36 @@
import CoreGraphics
import Testing
@testable import Kanban
/// `BoardMetrics` the board strip's arithmetic. Most of it is a multiple of the body font and
/// needs no assertion beyond the one every figure already carries (it renders what the board always
/// rendered at 13pt); what is worth pinning is the one figure that is *not* font-derived at all.
private let body: CGFloat = 13
@Suite("BoardMetrics · the card replica's width")
struct CardReplicaWidthTests {
@Test("The measured face's width wins — the replica is the face it was lifted from")
func measuredWidthWins() {
// A face in a wide lane, and one in a narrow three-column lane: neither is the nominal
// figure, and both are what the drag image must be drawn at.
#expect(BoardMetrics.cardReplicaWidth(measured: 340, bodyPointSize: body) == 340)
#expect(BoardMetrics.cardReplicaWidth(measured: 148, bodyPointSize: body) == 148)
}
@Test("A face that has not laid out yet falls back to the representative width")
func unmeasuredFallsBack() {
let fallback = BoardMetrics.cardReplicaWidth(bodyPointSize: body)
#expect(BoardMetrics.cardReplicaWidth(measured: 0, bodyPointSize: body) == fallback)
#expect(BoardMetrics.cardReplicaWidth(measured: -20, bodyPointSize: body) == fallback)
#expect(BoardMetrics.cardReplicaWidth(measured: .nan, bodyPointSize: body) == fallback)
#expect(BoardMetrics.cardReplicaWidth(measured: .infinity, bodyPointSize: body) == fallback)
}
@Test("The fallback scales with the body font, like every other figure here")
func fallbackScales() {
#expect(BoardMetrics.cardReplicaWidth(bodyPointSize: 26)
> BoardMetrics.cardReplicaWidth(bodyPointSize: body))
}
}