From 79a595eb620c84a28fa2f4b6e5acff8fee2cf856 Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:34:45 -0500 Subject: [PATCH 1/3] scroll: coast draws at the frame's own time The coast stepped after the children were laid out, so each frame drew where the clock was one gap ago and uneven gaps showed as uneven steps. It now steps in init, before layout. Agent-Team: linux Worker: claude Co-Authored-By: Claude Sonnet 5.5 --- core/widgets/ScrollArea.zig | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/core/widgets/ScrollArea.zig b/core/widgets/ScrollArea.zig index 6bd078b5a..63ece981d 100644 --- a/core/widgets/ScrollArea.zig +++ b/core/widgets/ScrollArea.zig @@ -56,7 +56,7 @@ pub const tuning: Fling.Tuning = .{ pub fn init(src: std.builtin.SourceLocation, init_opts: dvui.ScrollAreaWidget.InitOpts, opts: dvui.Options) ScrollArea { const area = dvui.scrollArea(src, init_opts, opts); const c = &area.scroll.?; - const self: ScrollArea = .{ + var self: ScrollArea = .{ .area = area, .si = area.si, .touch = dvui.dataGetPtrDefault(null, c.data().id, "_touch", Touch, .{}), @@ -77,6 +77,14 @@ pub fn init(src: std.builtin.SourceLocation, init_opts: dvui.ScrollAreaWidget.In self.touch.x.cancel(); self.touch.y.cancel(); } + + // The coast moves the content before it is laid out, so a frame draws where the clock is at + // that frame. Moved after the children, it would draw where the clock was one gap ago, and + // an uneven gap would show as an uneven step. + if (!self.touch.dragging) { + self.coast(); + if (c.init_opts.frame_viewport == null) c.frame_viewport = self.si.viewport.topLeft(); + } return self; } @@ -127,8 +135,6 @@ pub fn deinit(self: *ScrollArea) void { _ = t.y.releaseWindowed(tuning, window_s); } - if (!t.dragging) self.coast(); - // A nested child's propagated motion sets this (parent) area's velocity through dvui's // `processMotionScroll`; clear it so dvui's coast never runs beside ours. self.si.velocity = .{}; @@ -212,6 +218,7 @@ var t_use_dvui: bool = false; /// Where a child of the area is drawn this frame (physical y, as the renderer would place it), /// and whether the renderer snaps there. var t_drawn_y: f32 = 0; +var t_raw_y: f32 = 0; var t_snapping: bool = false; fn testFrame() !dvui.App.Result { @@ -231,6 +238,7 @@ fn testFrame() !dvui.App.Result { const child = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 100, .h = 20000 } }); t_snapping = dvui.snapToPixels(); const y = child.rectScale().r.y; + t_raw_y = y; t_drawn_y = if (t_snapping) @round(y) else y; } return .ok; @@ -273,19 +281,20 @@ 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 +/// How far the content has been drawn to move 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 swipe(); + const at_release = t_raw_y; + // A frame draws where the clock is at its start, 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; + slot.* = at_release - t_raw_y; } } From 3314dc7826b043b9ef96048a971a5f97cdabdefa Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:45:49 -0500 Subject: [PATCH 2/3] scroll: coast-frame-pacing (codex) Agent-Team: linux Worker: codex --- core/widgets/ScrollArea.zig | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/core/widgets/ScrollArea.zig b/core/widgets/ScrollArea.zig index 63ece981d..733ac631e 100644 --- a/core/widgets/ScrollArea.zig +++ b/core/widgets/ScrollArea.zig @@ -231,7 +231,7 @@ 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. + // Read the final viewport after the area's event handling and edge correction. const si = scroll.si; defer t_viewport_y = si.viewport.y; defer scroll.deinit(); @@ -306,6 +306,33 @@ test "the coast is a function of time, not of how the frames cut it" { for (steady, jittery) |a, b| try std.testing.expectApproxEqAbs(a, b, 0.01); } +test "coast displacement follows each frame gap" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 300, .h = 400 } }); + defer t.deinit(); + t_use_dvui = false; + _ = try swipe(); + var previous_time = dvui.currentWindow().frame_time_ns; + try frameAfter(25); + var previous_y = t_raw_y; + var previous_speed: f32 = std.math.inf(f32); + var follows_gaps = true; + // frameAfter draws at the current timestamp, then begins the next frame with this gap. + // Alternating long and short gaps exposes a displacement applied one frame late. + for ([_]u64{ 8, 25, 8, 25, 8, 25, 8, 25 }) |next_gap| { + const time = dvui.currentWindow().frame_time_ns; + const gap_ms: f32 = @as(f32, @floatFromInt(time - previous_time)) / std.time.ns_per_ms; + try frameAfter(next_gap); + const displacement = previous_y - t_raw_y; + const speed = displacement / gap_ms; + std.debug.print("coast gap_ms={d:.0} displacement_px={d:.4} px_per_ms={d:.4}\n", .{ gap_ms, displacement, speed }); + follows_gaps = follows_gaps and displacement > 0 and speed < previous_speed; + previous_time = time; + previous_y = t_raw_y; + previous_speed = speed; + } + try std.testing.expect(follows_gaps); +} + test "a slow coast draws at a new place every frame, then snaps at rest" { var t = try dvui.testing.init(.{ .window_size = .{ .w = 300, .h = 400 } }); defer t.deinit(); From 650c9b18d46e6a7d57993758b5a335b1aa67dd89 Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:01:05 -0500 Subject: [PATCH 3/3] scroll: coast-frame-pacing (codex) Agent-Team: linux Worker: codex --- core/widgets/ScrollArea.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/core/widgets/ScrollArea.zig b/core/widgets/ScrollArea.zig index 733ac631e..bc0e91e4c 100644 --- a/core/widgets/ScrollArea.zig +++ b/core/widgets/ScrollArea.zig @@ -324,7 +324,6 @@ test "coast displacement follows each frame gap" { try frameAfter(next_gap); const displacement = previous_y - t_raw_y; const speed = displacement / gap_ms; - std.debug.print("coast gap_ms={d:.0} displacement_px={d:.4} px_per_ms={d:.4}\n", .{ gap_ms, displacement, speed }); follows_gaps = follows_gaps and displacement > 0 and speed < previous_speed; previous_time = time; previous_y = t_raw_y;