From 2109098400fa37d7b448f232c848a0411c318591 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 16 Sep 2026 12:18:01 -0300 Subject: Give macOS scrolling momentum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A swipe that was still moving when the fingers lifted stopped dead. The dial next to it has thrown properly since pardes_rotate_end, and this is that same curve on the scroll accumulator: velocity sampled between events off the monotonic clock, weighted toward the newest sample because a flick is decided by how the hand was moving when it left, and a fling that ramps up from zero at the floor rather than switching on at it. A coast stops at the end of a document rather than spinning its remaining velocity against the edge, which is why spendScroll now reports whether the pane moved and why paneAt is public. Only a step that delivered a press can report an edge — the many steps between two rows cross nothing. Nothing suppresses AppKit's own momentum, and nothing needs to: its momentum events are ordinary pardes_scroll calls, every one of which cancels the coast before spending its travel, so on a real trackpad the system takes the gesture over about a frame after the lift and the tail it ends on is below the floor. This is the path for devices AppKit does not fling for — and the only one a script can reach, since NSEvent phases have no public constructor. momentum.snap asserts both halves, which is why it needs the long scrollback: a flick with no document left proves nothing. A slow swipe is byte-identical after the release; a hard one coasts twenty-four rows further on its own, and a finger back on the pad stops it there. Co-Authored-By: Claude Opus 5 (1M context) --- src/macos/Sources/PardesView.swift | 34 ++++++++++++++++++++++++++++++++++ src/macos/pardes.h | 18 ++++++++++++++++++ 2 files changed, 52 insertions(+) (limited to 'src/macos') diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift index 5df1fd73..ba3677e7 100644 --- a/src/macos/Sources/PardesView.swift +++ b/src/macos/Sources/PardesView.swift @@ -1842,6 +1842,20 @@ final class PardesView: NSView { fed() } + /// Fingers down and fingers up on a precise scroll. Entry points of their + /// own for the same reason rotateEnd is one: NSEvent phases have no public + /// constructor, and a fling nothing can synthesize is a fling nothing can + /// assert. + func scrollBegin() { + pardes_scroll_begin() + fed() + } + + func scrollEnd() { + pardes_scroll_end() + fed() + } + func rotate(degrees: CGFloat) { guard degrees != 0 else { return } pardes_rotate(Float(degrees)) @@ -2086,12 +2100,32 @@ final class PardesView: NSView { return } if event.hasPreciseScrollingDeltas { + // Phases first: a finger back on the pad has to catch a coast before + // its own travel is spent, or the first millimetre of the new swipe + // would be added to the old fling instead of replacing it. + // + // Momentum events carry `phase == .none` and a momentumPhase + // instead, so neither branch here fires for them — they are just + // more travel, and libpardes lets them take the gesture over. That + // is deliberate: AppKit's fling is the one the rest of the system + // uses, and a second fling of our own underneath it would scroll + // everything at double speed. `pardes_scroll_end` exists for the + // devices AppKit does NOT fling for. + if event.phase == .began { scrollBegin() } // The core scrolls a cell at a time, so libpardes accumulates the // sub-cell travel and spends it as wheel presses — which is why the // cell has to travel with the delta. scroll(rows: -event.scrollingDeltaY / cellHeight, cols: -event.scrollingDeltaX / cellWidth, at: at) + switch event.phase { + case .ended: scrollEnd() + // A gesture the system took away should not be thrown. scrollBegin + // is the catching half of the pair — coast stopped, bank and + // velocity cleared — which is exactly what a cancellation means. + case .cancelled: scrollBegin() + default: break + } } else { // AppKit's sign is the opposite of the DOM's: positive deltaY means the // content moved down, which is a scroll back through history. diff --git a/src/macos/pardes.h b/src/macos/pardes.h index 5f1ccf77..a2d13b1f 100644 --- a/src/macos/pardes.h +++ b/src/macos/pardes.h @@ -377,6 +377,24 @@ void pardes_watch_changed(uint8_t pane, uint32_t generation); void pardes_scroll(float delta_rows, float delta_cols, uint16_t col, uint16_t row); +// The gesture boundaries around pardes_scroll, and the whole of scroll +// momentum. `begin` is fingers down: it catches a coast the way a hand catches +// a spinning dial, and drops whatever the last gesture left banked. `end` is +// fingers up: a swipe still moving when it ended becomes a fling, in +// proportion to how hard it was thrown, on the same curve pardes_rotate_end +// uses — momentum ramps up from zero rather than switching on at a threshold. +// +// A coast makes pardes_animating true and is spent by pardes_animation_tick, +// and it stops at the end of a document instead of spinning against the edge. +// +// A host whose windowing system already flings for it needs no suppression: +// the system's momentum arrives as ordinary pardes_scroll calls, every one of +// which cancels the coast before spending its own travel, so that momentum +// simply takes the gesture over. A host that calls neither — a discrete wheel +// has no phases — scrolls with no momentum at all. +void pardes_scroll_begin(void); +void pardes_scroll_end(void); + // A two-finger trackpad rotation, in degrees since the last call, positive // counterclockwise (AppKit's sign, unchanged). The core has no rotation: this // is spent as the search-step keys, clockwise `n` and counterclockwise `N`, a -- cgit v1.3