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
255 lines
12 KiB
Swift
255 lines
12 KiB
Swift
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<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. 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
|
||
}
|
||
}
|