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