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.zig | 194 +++++++++++++++++++++++++++++++---- src/macos/Sources/PardesView.swift | 34 ++++++ src/macos/pardes.h | 18 ++++ src/pardes.zig | 2 +- test/macos-snapshots/momentum.golden | 186 +++++++++++++++++++++++++++++++++ test/macos-snapshots/momentum.snap | 73 +++++++++++++ test/macos_e2e.swift | 25 +++++ 7 files changed, 509 insertions(+), 23 deletions(-) create mode 100644 test/macos-snapshots/momentum.golden create mode 100644 test/macos-snapshots/momentum.snap diff --git a/src/macos.zig b/src/macos.zig index 67a5ec5b..4b37255e 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -658,6 +658,15 @@ const State = struct { gens: [pardes.MAX_PANES]u32 = @splat(0), scroll_lag: f32 = 0, scroll_lag_x: f32 = 0, + scroll_velocity: f32 = 0, + scroll_velocity_x: f32 = 0, + scroll_last_ns: i128 = 0, + /// Where the gesture turned. A coast keeps delivering THERE rather than + /// wherever the pointer has drifted to since — the same rule the SDL + /// shell keeps in scroll_col/scroll_row. + scroll_col: u16 = 0, + scroll_row: u16 = 0, + scroll_coasting: bool = false, rotate_lag: f32 = 0, rotate_velocity: f32 = 0, rotate_last_ns: i128 = 0, @@ -866,7 +875,7 @@ test "the animation clock spends real time, not callbacks" { export fn pardes_animating() bool { const st = &(state orelse return false); - return st.core.animationActive() or st.rotate_coasting; + return st.core.animationActive() or st.rotate_coasting or st.scroll_coasting; } export fn pardes_theme_bg() u32 { @@ -1282,6 +1291,10 @@ fn restoreCore(st: *State) bool { st.cwd_stale = @splat(false); st.scroll_lag = 0; st.scroll_lag_x = 0; + st.scroll_velocity = 0; + st.scroll_velocity_x = 0; + st.scroll_last_ns = 0; + st.scroll_coasting = false; st.rotate_lag = 0; st.rotate_velocity = 0; st.rotate_last_ns = 0; @@ -1464,6 +1477,30 @@ export fn pardes_animation_tick() bool { st.core.update(.tick); changed = true; } + if (st.scroll_coasting) { + const spent_now = spendScroll( + st, + st.scroll_velocity * scroll_fling_step, + st.scroll_velocity_x * scroll_fling_step, + st.scroll_col, + st.scroll_row, + ); + st.scroll_velocity *= scroll_fling_decay; + st.scroll_velocity_x *= scroll_fling_decay; + const decayed = @abs(st.scroll_velocity) < scroll_fling_stop and + @abs(st.scroll_velocity_x) < scroll_fling_stop; + // Only a step that actually delivered a press can report an edge; + // the many steps between two rows cross nothing and say nothing. + const at_edge = spent_now.ticks != 0 and !spent_now.moved; + if (decayed or at_edge) { + st.scroll_velocity = 0; + st.scroll_velocity_x = 0; + st.scroll_coasting = false; + st.scroll_lag = 0; + st.scroll_lag_x = 0; + } + changed = true; + } if (st.rotate_coasting) { spendRotation(st, st.rotate_velocity * rotation_fling_step); st.rotate_velocity *= rotation_fling_decay; @@ -1563,28 +1600,48 @@ export fn pardes_pointer_leave() void { export fn pardes_scroll(delta_rows: f32, delta_cols: f32, col: u16, row: u16) void { const st = &(state orelse return); - var down_left = takeScrollTicks(&st.scroll_lag, delta_rows); - while (down_left != 0) { - const down = down_left > 0; - down_left += if (down) -1 else 1; - st.core.update(.{ .mouse = .{ - .button = if (down) .wheel_down else .wheel_up, - .kind = .press, - .col = col, - .row = row, - } }); - } - var right_left = takeScrollTicks(&st.scroll_lag_x, delta_cols); - while (right_left != 0) { - const right = right_left > 0; - right_left += if (right) -1 else 1; - st.core.update(.{ .mouse = .{ - .button = if (right) .wheel_right else .wheel_left, - .kind = .press, - .col = col, - .row = row, - } }); + st.scroll_col = col; + st.scroll_row = row; + noteScrollVelocity(st, delta_rows, delta_cols); + _ = spendScroll(st, delta_rows, delta_cols, col, row); +} + +/// A finger back on the pad, which catches a coast the way a hand catches a +/// spinning dial. Also drops whatever the last gesture left banked, so the +/// first millimetre of a new swipe cannot inherit a nearly-complete row. +export fn pardes_scroll_begin() void { + const st = &(state orelse return); + st.scroll_lag = 0; + st.scroll_lag_x = 0; + st.scroll_velocity = 0; + st.scroll_velocity_x = 0; + st.scroll_coasting = false; + st.scroll_last_ns = monotonicNs(); +} + +/// Fingers lifted. A swipe that was still moving when it ended becomes a +/// fling, measured from the speed of the last events rather than from any one +/// of them, exactly as pardes_rotate_end measures the dial. +/// +/// A host whose windowing system flings for it need do nothing to suppress +/// this: its first momentum event is an ordinary pardes_scroll, and every +/// pardes_scroll cancels the coast before spending its own travel. The system's +/// momentum simply takes the gesture over, and the tail of that momentum is +/// below the fling floor, so its end arms nothing. +export fn pardes_scroll_end() void { + const st = &(state orelse return); + const last = st.scroll_last_ns; + st.scroll_last_ns = 0; + st.scroll_coasting = false; + // A hand that came to rest before lifting is not throwing anything. + if (last == 0 or monotonicNs() - last > 90 * std.time.ns_per_ms) { + st.scroll_velocity = 0; + st.scroll_velocity_x = 0; + return; } + st.scroll_velocity = scrollFling(st.scroll_velocity); + st.scroll_velocity_x = scrollFling(st.scroll_velocity_x); + st.scroll_coasting = st.scroll_velocity != 0 or st.scroll_velocity_x != 0; } export fn pardes_rotate(degrees: f32) void { @@ -2351,6 +2408,97 @@ fn encodeCellFlags(default: bool, role: pardes.FontRole) u8 { @as(u8, @intFromBool(role == .tagline)) * cell_flag_tagline; } +fn noteScrollVelocity(st: *State, delta_rows: f32, delta_cols: f32) void { + const now = monotonicNs(); + const last = st.scroll_last_ns; + st.scroll_last_ns = now; + // Any real travel ends a coast: that is what lets a host with momentum of + // its own take the gesture over without either side knowing about it. + st.scroll_coasting = false; + if (last == 0 or now == 0) return; + const dt_ns = now - last; + // A gap this long is a new gesture, not a fast one. Without the guard the + // pause between two separate swipes would read as enormous speed. + if (dt_ns <= 0 or dt_ns > 200 * std.time.ns_per_ms) return; + const seconds: f32 = @floatCast(@as(f64, @floatFromInt(dt_ns)) / @as(f64, std.time.ns_per_s)); + const rows = delta_rows / seconds; + const cols = delta_cols / seconds; + if (!std.math.isFinite(rows) or !std.math.isFinite(cols)) return; + // Weighted toward the newest sample: a flick is decided by how the hand was + // moving when it left, not by how the swipe started. + st.scroll_velocity = st.scroll_velocity * 0.35 + rows * 0.65; + st.scroll_velocity_x = st.scroll_velocity_x * 0.35 + cols * 0.65; +} + +/// Spend accumulated travel as wheel presses, and report whether the pane under +/// the gesture actually moved. +/// +/// A coast has to stop at the end of a document instead of spinning the rest of +/// its velocity against the edge — the SDL shell stops for the same reason in +/// stepScroll. Only the vertical axis can be measured that way (a pane's +/// horizontal position is not Pane.scroll()), so sideways travel counts as +/// movement; a purely horizontal fling therefore rides its decay out, which is +/// what it would do anyway once the drift guard has swallowed it. +const Spent = struct { + /// Whole wheel presses delivered. Most coast steps cross no row at all, + /// which is why "did not move" cannot be read off `moved` alone. + ticks: u32 = 0, + moved: bool = false, +}; + +fn spendScroll(st: *State, delta_rows: f32, delta_cols: f32, col: u16, row: u16) Spent { + const pane = if (st.core.paneAt(col, row)) |id| st.core.panes[id] else null; + var out: Spent = .{}; + var down_left = takeScrollTicks(&st.scroll_lag, delta_rows); + while (down_left != 0) { + const down = down_left > 0; + down_left += if (down) -1 else 1; + out.ticks += 1; + const was = if (pane) |p| p.scroll() else 0; + st.core.update(.{ .mouse = .{ + .button = if (down) .wheel_down else .wheel_up, + .kind = .press, + .col = col, + .row = row, + } }); + if (pane) |p| { + if (p.scroll() != was) out.moved = true; + } else out.moved = true; + } + var right_left = takeScrollTicks(&st.scroll_lag_x, delta_cols); + while (right_left != 0) { + const right = right_left > 0; + right_left += if (right) -1 else 1; + st.core.update(.{ .mouse = .{ + .button = if (right) .wheel_right else .wheel_left, + .kind = .press, + .col = col, + .row = row, + } }); + out.ticks += 1; + out.moved = true; + } + return out; +} + +// Rows per second. The floor is what separates a flick from a swipe that +// merely ended while still in contact; the ceiling stops a jab from launching +// the page past anything the eye can follow. Step and decay are stated at +// 60 Hz and spent by the same measured display clock the rotation fling uses, +// so a 120 Hz screen coasts the same distance in the same time. +const scroll_fling_floor: f32 = 8; +const scroll_fling_max: f32 = 90; +const scroll_fling_step: f32 = 1.0 / 60.0; +const scroll_fling_decay: f32 = 0.94; +const scroll_fling_stop: f32 = 2; + +fn scrollFling(speed: f32) f32 { + if (!std.math.isFinite(speed)) return 0; + const excess = @min(@abs(speed) - scroll_fling_floor, scroll_fling_max); + if (excess < scroll_fling_stop) return 0; + return std.math.copysign(excess, speed); +} + fn takeScrollTicks(lag: *f32, delta_rows: f32) i32 { if (!std.math.isFinite(delta_rows)) return 0; const next = std.math.clamp(lag.* + delta_rows, -256, 256); @@ -2419,6 +2567,8 @@ test "pardes.h declares every export the way it is defined" { try expectSameAbi(@TypeOf(c.pardes_pointer_leave), @TypeOf(pardes_pointer_leave)); try expectSameAbi(@TypeOf(c.pardes_watch_changed), @TypeOf(pardes_watch_changed)); try expectSameAbi(@TypeOf(c.pardes_scroll), @TypeOf(pardes_scroll)); + try expectSameAbi(@TypeOf(c.pardes_scroll_begin), @TypeOf(pardes_scroll_begin)); + try expectSameAbi(@TypeOf(c.pardes_scroll_end), @TypeOf(pardes_scroll_end)); try expectSameAbi(@TypeOf(c.pardes_rotate), @TypeOf(pardes_rotate)); try expectSameAbi(@TypeOf(c.pardes_rotate_end), @TypeOf(pardes_rotate_end)); try expectSameAbi(@TypeOf(c.pardes_command), @TypeOf(pardes_command)); 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 diff --git a/src/pardes.zig b/src/pardes.zig index 16343972..c1a6b038 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -11047,7 +11047,7 @@ pub const Pardes = struct { return null; } - fn paneAt(p: *const Pardes, col: u16, row: u16) ?usize { + pub fn paneAt(p: *const Pardes, col: u16, row: u16) ?usize { for (p.panes, 0..) |slot, id| { if (slot == null) continue; const r = p.rects[id]; diff --git a/test/macos-snapshots/momentum.golden b/test/macos-snapshots/momentum.golden new file mode 100644 index 00000000..b4471061 --- /dev/null +++ b/test/macos-snapshots/momentum.golden @@ -0,0 +1,186 @@ +== snap home grid=100x30 cursor=4,29 +|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill +| New Tty Find Grep Joincol +|$ /private/tmp/pardes-macos-e2e/momentum/cwd Tty Save Del Mode Filter Collapse +| LINE 175 +| LINE 176 +| LINE 177 +| LINE 178 +| LINE 179 +| LINE 180 +| LINE 181 +| LINE 182 +| LINE 183 +| LINE 184 +| LINE 185 +| LINE 186 +| LINE 187 +| LINE 188 +| LINE 189 +| LINE 190 +| LINE 191 +| LINE 192 +| LINE 193 +| LINE 194 +| LINE 195 +| LINE 196 +| LINE 197 +| LINE 198 +| LINE 199 +| LINE 200 +| $ +== snap placed grid=100x30 cursor=0,0 +|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill +| New Tty Find Grep Joincol +|$ /private/tmp/pardes-macos-e2e/momentum/cwd Tty Save Del Mode Filter Collapse +| LINE 174 +| LINE 175 +| LINE 176 +| LINE 177 +| LINE 178 +| LINE 179 +| LINE 180 +| LINE 181 +| LINE 182 +| LINE 183 +| LINE 184 +| LINE 185 +| LINE 186 +| LINE 187 +| LINE 188 +| LINE 189 +| LINE 190 +| LINE 191 +| LINE 192 +| LINE 193 +| LINE 194 +| LINE 195 +| LINE 196 +| LINE 197 +| LINE 198 +| LINE 199 +| LINE 200 +== snap placed_released grid=100x30 cursor=0,0 +|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill +| New Tty Find Grep Joincol +|$ /private/tmp/pardes-macos-e2e/momentum/cwd Tty Save Del Mode Filter Collapse +| LINE 174 +| LINE 175 +| LINE 176 +| LINE 177 +| LINE 178 +| LINE 179 +| LINE 180 +| LINE 181 +| LINE 182 +| LINE 183 +| LINE 184 +| LINE 185 +| LINE 186 +| LINE 187 +| LINE 188 +| LINE 189 +| LINE 190 +| LINE 191 +| LINE 192 +| LINE 193 +| LINE 194 +| LINE 195 +| LINE 196 +| LINE 197 +| LINE 198 +| LINE 199 +| LINE 200 +== snap thrown grid=100x30 cursor=0,0 +|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill +| New Tty Find Grep Joincol +|$ /private/tmp/pardes-macos-e2e/momentum/cwd Tty Save Del Mode Filter Collapse +| LINE 165 +| LINE 166 +| LINE 167 +| LINE 168 +| LINE 169 +| LINE 170 +| LINE 171 +| LINE 172 +| LINE 173 +| LINE 174 +| LINE 175 +| LINE 176 +| LINE 177 +| LINE 178 +| LINE 179 +| LINE 180 +| LINE 181 +| LINE 182 +| LINE 183 +| LINE 184 +| LINE 185 +| LINE 186 +| LINE 187 +| LINE 188 +| LINE 189 +| LINE 190 +| LINE 191 +== snap coasted grid=100x30 cursor=0,0 +|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill +| New Tty Find Grep Joincol +|$ /private/tmp/pardes-macos-e2e/momentum/cwd Tty Save Del Mode Filter Collapse +| LINE 141 +| LINE 142 +| LINE 143 +| LINE 144 +| LINE 145 +| LINE 146 +| LINE 147 +| LINE 148 +| LINE 149 +| LINE 150 +| LINE 151 +| LINE 152 +| LINE 153 +| LINE 154 +| LINE 155 +| LINE 156 +| LINE 157 +| LINE 158 +| LINE 159 +| LINE 160 +| LINE 161 +| LINE 162 +| LINE 163 +| LINE 164 +| LINE 165 +| LINE 166 +| LINE 167 +== snap caught grid=100x30 cursor=0,0 +|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill +| New Tty Find Grep Joincol +|$ /private/tmp/pardes-macos-e2e/momentum/cwd Tty Save Del Mode Filter Collapse +| LINE 141 +| LINE 142 +| LINE 143 +| LINE 144 +| LINE 145 +| LINE 146 +| LINE 147 +| LINE 148 +| LINE 149 +| LINE 150 +| LINE 151 +| LINE 152 +| LINE 153 +| LINE 154 +| LINE 155 +| LINE 156 +| LINE 157 +| LINE 158 +| LINE 159 +| LINE 160 +| LINE 161 +| LINE 162 +| LINE 163 +| LINE 164 +| LINE 165 +| LINE 166 +| LINE 167 diff --git a/test/macos-snapshots/momentum.snap b/test/macos-snapshots/momentum.snap new file mode 100644 index 00000000..1c578e68 --- /dev/null +++ b/test/macos-snapshots/momentum.snap @@ -0,0 +1,73 @@ +# Scroll momentum: a swipe still moving when the fingers lift keeps scrolling, +# in proportion to how hard it was thrown. +# +# The same shape as the dial's fling in rotate.snap, and asserted the same way, +# because the contract is the same one: momentum ramps up from zero at the +# floor (scroll_fling_floor in src/macos.zig, 8 rows/second) rather than +# switching on at it. The `gap_ms` argument is what makes the two cases +# different — it is a real sleep before the event, so libpardes measures a real +# speed off its monotonic clock rather than reading back-to-back script lines +# as an impossibly fast hand. +# +# Momentum on a real Apple trackpad comes from AppKit, not from here: its +# momentum events are ordinary pardes_scroll calls and every one of them +# cancels the coast. This is the path for the devices AppKit does not fling +# for, and it is the only one a script can reach at all, since NSEvent phases +# have no public constructor. +start 30 100 +wait 8000 Newcol Joincol +wait 8000 $ +stable 700 20000 +# Something long enough to coast ALONG. A flick that runs out of document has +# nothing left to prove: at the top of a short scrollback the hardest possible +# throw and no throw at all leave the same screen. Brace expansion rather than +# seq, so the hermetic world needs nothing on PATH. +text printf 'LINE %s\n' {1..200} +key enter +wait 10000 LINE 200 +stable 700 10000 +snap home + +# ---- PLACED ---- +# +# Half a row every 100 ms is 5 rows/second, under the floor, so lifting the +# fingers changes nothing: the swipe itself has moved what it moved and that is +# where it stops. This is the case that has to hold — a slow deliberate scroll +# that kept sliding afterwards would be unusable. +scroll_begin +scroll -0.5 10 10 0 100 +scroll -0.5 10 10 0 100 +scroll -0.5 10 10 0 100 +stable 700 15000 +snap placed +scroll_end +stable 700 15000 +# Byte-identical to `placed`, which is the whole assertion. +snap placed_released + +# ---- THROWN ---- +# +# The same hand moving six times as far in a twentieth of the time is hundreds +# of rows a second, so the release keeps the view travelling after the fingers +# are gone. +# +# `snap` and then `scroll_end` with NO `stable` between them, which is not +# impatience: a release is only a throw if it arrives while the hand is still +# moving, and a 700 ms wait here would be a hand that stopped — which is +# exactly what PLACED above already proves. +scroll_begin +scroll -3 10 10 0 5 +scroll -3 10 10 0 5 +scroll -3 10 10 0 5 +snap thrown +scroll_end +stable 700 15000 +# Further back through the scrollback than `thrown`, and that distance is the +# coast: nothing but the fling moved the view between these two captures. +snap coasted + +# A finger back on the pad catches it. Begin re-zeros the velocity and the +# bank, so a second throw cannot inherit the first one's speed. +scroll_begin +stable 700 15000 +snap caught diff --git a/test/macos_e2e.swift b/test/macos_e2e.swift index fbfa0e3e..43627a7e 100644 --- a/test/macos_e2e.swift +++ b/test/macos_e2e.swift @@ -790,6 +790,12 @@ private final class Driver: PardesViewDelegate { view.drop([path], at: cell) pump() + // `scroll [cols] [gap_ms]`. The optional gap is a + // real sleep BEFORE the event and means what it does for `rotate`: + // libpardes measures the release speed off the monotonic clock between + // calls, so back-to-back script lines read as an impossibly fast swipe. + // Pacing them is the only way a script can say a swipe was SLOW, which + // is the half of the momentum contract a fling test cannot show. case "scroll": let view = try live() let rows = try double(args, 0, "scroll rows") @@ -799,9 +805,28 @@ private final class Driver: PardesViewDelegate { // other suites' scroll commands do. var cols = 0.0 if args.count > 3 { cols = try double(args, 3, "scroll cols") } + if args.count > 4 { + Thread.sleep(forTimeInterval: try double(args, 4, "scroll gap_ms") / 1000) + } view.scroll(rows: CGFloat(rows), cols: CGFloat(cols), at: cell) pump() + // Fingers down and fingers up. Their own commands for the reason + // rotate_end is one: the fling is decided by the RELEASE SPEED, and + // NSEvent phases have no public constructor, so these entry points are + // the only way the momentum path is reachable from a script at all. + // The coast is then spent by idle's display clock, one step per + // animation frame; ordinary pumps do not move it. + case "scroll_begin": + let view = try live() + view.scrollBegin() + pump() + + case "scroll_end": + let view = try live() + view.scrollEnd() + pump() + case "wheel": let view = try live() let button = try mouseButton(try token(args, 0, "wheel button")) -- cgit v1.3