Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71b112d04c | ||
|
|
f83385e79f |
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import CoreGraphics
|
||||
|
||||
/// Where a drag preview attaches to the pointer, as pure arithmetic (`DragPreviewAnchorTests`).
|
||||
///
|
||||
/// **SwiftUI gives no anchor control.** `.onDrag(_:preview:)` lays the preview over the *grabbed
|
||||
/// view's* frame, centred on it — which is exactly right while the preview is the size of what was
|
||||
/// grabbed (a card face lifting off its own plate), and wrong the moment it is not. A lane is
|
||||
/// grabbed by its title bar and its replica is the whole lane (03-board-ui.md § Motion: "a faithful,
|
||||
/// full-size replica of the dragged item — the whole lane, not the strip of title bar that was
|
||||
/// grabbed"), so centring that replica on the bar hangs half a lane above the pointer: the cursor
|
||||
/// ends up in the middle of the image instead of on the pixel it grabbed.
|
||||
///
|
||||
/// The one lever the API does leave is the preview's own geometry, and **transparent padding moves a
|
||||
/// view's centre without moving the view**: pad the side opposite the anchor by however much the
|
||||
/// anchor is off-centre and the padded box's centre lands exactly on it. The system then centres
|
||||
/// *that* box on the grabbed view, which puts the replica's title bar over the real one — and, since
|
||||
/// the replica is drawn at the lane's own size, every other pixel of it over the lane it was lifted
|
||||
/// from. That is the Finder-icon promise stated as geometry: the cursor stays where it grabbed.
|
||||
///
|
||||
/// Axis-agnostic, and said once because both axes ask the same question. The horizontal one answers
|
||||
/// zero for a lane — a lane replica is as wide as its lane, so its header's centre is already the
|
||||
/// box's — and the arithmetic is written for both anyway rather than assuming that stays true.
|
||||
///
|
||||
/// The compensation is **invariant under symmetric padding**: the drag replicas wear a transparent
|
||||
/// margin so their shadows are not clipped (`BoardMetrics.replicaPadding`), and adding `p` to both
|
||||
/// ends adds `p` to the length and `p` to the anchor, which cancels. So a caller may compute this
|
||||
/// from the replica's own size and apply it outside that margin, in either order.
|
||||
enum DragPreviewAnchor {
|
||||
|
||||
/// Extra transparent length to add before and after a preview along one axis.
|
||||
struct Padding: Equatable, Sendable {
|
||||
|
||||
var before: CGFloat = 0
|
||||
var after: CGFloat = 0
|
||||
|
||||
static let none = Padding()
|
||||
}
|
||||
|
||||
/// The padding that makes `anchor` — a distance from the preview's leading edge — the centre of
|
||||
/// a preview `length` long.
|
||||
///
|
||||
/// Only ever one side of it: an anchor above the middle needs room before it, one below needs
|
||||
/// room after it, and the exact middle needs none. The anchor is clamped into the preview, so a
|
||||
/// stale measurement can shift the image by at most its own length.
|
||||
///
|
||||
/// Degenerate input — an unmeasured, zero or non-finite length — pads nothing, which draws the
|
||||
/// preview exactly as it was drawn before this existed rather than flinging it off the pointer.
|
||||
static func padding(length: CGFloat, anchor: CGFloat) -> Padding {
|
||||
guard length.isFinite, anchor.isFinite, length > 0 else { return .none }
|
||||
let clamped = min(max(anchor, 0), length)
|
||||
let delta = length - 2 * clamped
|
||||
return delta > 0 ? Padding(before: delta) : Padding(after: -delta)
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,11 @@ struct LaneView: View {
|
||||
/// tall as the strip gives it.
|
||||
@State private var measuredHeight: CGFloat = 0
|
||||
|
||||
/// The title bar's drawn height — **the replica's anchor** (`dragReplica`), measured for the same
|
||||
/// reason the lane's height is: the bar is as tall as the text in it, at whatever size the system
|
||||
/// is set to.
|
||||
@State private var measuredHeaderHeight: CGFloat = 0
|
||||
|
||||
/// This lane's edge-autoscroll driver — one per lane, ticking only while a card session is in
|
||||
/// flight (`DragAutoScroller`, DRAG-REORDER.md § Edge autoscroll).
|
||||
@State private var autoScroller = DragAutoScroller()
|
||||
@@ -190,6 +195,10 @@ struct LaneView: View {
|
||||
// past the header and would stop being a boundary at all. The bar itself never moves.
|
||||
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in
|
||||
drops.registry.update(header: frame, for: lane.id)
|
||||
// The same measurement answers a second question, so it is read once: the bar is
|
||||
// what a lane drag is grabbed by, and its height is where the replica has to hang
|
||||
// from the pointer (`replicaHeaderCenterY`).
|
||||
measuredHeaderHeight = frame.height
|
||||
}
|
||||
.onDisappear { drops.registry.removeHeader(lane.id) }
|
||||
.overlay(alignment: .trailing) { newCardButton }
|
||||
@@ -530,8 +539,17 @@ struct LaneView: View {
|
||||
/// A static rendition rather than a live `LaneView`: a drag image is a snapshot, so it carries no
|
||||
/// scrolling, no gestures and no geometry observers, and the card list is capped because anything
|
||||
/// past the lane's height is clipped anyway.
|
||||
///
|
||||
/// **The pointer keeps the point it grabbed** — the Finder-icon promise, and the whole of what
|
||||
/// the anchoring padding below buys. SwiftUI centres a preview on the view the drag started
|
||||
/// from, which here is the *title bar*: uncompensated, a full-height replica centred on a bar a
|
||||
/// line and a half tall hangs half a lane above the cursor, and the cursor lands in the middle of
|
||||
/// the image rather than on the bar it grabbed. Padding the replica so its own title bar is the
|
||||
/// image's centre undoes exactly that, and lands every other pixel of the replica over the lane
|
||||
/// it was lifted from (`DragPreviewAnchor`, which is where the arithmetic and its reasoning live).
|
||||
private var dragReplica: some View {
|
||||
let count = max(1, draggedLaneCount)
|
||||
let anchor = DragPreviewAnchor.padding(length: replicaHeight, anchor: replicaHeaderCenterY)
|
||||
return ZStack {
|
||||
if count > 2 { replicaFace.offset(x: 12, y: 12).opacity(0.45) }
|
||||
if count > 1 { replicaFace.offset(x: 6, y: 6).opacity(0.7) }
|
||||
@@ -539,6 +557,11 @@ struct LaneView: View {
|
||||
}
|
||||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||||
.padding(BoardMetrics.replicaPadding(bodyPointSize: pointSize))
|
||||
// Transparent, and only ever on one side — see `DragPreviewAnchor`, whose figure is
|
||||
// deliberately invariant under the symmetric margin above, so the two paddings compose in
|
||||
// any order.
|
||||
.padding(.top, anchor.before)
|
||||
.padding(.bottom, anchor.after)
|
||||
// **Back to the window's own appearance**, undoing `header`'s runtime-contrast override for
|
||||
// this one subtree (`boardTextInk`). The preview is attached inside that modifier and would
|
||||
// otherwise inherit it — but the replica is not text on the board background: it draws its
|
||||
@@ -583,15 +606,35 @@ struct LaneView: View {
|
||||
}
|
||||
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||
}
|
||||
.frame(
|
||||
width: max(slotWidth, BoardMetrics.laneReplicaMinimumWidth(bodyPointSize: pointSize)),
|
||||
height: max(measuredHeight, BoardMetrics.laneReplicaMinimumHeight(bodyPointSize: pointSize)),
|
||||
alignment: .topLeading
|
||||
)
|
||||
.frame(width: replicaWidth, height: replicaHeight, alignment: .topLeading)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background))
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
}
|
||||
|
||||
/// The replica's size — **the lane's own**, floored for a lane that has not measured itself yet
|
||||
/// (`BoardMetrics`). Named rather than inlined in the frame because the anchoring below has to
|
||||
/// ask the same question the drawing does, and two derivations of one figure would be two
|
||||
/// answers.
|
||||
private var replicaWidth: CGFloat {
|
||||
max(slotWidth, BoardMetrics.laneReplicaMinimumWidth(bodyPointSize: pointSize))
|
||||
}
|
||||
|
||||
private var replicaHeight: CGFloat {
|
||||
max(measuredHeight, BoardMetrics.laneReplicaMinimumHeight(bodyPointSize: pointSize))
|
||||
}
|
||||
|
||||
/// Where the title bar sits inside the replica — its **centre**, measured down from the replica's
|
||||
/// top edge, which is the point the pointer must hold (`dragReplica`).
|
||||
///
|
||||
/// The replica stacks exactly what the lane stacks, in the same order and off the same figures,
|
||||
/// so this is the lane's own layout arithmetic rather than a second description of it: the accent
|
||||
/// band when the lane's colour resolves to one (`accentBand` draws nothing when it does not, and
|
||||
/// contributes no height either), the plate's inset, and half the measured bar.
|
||||
private var replicaHeaderCenterY: CGFloat {
|
||||
let band = Palette.color(for: lane.background) == nil ? 0 : bandHeight
|
||||
return band + BoardMetrics.lanePlatePadding(bodyPointSize: pointSize) + measuredHeaderHeight / 2
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
/// The card stack. Its empty space is a click target in its own right (04 ▸ Selection): one
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import CoreGraphics
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// `DragPreviewAnchor` — given a preview's length and the point in it the pointer grabbed, how much
|
||||
/// transparent room goes on either side so the system's centred placement lands that point under the
|
||||
/// cursor? The visual result is a real drag session's and cannot be asserted here; this is the one
|
||||
/// decision it is made of.
|
||||
|
||||
/// The centre of the padded box, and the anchor's position in it — the two figures the padding
|
||||
/// exists to make equal.
|
||||
private func centreAndAnchor(
|
||||
length: CGFloat,
|
||||
anchor: CGFloat
|
||||
) -> (centre: CGFloat, anchor: CGFloat) {
|
||||
let padding = DragPreviewAnchor.padding(length: length, anchor: anchor)
|
||||
let padded = padding.before + length + padding.after
|
||||
return (centre: padded / 2, anchor: padding.before + anchor)
|
||||
}
|
||||
|
||||
private func isClose(_ value: CGFloat, _ expected: CGFloat, _ tolerance: CGFloat = 0.0001) -> Bool {
|
||||
abs(value - expected) <= tolerance
|
||||
}
|
||||
|
||||
@Suite("DragPreviewAnchor")
|
||||
struct DragPreviewAnchorTests {
|
||||
|
||||
// MARK: The promise
|
||||
|
||||
@Test("The padded preview's centre is the anchor, wherever the anchor is")
|
||||
func anchorBecomesTheCentre() {
|
||||
let length: CGFloat = 640
|
||||
for anchor in stride(from: CGFloat(0), through: length, by: 16) {
|
||||
let result = centreAndAnchor(length: length, anchor: anchor)
|
||||
#expect(
|
||||
isClose(result.centre, result.anchor),
|
||||
"anchor \(anchor) landed at \(result.anchor), centre is \(result.centre)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("A lane grabbed by its title bar hangs from the bar, not from its middle")
|
||||
func laneHeaderAnchor() {
|
||||
// A tall lane whose bar sits near the top: the compensation is most of a second lane's
|
||||
// height, all of it transparent and all of it above.
|
||||
let padding = DragPreviewAnchor.padding(length: 700, anchor: 20)
|
||||
#expect(padding.before == 660)
|
||||
#expect(padding.after == 0)
|
||||
|
||||
// The bar's centre and the image's centre are then the same point — 20 down from the
|
||||
// replica's top edge, which is 680 down from the padded image's.
|
||||
let result = centreAndAnchor(length: 700, anchor: 20)
|
||||
#expect(isClose(result.centre, result.anchor))
|
||||
#expect(isClose(result.centre, 680))
|
||||
}
|
||||
|
||||
// MARK: Which side
|
||||
|
||||
@Test("An anchor above the middle pads before it, one below pads after it")
|
||||
func padsTheOppositeSide() {
|
||||
let high = DragPreviewAnchor.padding(length: 100, anchor: 30)
|
||||
#expect(high.before == 40)
|
||||
#expect(high.after == 0)
|
||||
|
||||
let low = DragPreviewAnchor.padding(length: 100, anchor: 70)
|
||||
#expect(low.before == 0)
|
||||
#expect(low.after == 40)
|
||||
}
|
||||
|
||||
@Test("An anchor already at the centre pads nothing")
|
||||
func centredAnchorPadsNothing() {
|
||||
#expect(DragPreviewAnchor.padding(length: 100, anchor: 50) == .none)
|
||||
}
|
||||
|
||||
@Test("Either extreme pads a whole length — the preview hangs entirely from one edge")
|
||||
func extremesPadAWholeLength() {
|
||||
#expect(DragPreviewAnchor.padding(length: 100, anchor: 0) == .init(before: 100, after: 0))
|
||||
#expect(DragPreviewAnchor.padding(length: 100, anchor: 100) == .init(before: 0, after: 100))
|
||||
}
|
||||
|
||||
// MARK: Symmetric padding does not disturb it
|
||||
|
||||
@Test("The figure is invariant under the replica's own transparent margin")
|
||||
func invariantUnderSymmetricPadding() {
|
||||
// `BoardMetrics.replicaPadding` wraps every replica so its shadow is not clipped. Adding it
|
||||
// to both ends adds to the length and to the anchor alike, so the compensation is the same
|
||||
// number and the two paddings compose in either order.
|
||||
let margin: CGFloat = 12
|
||||
let bare = DragPreviewAnchor.padding(length: 700, anchor: 20)
|
||||
let wrapped = DragPreviewAnchor.padding(length: 700 + 2 * margin, anchor: 20 + margin)
|
||||
#expect(bare == wrapped)
|
||||
}
|
||||
|
||||
// MARK: Degenerate input
|
||||
|
||||
@Test("An unmeasured preview pads nothing")
|
||||
func unmeasuredPadsNothing() {
|
||||
#expect(DragPreviewAnchor.padding(length: 0, anchor: 0) == .none)
|
||||
#expect(DragPreviewAnchor.padding(length: -100, anchor: 20) == .none)
|
||||
}
|
||||
|
||||
@Test("Non-finite input pads nothing")
|
||||
func nonFinitePadsNothing() {
|
||||
#expect(DragPreviewAnchor.padding(length: .infinity, anchor: 20) == .none)
|
||||
#expect(DragPreviewAnchor.padding(length: .nan, anchor: 20) == .none)
|
||||
#expect(DragPreviewAnchor.padding(length: 700, anchor: .infinity) == .none)
|
||||
#expect(DragPreviewAnchor.padding(length: 700, anchor: .nan) == .none)
|
||||
}
|
||||
|
||||
@Test("An anchor outside the preview clamps to its edges")
|
||||
func anchorClampsIntoThePreview() {
|
||||
#expect(DragPreviewAnchor.padding(length: 100, anchor: -50) == .init(before: 100, after: 0))
|
||||
#expect(DragPreviewAnchor.padding(length: 100, anchor: 500) == .init(before: 0, after: 100))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user