diff --git a/Kanban/UI/Board/BoardMetrics.swift b/Kanban/UI/Board/BoardMetrics.swift index 2a40ffa..a571314 100644 --- a/Kanban/UI/Board/BoardMetrics.swift +++ b/Kanban/UI/Board/BoardMetrics.swift @@ -164,8 +164,24 @@ enum BoardMetrics { // MARK: - The drag replica - /// The width the card drag's replica is drawn at — a card at a representative lane width, since - /// the image under the cursor has no lane to measure itself against. + /// The width the card drag's replica is drawn at: **the width of the face it was lifted from**. + /// + /// 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 { em(17, bodyPointSize: bodyPointSize) } diff --git a/Kanban/UI/Board/CardFaceView.swift b/Kanban/UI/Board/CardFaceView.swift index b1d5b8e..b274280 100644 --- a/Kanban/UI/Board/CardFaceView.swift +++ b/Kanban/UI/Board/CardFaceView.swift @@ -129,6 +129,13 @@ struct CardFaceView: View { /// treatment shootout). 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 /// 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 @@ -251,13 +258,18 @@ struct CardFaceView: View { // 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. .onDrag(startDrag, preview: { dragReplica }) - // The card's height, for the drop model's analytic resting grid. A height is content-driven - // and does not animate under the reflow — only positions do, and those are never measured - // (`LaneDropRegistry`). The trash side registers too: a trash card dragged out is an - // ordinary card session, and the shadow it opens in the destination lane 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) + // The card's drawn size. Its **height** goes to the drop model's analytic resting grid — a + // height is content-driven and does not animate under the reflow, only positions do, and + // those are never measured (`LaneDropRegistry`). The trash side registers too: a trash card + // dragged out is an ordinary card session, and the shadow it opens in the destination lane + // should be its real footprint rather than the nominal guess. + // + // 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) } .marqueeTarget(card.id, kind: .card, container: role.container, in: marquee.registry) @@ -414,10 +426,17 @@ struct CardFaceView: View { } .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize)) .padding(.leading, stripeWidth) - // The replica has no lane to measure itself against, so it takes a representative card - // width — font-derived like everything else here, so the image under the cursor is the size - // the cards on the board actually are at this text size (`BoardMetrics`). - .frame(width: BoardMetrics.cardReplicaWidth(bodyPointSize: pointSize), alignment: .leading) + // **The size of the face it was lifted from**, taken from that face's own measurement rather + // than from a representative figure: a card is as wide as its lane's interior column, so a + // replica drawn at a nominal width is visibly a different card from the one under the cursor, + // 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)) .overlay(alignment: .leading) { accentStripe } } diff --git a/KanbanTests/BoardMetricsTests.swift b/KanbanTests/BoardMetricsTests.swift new file mode 100644 index 0000000..dc2c7a0 --- /dev/null +++ b/KanbanTests/BoardMetricsTests.swift @@ -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)) + } +}