import AppKit import QuartzCore import SwiftUI /// Drives edge-autoscroll for one lane's scroll view across one drag session — the *driver* half of /// DRAG-REORDER.md § Edge autoscroll, whose geometry is `DragAutoScrollMath`. /// /// Three constraints shape it, and each is a rule from that section: /// /// - **The pointer is the physical mouse.** Partly for the general animation-proof reason, but mostly /// because drop callbacks only arrive while the mouse *moves*, and holding still against an edge is /// exactly the gesture that must keep scrolling. A ticking task plus `NSEvent.mouseLocation` needs /// no events at all. /// - **Every scroll step re-resolves the proposal.** The cursor is stationary in the lane's space /// while the *content* moves under it, so without this the shadow would freeze at whatever slot the /// last mouse movement proposed and the drop would land there. `didScroll` is that callback, and it /// goes through the same `BoardDropContext.retargetCards(inLane:)` the lane's drop delegate uses — /// one shared retarget, so the two can never disagree. /// - **Termination is structural.** The driver is owned by a `.task(id:)` keyed on the session, so it /// is cancelled the moment the session ends — and `DragSession`'s watchdog guarantees that flag /// clears no matter how the drag finished. @MainActor final class DragAutoScroller { /// A view living inside the scroll view's *content*: its `enclosingScrollView` is the scroller to /// drive, and its window converts the physical cursor. Weak — the view belongs to the hierarchy. fileprivate weak var anchor: NSView? /// Invoked after every scroll step. Re-resolves the drop proposal; see the type's note. fileprivate var didScroll: (() -> Void)? private var lastTick: CFTimeInterval? nonisolated init() {} /// Ticks at display rate until the owning task is cancelled. func run() async { lastTick = nil while !Task.isCancelled { try? await Task.sleep(for: .milliseconds(16)) guard !Task.isCancelled else { break } step() } lastTick = nil } private func step() { let now = CACurrentMediaTime() let elapsed = min(max(now - (lastTick ?? now), 0), 0.05) lastTick = now guard elapsed > 0, let anchor, let window = anchor.window, let scrollView = anchor.enclosingScrollView else { return } let clip = scrollView.contentView let visible = clip.bounds guard visible.width > 0, visible.height > 0 else { return } // Physical cursor → window → the clip view's (scrolled) coordinates, then relative to the // visible area's top-left corner, which is the space `DragAutoScrollMath` is written in. let inWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation) let inClip = clip.convert(inWindow, from: nil) let pointer = CGPoint(x: inClip.x - visible.minX, y: inClip.y - visible.minY) guard DragAutoScrollMath.engagementRect(viewport: visible.size).contains(pointer) else { return } let velocity = DragAutoScrollMath.velocity(pointer: pointer, viewport: visible.size) guard velocity.dx != 0 || velocity.dy != 0 else { return } let document = scrollView.documentView?.frame ?? .zero var proposed = visible proposed.origin.x = DragAutoScrollMath.nextOffset( current: visible.minX, velocity: velocity.dx, elapsed: CGFloat(elapsed), minOffset: document.minX, maxOffset: document.maxX - visible.width) proposed.origin.y = DragAutoScrollMath.nextOffset( current: visible.minY, velocity: velocity.dy, elapsed: CGFloat(elapsed), minOffset: document.minY, maxOffset: document.maxY - visible.height) // The clip view has the final say (content insets, magnification). let target = clip.constrainBoundsRect(proposed).origin guard abs(target.x - visible.minX) > 0.01 || abs(target.y - visible.minY) > 0.01 else { return } clip.scroll(to: target) scrollView.reflectScrolledClipView(clip) didScroll?() } } /// Binds a `DragAutoScroller` to the scroll view it should drive. /// /// **Must be placed inside the scroll view's content** (as its `.background`), so /// `enclosingScrollView` resolves; a background on the `ScrollView` itself sits outside the clip view /// and would find nothing. struct DragAutoScrollAnchor: NSViewRepresentable { let scroller: DragAutoScroller /// Refreshed on every view update, so the callback always closes over the current context rather /// than the one the session started with. let didScroll: () -> Void func makeNSView(context: Context) -> NSView { let view = NSView() bind(view) return view } func updateNSView(_ view: NSView, context: Context) { bind(view) } private func bind(_ view: NSView) { scroller.anchor = view scroller.didScroll = didScroll } }