From eee45226142bc313a7b84a47251ae7e4590279e5 Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:48:03 -0500 Subject: [PATCH] core: touch coast is a function of time, not of frame cuts Fling.step integrated the decaying speed with an Euler step (speed times the frame gap, then decay), so the same flick travelled differently on uneven frame times. It now applies the exact integral over the frame. Agent-Team: linux Worker: claude Co-Authored-By: Claude Sonnet 5.5 --- core/Fling.zig | 9 ++++++--- core/widgets/ScrollArea.zig | 28 +++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/core/Fling.zig b/core/Fling.zig index 03fc0f049..4fb814459 100644 --- a/core/Fling.zig +++ b/core/Fling.zig @@ -150,14 +150,17 @@ pub fn releaseWindowed(self: *Fling, t: Tuning, window_s: f32) bool { return self.coasting; } -/// Advance the coast one frame and return the position delta to apply, or null when +/// Advance the coast by the last frame's duration and return the position delta to apply, or null when /// not coasting. On the frame the coast finishes it still returns the final delta /// but leaves `coasting` false, so check `coasting` afterward to detect the stop. pub fn step(self: *Fling, t: Tuning) ?f32 { if (!self.coasting) return null; const dt = dvui.secondsSinceLastFrame(); - const delta = self.vel * dt; - self.vel *= @exp(-t.decay * dt); + // The integral of the decaying speed over this frame, not speed times the frame: the + // position then depends on the elapsed time alone, however it was cut into frames. + const keep = @exp(-t.decay * dt); + const delta = self.vel * (1.0 - keep) / t.decay; + self.vel *= keep; if (@abs(self.vel) < t.stop) self.coasting = false; return delta; } diff --git a/core/widgets/ScrollArea.zig b/core/widgets/ScrollArea.zig index b4d81d4ee..54c8c7f15 100644 --- a/core/widgets/ScrollArea.zig +++ b/core/widgets/ScrollArea.zig @@ -211,9 +211,11 @@ fn testFrame() !dvui.App.Result { t_viewport_y = scroll.si.viewport.y; } else { var scroll = ScrollArea.init(@src(), .{ .horizontal = .none, .vertical_bar = .hide }, .{ .expand = .both }); + // After the area's `deinit`, which is where the coast moves it: the frame's final place. + const si = scroll.si; + defer t_viewport_y = si.viewport.y; defer scroll.deinit(); _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 100, .h = 20000 } }); - t_viewport_y = scroll.si.viewport.y; } return .ok; } @@ -255,6 +257,30 @@ fn flick() !f32 { return t_viewport_y - at_release; } +/// How far the viewport has coasted from the release at each of 15 steps of 33 ms, the clock +/// cut into frames by `gaps_ms` (summing to 33), so different cuts meet at equal timestamps. +fn coastTrace(gaps_ms: []const u64, out: *[15]f32) !void { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 300, .h = 400 } }); + defer t.deinit(); + t_use_dvui = false; + const at_release = try swipe(); + // A frame's step spans the gap before it, so end on the last gap and the frames after + // it span `gaps_ms` rotated, summing to 33 each step. + try frameAfter(gaps_ms[gaps_ms.len - 1]); + for (out) |*slot| { + for (gaps_ms) |g| try frameAfter(g); + slot.* = t_viewport_y - at_release; + } +} + +test "the coast is a function of time, not of how the frames cut it" { + var steady: [15]f32 = undefined; + try coastTrace(&.{33}, &steady); + var jittery: [15]f32 = undefined; + try coastTrace(&.{ 25, 8 }, &jittery); + for (steady, jittery) |a, b| try std.testing.expectApproxEqAbs(a, b, 0.01); +} + test "a fling coasts at the speed of the last 100 ms, not of the last move" { var t = try dvui.testing.init(.{ .window_size = .{ .w = 300, .h = 400 } }); defer t.deinit();