Autoscroll ticks on the display's own clock — the 16ms sleeping loop retires

DragAutoScroller.run() consumes an AsyncStream of CADisplayLink
targetTimestamps from NSView.displayLink(target:selector:) on the
lane's existing anchor view (which was already the right NSView — no
new plumbing). The stream is the lifetime: cancellation ends the
iteration, termination invalidates the link, the link releases its
target. DragAutoScrollClock keeps the retired loop's two non-trivial
rules — first tick scrolls nothing, no tick integrates past 50ms (a
paused link resumes with the whole gap as its delta) — and the math is
frame-rate independent by test: one second at the edge moves 800.0
points at 120Hz and at 60Hz alike. Run-loop mode .common is
load-bearing: a drag runs AppKit's event-tracking mode.

Drag-perf card 2e08fd31.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 20:22:42 -04:00
parent 0218ae4c21
commit 1fc8aaa249
4 changed files with 409 additions and 21 deletions
+156 -18
View File
@@ -9,7 +9,7 @@ import SwiftUI
///
/// - **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
/// 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
@@ -19,35 +19,53 @@ import SwiftUI
/// - **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, and its window converts the physical cursor. Weak the view belongs to the hierarchy.
/// 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)?
private var lastTick: CFTimeInterval?
/// 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
nonisolated init() {}
private var clock = DragAutoScrollClock()
/// 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
nonisolated init(tickSource: any DragAutoScrollTickSource = DisplayLinkTickSource()) {
self.tickSource = tickSource
}
private func step() {
let now = CACurrentMediaTime()
let elapsed = min(max(now - (lastTick ?? now), 0), 0.05)
lastTick = now
/// 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,
@@ -86,11 +104,131 @@ final class DragAutoScroller {
}
}
// 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<CFTimeInterval>
}
/// 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<CFTimeInterval> {
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<CFTimeInterval>.Continuation
private init(continuation: AsyncStream<CFTimeInterval>.Continuation) {
self.continuation = continuation
super.init()
}
static func ticks(anchoredTo view: NSView) -> AsyncStream<CFTimeInterval> {
// 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<CFTimeInterval>.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.
/// 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