View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight rungs, app-wide and persisted (the Show Comments precedent) — a viewing comfort, not a property of any one board. The level travels as BoardZoomContext in the environment, injected on BoardView alone so the banner strip, search bar, sheets and popovers stay at the system size; the environment is also what carries it through CardFaceView's equality gate, which compares nothing that moves with the level. Every BoardMetrics figure follows zoom.bodyPointSize — card and lane chrome, drag replicas and the count badge, the resize handle, the trash column — and the drop registry carries the ruler for event-time reads, with the autoscroller's three reaches turning font-derived (reachSide named as the stripGap it always equalled). Lanes still divide the window; zoom never moves the window or its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung announces itself to VoiceOver, and the render suite pins both invariants: a rung repaints every face, a no-op Actual Size repaints nothing. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
151 lines
7.8 KiB
Swift
151 lines
7.8 KiB
Swift
import CoreGraphics
|
||
|
||
/// Edge-autoscroll geometry for a scroll view hosting drop targets, as pure arithmetic — no view,
|
||
/// no timer, no `NSScrollView` (`DragAutoScrollMathTests`). Ported from the pathfinder, whose
|
||
/// numbers are what was proven; the reasoning is reproduced because the behaviour is.
|
||
///
|
||
/// A lane's cards live in a scroll view, so a lane taller than its viewport has landing spots below
|
||
/// the fold — and nothing in `DropSlotMath` can reach them, since the proposal is a function of the
|
||
/// cursor over the *visible* resting layout. A card session hovering near either end of a lane's
|
||
/// scroll area therefore scrolls it, continuously, until the pointer leaves the band or the drag
|
||
/// ends (DRAG-REORDER.md § Edge autoscroll).
|
||
///
|
||
/// ## The geometry
|
||
///
|
||
/// Along each axis the visible area owns an **activation band** of `band` points at either end. A
|
||
/// pointer inside a band scrolls that way at a speed that ramps with how deep into the band it
|
||
/// sits: `minSpeed` at the band's inner edge, `maxSpeed` at (and beyond) the visible area's own
|
||
/// edge. Outside both bands the velocity is exactly zero, so a drag that merely crosses the middle
|
||
/// of a lane never scrolls it.
|
||
///
|
||
/// The `minSpeed` floor is deliberate: entering a band produces immediate, visible motion instead
|
||
/// of an imperceptible crawl that leaves the user wondering whether autoscroll exists at all. It is
|
||
/// the one discontinuity in the ramp, and it sits exactly on the band boundary, where the pointer
|
||
/// is moving anyway.
|
||
///
|
||
/// The pointer may also sit *outside* the visible area and still drive it — generously above and
|
||
/// below (the lane's header and the strip's padding are still "this lane"), but barely sideways, so
|
||
/// a drag over the neighbouring lane never scrolls this one. `engagementRect` is that reach; a
|
||
/// pointer outside it drives nothing.
|
||
///
|
||
/// Everything is axis-agnostic: the board strip has nothing to autoscroll today (every lane shares
|
||
/// the window width and the strip fills the window height — 03-board-ui.md § Layout), and the same
|
||
/// math would serve one unchanged if that ever changes.
|
||
///
|
||
/// The ticking driver — the physical-mouse read, the re-resolved proposal on every step, the
|
||
/// structurally terminated task — is the drag session's, not this file's.
|
||
enum DragAutoScrollMath {
|
||
|
||
/// Thickness of the activation band at each end of the visible area.
|
||
static let band: CGFloat = 56
|
||
|
||
/// Speed at the band's inner edge — the floor described above, in points/second.
|
||
static let minSpeed: CGFloat = 90
|
||
|
||
/// Speed at (and beyond) the visible area's own edge, in points/second. Deliberately not
|
||
/// faster: every scroll step re-resolves the drop proposal against the lane's resting grid, and
|
||
/// the distance the content travels between two resolutions is this speed divided by the tick
|
||
/// rate.
|
||
static let maxSpeed: CGFloat = 800
|
||
|
||
/// How far above the visible area the pointer may sit and still drive it — enough to cover the
|
||
/// lane's header, which is where a drag naturally goes to scroll up.
|
||
///
|
||
/// **Font-derived, unlike the three figures above it, and the split is the point.** `band`,
|
||
/// `minSpeed` and `maxSpeed` describe the *cursor's* relationship to an edge — how near counts as
|
||
/// near, how fast the content should answer — and none of that changes because the board is drawn
|
||
/// larger; they stay fixed for `LaneResizeSession.reentry`'s reason. The three reaches describe
|
||
/// **where the lane's furniture is**, and that furniture moves: this one is specified as "enough
|
||
/// to cover the lane's header", and a header at 200% zoom is twice as tall as the 48 points that
|
||
/// covered it at 13pt (03-board-ui.md ▸ Layout — zoom).
|
||
///
|
||
/// 3.7 em: 48 points at the standard body size, which is what the reach has always been.
|
||
static func reachAbove(bodyPointSize: CGFloat) -> CGFloat {
|
||
BoardMetrics.em(3.7, bodyPointSize: bodyPointSize)
|
||
}
|
||
|
||
/// The same below, covering the lane's bottom padding — 1.85 em, the standard size's 24 points.
|
||
static func reachBelow(bodyPointSize: CGFloat) -> CGFloat {
|
||
BoardMetrics.em(1.85, bodyPointSize: bodyPointSize)
|
||
}
|
||
|
||
/// The sideways reach — kept under half the distance between two lanes' scroll areas so only
|
||
/// one lane ever engages.
|
||
///
|
||
/// **It is the inter-lane gap, named as itself** rather than as a coincidentally equal number:
|
||
/// the distance between two lanes' scroll areas is the gap plus a plate padding on each side
|
||
/// (0.9 + 2 × 0.45 em), so half of it is exactly `stripGap`. Writing it that way is what keeps
|
||
/// the sentence above true at every zoom level instead of only at 13pt, where 12 happened to be
|
||
/// the answer.
|
||
static func reachSide(bodyPointSize: CGFloat) -> CGFloat {
|
||
BoardMetrics.stripGap(bodyPointSize: bodyPointSize)
|
||
}
|
||
|
||
/// The region — in the visible area's own coordinates, `(0, 0)` at its top-left — a pointer
|
||
/// must be in to drive this scroller at all.
|
||
static func engagementRect(viewport: CGSize, bodyPointSize: CGFloat) -> CGRect {
|
||
let above = reachAbove(bodyPointSize: bodyPointSize)
|
||
let below = reachBelow(bodyPointSize: bodyPointSize)
|
||
let side = reachSide(bodyPointSize: bodyPointSize)
|
||
return CGRect(x: -side,
|
||
y: -above,
|
||
width: viewport.width + side * 2,
|
||
height: viewport.height + above + below)
|
||
}
|
||
|
||
/// Signed scroll velocity in points/second for a pointer at `position` along an axis whose
|
||
/// visible extent runs `0...length`: negative scrolls toward the start (content moves
|
||
/// down/right), positive toward the end.
|
||
///
|
||
/// `band` is clamped to half the extent, so the two bands of a short viewport meet rather than
|
||
/// overlap and its exact centre still resolves to "no scrolling".
|
||
static func velocity(position: CGFloat,
|
||
length: CGFloat,
|
||
band: CGFloat = band,
|
||
minSpeed: CGFloat = minSpeed,
|
||
maxSpeed: CGFloat = maxSpeed) -> CGFloat {
|
||
guard length > 0 else { return 0 }
|
||
let band = min(band, length / 2)
|
||
guard band > 0 else { return 0 }
|
||
|
||
let depth: CGFloat
|
||
let direction: CGFloat
|
||
if position < band {
|
||
depth = (band - position) / band
|
||
direction = -1
|
||
} else if position > length - band {
|
||
depth = (position - (length - band)) / band
|
||
direction = 1
|
||
} else {
|
||
return 0
|
||
}
|
||
return direction * (minSpeed + (maxSpeed - minSpeed) * min(max(depth, 0), 1))
|
||
}
|
||
|
||
/// Both axes at once for a pointer in the visible area's own coordinates.
|
||
static func velocity(pointer: CGPoint,
|
||
viewport: CGSize,
|
||
band: CGFloat = band,
|
||
minSpeed: CGFloat = minSpeed,
|
||
maxSpeed: CGFloat = maxSpeed) -> CGVector {
|
||
CGVector(
|
||
dx: velocity(position: pointer.x, length: viewport.width,
|
||
band: band, minSpeed: minSpeed, maxSpeed: maxSpeed),
|
||
dy: velocity(position: pointer.y, length: viewport.height,
|
||
band: band, minSpeed: minSpeed, maxSpeed: maxSpeed)
|
||
)
|
||
}
|
||
|
||
/// One tick's scroll offset: `current` advanced by `velocity` for `elapsed` seconds, clamped
|
||
/// into the scrollable range. An empty or inverted range (content shorter than the viewport)
|
||
/// pins to `minOffset`.
|
||
static func nextOffset(current: CGFloat,
|
||
velocity: CGFloat,
|
||
elapsed: CGFloat,
|
||
minOffset: CGFloat,
|
||
maxOffset: CGFloat) -> CGFloat {
|
||
let upper = max(minOffset, maxOffset)
|
||
return min(max(current + velocity * elapsed, minOffset), upper)
|
||
}
|
||
}
|