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) } }