diff --git a/Kanban/UI/Board/DragPreviewAnchor.swift b/Kanban/UI/Board/DragPreviewAnchor.swift new file mode 100644 index 0000000..0f94c88 --- /dev/null +++ b/Kanban/UI/Board/DragPreviewAnchor.swift @@ -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) + } +} diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index c1881b2..47a754b 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -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 diff --git a/KanbanTests/DragPreviewAnchorTests.swift b/KanbanTests/DragPreviewAnchorTests.swift new file mode 100644 index 0000000..e03a3f5 --- /dev/null +++ b/KanbanTests/DragPreviewAnchorTests.swift @@ -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)) + } +}