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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user