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. static let reachAbove: CGFloat = 48 /// The same below, covering the lane's bottom padding. static let reachBelow: CGFloat = 24 /// The sideways reach — kept under half the distance between two lanes' scroll areas so only /// one lane ever engages. static let reachSide: CGFloat = 12 /// 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) -> CGRect { CGRect(x: -reachSide, y: -reachAbove, width: viewport.width + reachSide * 2, height: viewport.height + reachAbove + reachBelow) } /// 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) } }