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:
+3
-1
@@ -182,10 +182,12 @@ The pointer may also sit outside the visible area and still drive it: generously
|
||||
|
||||
Three constraints shape the driver, which is the session's half of the work:
|
||||
|
||||
- **The pointer is the physical mouse**, partly for the general reason above, 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 no events at all.
|
||||
- **The pointer is the physical mouse**, partly for the general reason above, 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. The lane's drop delegate and the autoscroll driver must go through one shared retarget, so they can never disagree.
|
||||
- **Termination is structural**, like the rest of the session lifecycle: the driver is a `.task(id:)` keyed on the session, so it is cancelled the moment the session ends — and the watchdog guarantees that flag clears no matter how the drag finished.
|
||||
|
||||
The clock is the display's. Frames arrive from a `CADisplayLink` on whichever screen the lane is drawn on (`NSView.displayLink(target:selector:)`, bridged to an `AsyncStream` the `.task` above consumes), and each step integrates the gap between two `targetTimestamp`s, clamped at 50ms so a link resuming from an occluded window steps once rather than teleporting the lane. The 16ms `Task.sleep` loop this replaced had two defects that reach the user as judder: `Task.sleep` guarantees only a *lower* bound on the wake-up, and a fixed ~60Hz cadence beats against a 120Hz panel instead of landing on it. Both arrive at the shadow as well as at the content, because every scroll step drags a retarget behind it.
|
||||
|
||||
The board strip itself has nothing to autoscroll: every lane is always visible (the window width divides across the lanes' width units) and the strip fills the window height, so there is no board-level scroller in either axis. The geometry above is axis-agnostic and would serve one unchanged if that ever changes.
|
||||
|
||||
## Adjacent interaction: the lane resize drag (not a drag session)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **The edge-autoscroll driver's clock** — the half of `DragAutoScroller` that is not AppKit
|
||||
/// (DRAG-REORDER.md § Edge autoscroll; the geometry it feeds is `DragAutoScrollMathTests`).
|
||||
///
|
||||
/// The split is where the testable line falls. `step(at:)` reads the physical mouse, the anchor's
|
||||
/// window and a live `NSClipView`, none of which a unit test can pose; what it *decides* is
|
||||
/// `elapsed × velocity`, and both factors are pure. So `DragAutoScrollClock` is exercised directly —
|
||||
/// composed with `DragAutoScrollMath` exactly as the step composes them, which is what makes the
|
||||
/// frame-rate-independence claim a measurement rather than an assertion — and the loop around it is
|
||||
/// exercised through a scripted tick source, because a `CADisplayLink` needs a screen and a run loop
|
||||
/// and there is neither in a test bundle.
|
||||
|
||||
// MARK: - Composing the clock with the geometry, as the step does
|
||||
|
||||
/// A tick sequence of `count` frames at `hz`, starting at a plausible `CACurrentMediaTime()` rather
|
||||
/// than at zero — the driver never sees a timeline that begins at the origin.
|
||||
private func frames(hz: Int, count: Int, from start: CFTimeInterval = 41_297.5) -> [CFTimeInterval] {
|
||||
(0...count).map { start + CFTimeInterval($0) / CFTimeInterval(hz) }
|
||||
}
|
||||
|
||||
/// Total scroll travel over `ticks`, integrated the way `DragAutoScroller.step(at:)` integrates: the
|
||||
/// clock's elapsed for this frame, the geometry's velocity for this pointer, `nextOffset` for the
|
||||
/// new origin.
|
||||
private func travel(over ticks: [CFTimeInterval],
|
||||
pointer: CGFloat,
|
||||
length: CGFloat = 400) -> CGFloat {
|
||||
let velocity = DragAutoScrollMath.velocity(position: pointer, length: length)
|
||||
var clock = DragAutoScrollClock()
|
||||
var offset: CGFloat = 0
|
||||
for timestamp in ticks {
|
||||
offset = DragAutoScrollMath.nextOffset(
|
||||
current: offset,
|
||||
velocity: velocity,
|
||||
elapsed: CGFloat(clock.elapsed(at: timestamp)),
|
||||
// Far beyond anything a second of scrolling can reach, in either direction, so the range
|
||||
// clamp — which `DragAutoScrollMathTests` owns — never confounds these numbers.
|
||||
minOffset: -100_000,
|
||||
maxOffset: 100_000
|
||||
)
|
||||
}
|
||||
return offset
|
||||
}
|
||||
|
||||
private func isClose(_ value: CGFloat, _ expected: CGFloat, _ tolerance: CGFloat = 0.0001) -> Bool {
|
||||
abs(value - expected) <= tolerance
|
||||
}
|
||||
|
||||
@Suite("DragAutoScrollClock")
|
||||
struct DragAutoScrollClockTests {
|
||||
|
||||
@Test("A session's first tick establishes the base and scrolls nothing")
|
||||
func firstTickIsTheBase() {
|
||||
var clock = DragAutoScrollClock()
|
||||
#expect(clock.elapsed(at: 41_297.5) == 0)
|
||||
// And the second measures from it rather than from anything the driver did before.
|
||||
#expect(isClose(CGFloat(clock.elapsed(at: 41_297.5 + 1.0 / 120)), CGFloat(1.0 / 120)))
|
||||
}
|
||||
|
||||
@Test("Resetting makes the next tick a first tick again")
|
||||
func resetRestoresTheBase() {
|
||||
var clock = DragAutoScrollClock()
|
||||
_ = clock.elapsed(at: 100)
|
||||
_ = clock.elapsed(at: 100.5)
|
||||
clock.reset()
|
||||
#expect(clock.elapsed(at: 900) == 0, "a new drag must not integrate the gap since the last one")
|
||||
}
|
||||
|
||||
@Test("A second at 120Hz and a second at 60Hz travel the same distance")
|
||||
func frameRateIndependence() {
|
||||
// Deep in the trailing band, so the velocity is the 800pt/s ceiling and the expected figure
|
||||
// is arithmetic anyone can check: one second of it.
|
||||
let atTheEdge: CGFloat = 400
|
||||
let fast = travel(over: frames(hz: 120, count: 120), pointer: atTheEdge)
|
||||
let slow = travel(over: frames(hz: 60, count: 60), pointer: atTheEdge)
|
||||
|
||||
#expect(isClose(fast, DragAutoScrollMath.maxSpeed))
|
||||
#expect(isClose(slow, DragAutoScrollMath.maxSpeed))
|
||||
#expect(isClose(fast, slow, 0.000_001), "\(fast) vs \(slow)")
|
||||
}
|
||||
|
||||
@Test("A ramp speed is frame-rate independent too, not just the ceiling")
|
||||
func frameRateIndependenceOnTheRamp() {
|
||||
// Halfway into the leading band: the mean of floor and ceiling, scrolling the other way.
|
||||
let midBand = DragAutoScrollMath.band / 2
|
||||
let expected = -(DragAutoScrollMath.minSpeed + DragAutoScrollMath.maxSpeed) / 2
|
||||
// Same second, three different cadences — including one no display runs at, because the
|
||||
// point is that the integration does not know or care.
|
||||
let rates = [120, 60, 47]
|
||||
let travelled = rates.map { travel(over: frames(hz: $0, count: $0), pointer: midBand) }
|
||||
for (rate, distance) in zip(rates, travelled) {
|
||||
#expect(isClose(distance, expected, 0.001), "\(rate)Hz travelled \(distance)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("An irregular cadence integrates its real span, not its frame count")
|
||||
func jitterIntegratesTheSpan() {
|
||||
// 120Hz with two frames dropped and one late — the same second of wall clock either way.
|
||||
let start: CFTimeInterval = 41_297.5
|
||||
var ticks: [CFTimeInterval] = [start]
|
||||
var t = start
|
||||
for step in [0.008, 0.0083, 0.025, 0.0083, 0.0083, 0.0418, 0.9] {
|
||||
t += step
|
||||
ticks.append(t)
|
||||
}
|
||||
// The 0.9 gap is clamped, so the honest expectation is the sum of the clamped steps.
|
||||
let expected = DragAutoScrollMath.maxSpeed
|
||||
* CGFloat(0.008 + 0.0083 + 0.025 + 0.0083 + 0.0083 + 0.0418 + DragAutoScrollClock.maxStep)
|
||||
#expect(isClose(travel(over: ticks, pointer: 400), expected, 0.001))
|
||||
}
|
||||
|
||||
@Test("A resumed link steps once, not by however long it was paused")
|
||||
func pauseResumeHitsTheClamp() {
|
||||
var clock = DragAutoScrollClock()
|
||||
_ = clock.elapsed(at: 41_297.5)
|
||||
_ = clock.elapsed(at: 41_297.5 + 1.0 / 120)
|
||||
// The view was occluded for two seconds; the first frame back must not teleport the lane.
|
||||
#expect(clock.elapsed(at: 41_299.5) == DragAutoScrollClock.maxStep)
|
||||
// 40 points rather than 1600.
|
||||
#expect(isClose(travel(over: [0, 2.0], pointer: 400),
|
||||
DragAutoScrollMath.maxSpeed * CGFloat(DragAutoScrollClock.maxStep)))
|
||||
}
|
||||
|
||||
@Test("A merely late frame is honoured in full")
|
||||
func aLateFrameIsNotClamped() {
|
||||
var clock = DragAutoScrollClock()
|
||||
_ = clock.elapsed(at: 100)
|
||||
// Two 60Hz frames' worth: late, well inside the clamp, and worth exactly what it says.
|
||||
#expect(isClose(CGFloat(clock.elapsed(at: 100 + 1.0 / 30)), CGFloat(1.0 / 30)))
|
||||
}
|
||||
|
||||
@Test("A timestamp that goes backwards scrolls nothing rather than backwards")
|
||||
func backwardsTimeIsInert() {
|
||||
var clock = DragAutoScrollClock()
|
||||
_ = clock.elapsed(at: 500)
|
||||
#expect(clock.elapsed(at: 499) == 0)
|
||||
// And the base moved with it, so the sequence recovers rather than stalling.
|
||||
#expect(isClose(CGFloat(clock.elapsed(at: 499 + 1.0 / 120)), CGFloat(1.0 / 120)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The loop and the link's lifetime
|
||||
|
||||
/// A stand-in for `DisplayLinkTickSource` whose frames the test writes, and which records the one
|
||||
/// thing the real source does on termination: it invalidates the link.
|
||||
@MainActor
|
||||
private final class ScriptedTicks: DragAutoScrollTickSource {
|
||||
|
||||
private var continuation: AsyncStream<CFTimeInterval>.Continuation?
|
||||
private var startWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
private var stopWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
private(set) var started = false
|
||||
|
||||
/// Set from the stream's `onTermination`, through the same main-actor hop the real ticker uses to
|
||||
/// reach `CADisplayLink.invalidate()`.
|
||||
private(set) var invalidated = false
|
||||
|
||||
func ticks(anchoredTo view: NSView?) -> AsyncStream<CFTimeInterval> {
|
||||
let (stream, continuation) = AsyncStream<CFTimeInterval>.makeStream(bufferingPolicy: .unbounded)
|
||||
self.continuation = continuation
|
||||
continuation.onTermination = { _ in
|
||||
Task { @MainActor in self.markInvalidated() }
|
||||
}
|
||||
started = true
|
||||
for waiter in startWaiters { waiter.resume() }
|
||||
startWaiters = []
|
||||
return stream
|
||||
}
|
||||
|
||||
func yield(_ timestamp: CFTimeInterval) {
|
||||
continuation?.yield(timestamp)
|
||||
}
|
||||
|
||||
func finish() {
|
||||
continuation?.finish()
|
||||
}
|
||||
|
||||
/// Resumes once the driver has asked for its stream — the loop is running and suspended on it.
|
||||
func waitUntilStarted() async {
|
||||
guard !started else { return }
|
||||
await withCheckedContinuation { startWaiters.append($0) }
|
||||
}
|
||||
|
||||
func waitUntilInvalidated() async {
|
||||
guard !invalidated else { return }
|
||||
await withCheckedContinuation { stopWaiters.append($0) }
|
||||
}
|
||||
|
||||
private func markInvalidated() {
|
||||
invalidated = true
|
||||
for waiter in stopWaiters { waiter.resume() }
|
||||
stopWaiters = []
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("DragAutoScrollDriver")
|
||||
@MainActor
|
||||
struct DragAutoScrollDriverTests {
|
||||
|
||||
@Test("The loop is driven by the frames, and ends with them")
|
||||
func theLoopFollowsTheStream() async {
|
||||
let source = ScriptedTicks()
|
||||
let scroller = DragAutoScroller(tickSource: source)
|
||||
|
||||
let running = Task { await scroller.run() }
|
||||
await source.waitUntilStarted()
|
||||
for frame in frames(hz: 120, count: 4) { source.yield(frame) }
|
||||
source.finish()
|
||||
// Returning at all is the assertion: nothing in the driver sleeps or polls, so a run that
|
||||
// ends when its frames do is a run the frames were driving. A hang here is the failure.
|
||||
await running.value
|
||||
await source.waitUntilInvalidated()
|
||||
#expect(source.invalidated, "finishing terminates the stream, which retires the link")
|
||||
}
|
||||
|
||||
@Test("Cancelling the drag terminates the stream, which is what invalidates the link")
|
||||
func cancellationRetiresTheLink() async {
|
||||
let source = ScriptedTicks()
|
||||
let scroller = DragAutoScroller(tickSource: source)
|
||||
|
||||
// A source that never finishes: only cancellation can end this, which is the drag session's
|
||||
// contract — the `.task(id:)` is cancelled the moment the session clears.
|
||||
let running = Task { await scroller.run() }
|
||||
await source.waitUntilStarted()
|
||||
source.yield(41_297.5)
|
||||
source.yield(41_297.5 + 1.0 / 120)
|
||||
|
||||
running.cancel()
|
||||
await running.value
|
||||
await source.waitUntilInvalidated()
|
||||
|
||||
#expect(source.invalidated, "the link must not outlive the drag")
|
||||
}
|
||||
|
||||
@Test("A driver with no anchor returns instead of spinning")
|
||||
func noAnchorNoFrames() async {
|
||||
// The shipping source with nothing to scroll: a lane whose representable never bound a view
|
||||
// has no display to link to, and must not leave a `.task` running for the drag's duration.
|
||||
let scroller = DragAutoScroller()
|
||||
await scroller.run()
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import Testing
|
||||
|
||||
/// `DragAutoScrollMath` — given a viewport and a pointer inside (or just outside) it, how fast, and
|
||||
/// which way, should the scroll view move? Ported from the pathfinder's suite, whose numbers are
|
||||
/// what was proven. The live driver is the drag session's; this is the decision it makes 60 times a
|
||||
/// second (DRAG-REORDER.md § Edge autoscroll).
|
||||
/// what was proven. The live driver is the drag session's; this is the decision it makes once per
|
||||
/// frame of the display the lane is on (`DragAutoScrollDriverTests`, DRAG-REORDER.md § Edge
|
||||
/// autoscroll).
|
||||
|
||||
private let length: CGFloat = 400
|
||||
private let band = DragAutoScrollMath.band
|
||||
|
||||
Reference in New Issue
Block a user