summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/macos.zig194
-rw-r--r--src/macos/Sources/PardesView.swift34
-rw-r--r--src/macos/pardes.h18
-rw-r--r--src/pardes.zig2
-rw-r--r--test/macos-snapshots/momentum.golden186
-rw-r--r--test/macos-snapshots/momentum.snap73
-rw-r--r--test/macos_e2e.swift25
7 files changed, 509 insertions, 23 deletions
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 <rows> <col> <row> [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"))