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 frame clock 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. /// /// ## The clock is the display's /// /// Ticks come from a `CADisplayLink` on whichever display the lane is drawn on /// (`DisplayLinkTickSource`), not from a sleeping task. The retired version was /// `Task.sleep(16ms)` in a loop, which has two defects a scroll that must look continuous can feel: /// `Task.sleep` guarantees only a **lower** bound — a busy cooperative pool pushes the wake-up /// arbitrarily late — and a fixed ~60Hz cadence against a 120Hz panel beats against the compositor /// rather than landing on it. Every scroll step drags a retarget behind it, so both defects arrive /// at the shadow as well as at the content. @MainActor final class DragAutoScroller { /// A view living inside the scroll view's *content*: its `enclosingScrollView` is the scroller to /// drive, its window converts the physical cursor, and it is the view the display link is asked /// for. 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)? /// Where frames come from. One implementation ships (`DisplayLinkTickSource`); the seam exists /// because a `CADisplayLink` needs a screen and a run loop and so cannot tick in a test bundle /// (`DragAutoScrollDriverTests`). private let tickSource: any DragAutoScrollTickSource private var clock = DragAutoScrollClock() nonisolated init(tickSource: any DragAutoScrollTickSource = DisplayLinkTickSource()) { self.tickSource = tickSource } /// Ticks once per frame of the anchor's display until the owning task is cancelled. /// /// The stream *is* the lifetime: cancelling this task ends the iteration, which terminates the /// stream, which invalidates the link (`DisplayLinkTickSource`). Nothing else has to remember to. func run() async { clock.reset() for await timestamp in tickSource.ticks(anchoredTo: anchor) { guard !Task.isCancelled else { break } step(at: timestamp) } clock.reset() } private func step(at timestamp: CFTimeInterval) { let elapsed = clock.elapsed(at: timestamp) 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?() } } // MARK: - The frame clock /// One drag session's frame-time bookkeeping: display timestamps in, one tick's elapsed seconds out /// (`DragAutoScrollClockTests`). /// /// Trivial arithmetic with two rules that are not, both inherited from the sleeping loop this /// replaced: /// /// - **The first tick of a session scrolls nothing.** It has no predecessor to measure from, and the /// alternative — measuring from `run()`'s own start — would integrate the scheduling delay before /// the first frame into the first step. /// - **A tick can never integrate more than `maxStep`.** A display link *pauses* — its view occluded, /// its window ordered out, the display asleep — and resumes with a delta of however long that /// lasted. Unclamped, the resuming frame would teleport the lane by that gap × up to 800pt/s. struct DragAutoScrollClock { /// The largest span a single tick may integrate over, in seconds — three 60Hz frames' worth, so a /// merely late frame is still honoured in full and a resume after a pause is not. static let maxStep: CFTimeInterval = 0.05 private var last: CFTimeInterval? /// Seconds since the previous tick: zero for the first of a session, clamped to `maxStep`, and /// never negative (a timestamp that goes backwards is not a reason to scroll the other way). mutating func elapsed(at timestamp: CFTimeInterval) -> CFTimeInterval { let elapsed = min(max(timestamp - (last ?? timestamp), 0), Self.maxStep) last = timestamp return elapsed } /// Forgets the base, so the next tick is a session's first again. mutating func reset() { last = nil } } // MARK: - The tick source /// Where `DragAutoScroller` gets its frames — the seam under the driver's loop. /// /// `Sendable` so the scroller's `nonisolated init` can carry one (it is stored on the main actor and /// only ever called there, which the `@MainActor` requirement states). protocol DragAutoScrollTickSource: Sendable { /// A fresh stream of frame timestamps for one drag session, on the display `view` is drawn on. /// /// Terminating the stream — finishing it, cancelling the consuming task, or simply dropping it — /// is what stops the source. A `nil` view has nothing to scroll, and answers with a stream that /// is already finished. @MainActor func ticks(anchoredTo view: NSView?) -> AsyncStream } /// The shipping tick source: `CADisplayLink`, obtained through `NSView.displayLink(target:selector:)`. /// /// That factory (macOS 14+) is Apple's named replacement for the deprecated `CVDisplayLink`, and it /// is asked for *per view* precisely because the view is what knows which display it is on: the link /// follows the window between a 60Hz panel and a 120Hz one, and suspends itself while the view is /// off-screen, with nothing here to write for either case. struct DisplayLinkTickSource: DragAutoScrollTickSource { @MainActor func ticks(anchoredTo view: NSView?) -> AsyncStream { guard let view else { return AsyncStream { $0.finish() } } return DisplayLinkTicker.ticks(anchoredTo: view) } } /// The target-action → `AsyncStream` bridge, and the one place the link's lifetime is managed. /// /// **`CADisplayLink` retains its target**, and this object holds the link: that is a cycle, and /// `invalidate()` is the only thing that breaks it. So the link is created and invalidated in one /// place — here — with the stream's own `onTermination` as the trigger, because every way a drag can /// end (cancelled task, `break` out of the loop, the stream simply dropped) terminates the stream. @MainActor private final class DisplayLinkTicker: NSObject { private var link: CADisplayLink? private let continuation: AsyncStream.Continuation private init(continuation: AsyncStream.Continuation) { self.continuation = continuation super.init() } static func ticks(anchoredTo view: NSView) -> AsyncStream { // One frame of backlog and no more: a consumer that fell behind wants the newest timestamp, // not a queue of stale ones to replay. Dropping intermediate frames costs nothing — the // clock integrates over whatever gap it is handed. let (stream, continuation) = AsyncStream.makeStream( bufferingPolicy: .bufferingNewest(1) ) let ticker = DisplayLinkTicker(continuation: continuation) let link = view.displayLink(target: ticker, selector: #selector(fire(_:))) // `.common`, not `.default`: a drag runs AppKit's event-tracking run loop mode, which is // exactly when this has to tick. link.add(to: .main, forMode: .common) ticker.link = link continuation.onTermination = { _ in // Cancellation arrives on whatever thread cancelled; the link is main-actor state. Task { @MainActor in ticker.invalidate() } } return stream } /// `targetTimestamp`, never `timestamp`: the latter is the frame the callback is *behind*, and /// integrating against it produces the interpolation stutter the API's own guidance warns about. /// The target is the time the frame this callback is preparing will be shown, which is the time /// the content should be at. @objc private func fire(_ link: CADisplayLink) { continuation.yield(link.targetTimestamp) } private func invalidate() { link?.invalidate() link = nil } } /// 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. The same view is the display link's anchor, so this is also what puts the /// driver's clock on the right screen. 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 } }