Files
lanework/Kanban/UI/Board/DragAutoScroller.swift
T
rzen 1fc8aaa249 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
2026-08-01 20:22:42 -04:00

255 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}