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();