Compare commits
2
Commits
28ef9eef7e
...
71b112d04c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71b112d04c | ||
|
|
f83385e79f |
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
/// tall as the strip gives it.
|
||||||
@State private var measuredHeight: CGFloat = 0
|
@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
|
/// This lane's edge-autoscroll driver — one per lane, ticking only while a card session is in
|
||||||
/// flight (`DragAutoScroller`, DRAG-REORDER.md § Edge autoscroll).
|
/// flight (`DragAutoScroller`, DRAG-REORDER.md § Edge autoscroll).
|
||||||
@State private var autoScroller = DragAutoScroller()
|
@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.
|
// 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
|
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in
|
||||||
drops.registry.update(header: frame, for: lane.id)
|
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) }
|
.onDisappear { drops.registry.removeHeader(lane.id) }
|
||||||
.overlay(alignment: .trailing) { newCardButton }
|
.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
|
/// 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
|
/// scrolling, no gestures and no geometry observers, and the card list is capped because anything
|
||||||
/// past the lane's height is clipped anyway.
|
/// 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 {
|
private var dragReplica: some View {
|
||||||
let count = max(1, draggedLaneCount)
|
let count = max(1, draggedLaneCount)
|
||||||
|
let anchor = DragPreviewAnchor.padding(length: replicaHeight, anchor: replicaHeaderCenterY)
|
||||||
return ZStack {
|
return ZStack {
|
||||||
if count > 2 { replicaFace.offset(x: 12, y: 12).opacity(0.45) }
|
if count > 2 { replicaFace.offset(x: 12, y: 12).opacity(0.45) }
|
||||||
if count > 1 { replicaFace.offset(x: 6, y: 6).opacity(0.7) }
|
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) }
|
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||||||
.padding(BoardMetrics.replicaPadding(bodyPointSize: pointSize))
|
.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
|
// **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
|
// 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
|
// 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))
|
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||||
}
|
}
|
||||||
.frame(
|
.frame(width: replicaWidth, height: replicaHeight, alignment: .topLeading)
|
||||||
width: max(slotWidth, BoardMetrics.laneReplicaMinimumWidth(bodyPointSize: pointSize)),
|
|
||||||
height: max(measuredHeight, BoardMetrics.laneReplicaMinimumHeight(bodyPointSize: pointSize)),
|
|
||||||
alignment: .topLeading
|
|
||||||
)
|
|
||||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background))
|
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
.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
|
// MARK: - Body
|
||||||
|
|
||||||
/// The card stack. Its empty space is a click target in its own right (04 ▸ Selection): one
|
/// 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