From c822ab787d56195b443816f0bab6bea72b1aae4a Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:23:41 -0500 Subject: [PATCH 1/2] core: a coast ends on a whole pixel, so cards keep their size at rest ScrollArea draws a touch coast unsnapped and snaps again at rest. The frame a coast ends (run out, or stopped by a press) now moves the viewport so the content's origin sits on a whole physical pixel, staying inside the scroll range, so the snapped frames after it draw the same geometry as the last coast frame instead of rounding every pane and card by up to a pixel. Agent-Team: linux Worker: claude Co-Authored-By: Claude Opus 5.5 --- core/widgets/ScrollArea.zig | 120 +++++++++++++++++++++++++++++++++--- 1 file changed, 113 insertions(+), 7 deletions(-) diff --git a/core/widgets/ScrollArea.zig b/core/widgets/ScrollArea.zig index bc3361d3d..9d56b994e 100644 --- a/core/widgets/ScrollArea.zig +++ b/core/widgets/ScrollArea.zig @@ -68,8 +68,11 @@ pub fn init(src: std.builtin.SourceLocation, init_opts: dvui.ScrollAreaWidget.In // A coast's last stretch moves a fraction of a pixel a frame: drawn at whole pixels it // steps instead of gliding. Content is drawn at the fractional offset until the coast - // ends, and `deinit` snaps it again so text is crisp at rest. - if (self.touch.x.coasting or self.touch.y.coasting) _ = dvui.snapToPixelsSet(false); + // ends (`settle` puts it on whole pixels that frame), and `deinit` snaps it again so text + // is crisp at rest. + const was_coasting_x = self.touch.x.coasting; + const was_coasting_y = self.touch.y.coasting; + if (was_coasting_x or was_coasting_y) _ = dvui.snapToPixelsSet(false); // A press while coasting only stops the coast: eat it so it doesn't also tap what is under it. for (dvui.events()) |*e| { @@ -84,10 +87,9 @@ pub fn init(src: std.builtin.SourceLocation, init_opts: dvui.ScrollAreaWidget.In // 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(); - } + if (!self.touch.dragging) self.coast(); + self.settle(was_coasting_x and !self.touch.x.coasting, was_coasting_y and !self.touch.y.coasting); + if (!self.touch.dragging and c.init_opts.frame_viewport == null) c.frame_viewport = self.si.viewport.topLeft(); return self; } @@ -209,6 +211,33 @@ fn coast(self: *ScrollArea) void { if (t.x.coasting or t.y.coasting) dvui.refresh(null, @src(), self.data().id); } +/// On the axes whose coast ended this frame (run out, or stopped by a press), move the content +/// onto whole physical pixels. This last frame is still drawn unsnapped and the next ones +/// snapped; at a fractional offset the snap would round every rect and texture to another +/// pixel, so panes and cards would shift or change size by a pixel the moment the coast stops. +fn settle(self: *ScrollArea, x: bool, y: bool) void { + const rs = self.area.scroll.?.data().contentRectScale(); + if (rs.s == 0) return; + const si = self.si; + self.scrollBy(.{ + .x = if (x) settleBy(rs.r.x, si.viewport.x, si.scrollMax(.horizontal), rs.s) else 0, + .y = if (y) settleBy(rs.r.y, si.viewport.y, si.scrollMax(.vertical), rs.s) else 0, + }); +} + +/// How far to move a viewport at `viewport` (of `0..max`, in an area whose content starts at +/// physical `area_px` with `scale` pixels a point) so the content's origin lands on a pixel. +fn settleBy(area_px: f32, viewport: f32, max: f32, scale: f32) f32 { + // A child at content point p draws at `area_px + (p - viewport) * scale`. Round the + // content's origin, not the viewport: the area itself may sit between pixels. + const origin = area_px - viewport * scale; + var by = (origin - @round(origin)) / scale; + // Past an edge dvui's bounce would pull it back off the pixel, so round the other way. + if (viewport + by < 0) by += 1 / scale; + if (viewport + by > max) by -= 1 / scale; + return by; +} + // Evaluate from the release, rounding the position once rather than accumulating f32 deltas. // Clamp time at the stop speed so the final position also agrees across frame schedules. fn coastPosition(fling: *Fling, origin: f32, velocity: f32, elapsed: f64) f32 { @@ -238,6 +267,11 @@ var t_use_dvui: bool = false; var t_drawn_y: f32 = 0; var t_raw_y: f32 = 0; var t_snapping: bool = false; +/// Lay the area out as a column of `t_rows` rows, `t_top` points below the window's top, and +/// record where each row is drawn. +var t_column: bool = false; +var t_top: f32 = 0; +var t_rows: [400]dvui.Point.Physical = undefined; fn testFrame() !dvui.App.Result { var root = dvui.box(@src(), .{}, .{ .expand = .both }); @@ -248,11 +282,20 @@ fn testFrame() !dvui.App.Result { _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 100, .h = 20000 } }); t_viewport_y = scroll.si.viewport.y; } else { - var scroll = ScrollArea.init(@src(), .{ .horizontal = .none, .vertical_bar = .hide }, .{ .expand = .both }); + var scroll = ScrollArea.init(@src(), .{ .horizontal = .none, .vertical_bar = .hide }, .{ .expand = .both, .margin = .{ .y = t_top } }); // 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(); + if (t_column) { + t_snapping = dvui.snapToPixels(); + for (&t_rows, 0..) |*drawn, i| { + const r = dvui.spacer(@src(), .{ .id_extra = i, .min_size_content = .{ .w = 100, .h = 30 } }).rectScale().r; + // Where the renderer puts a texture or a glyph run: rounded while snapping. + drawn.* = if (t_snapping) .{ .x = @round(r.x), .y = @round(r.y) } else .{ .x = r.x, .y = r.y }; + } + return .ok; + } const child = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 100, .h = 20000 } }); t_snapping = dvui.snapToPixels(); const y = child.rectScale().r.y; @@ -380,6 +423,69 @@ test "a slow coast draws at a new place every frame, then snaps at rest" { try std.testing.expect(t_snapping); } +test "a settled viewport puts the content's origin on a pixel, inside the range" { + const cases = [_]struct { area_px: f32, viewport: f32, max: f32, scale: f32 }{ + .{ .area_px = 0, .viewport = 10.3, .max = 100, .scale = 1 }, + .{ .area_px = 0.5, .viewport = 10, .max = 100, .scale = 1 }, + .{ .area_px = 3.25, .viewport = 41.7, .max = 100, .scale = 2 }, + // The nearest pixel would put these past the top and the bottom. + .{ .area_px = 0.9, .viewport = 0.2, .max = 100, .scale = 1 }, + .{ .area_px = 0, .viewport = 99.7, .max = 99.9, .scale = 1 }, + }; + for (cases) |c| { + const settled = c.viewport + settleBy(c.area_px, c.viewport, c.max, c.scale); + const origin = c.area_px - settled * c.scale; + try std.testing.expectApproxEqAbs(@round(origin), origin, 0.0001); + try std.testing.expect(settled >= 0 and settled <= c.max); + try std.testing.expect(@abs(settled - c.viewport) * c.scale < 1); + } +} + +/// Step frames until the area snaps again and return the largest move of any row between the +/// last unsnapped frame and the first snapped one. +fn restJump(gap_ms: u64) !f32 { + var last_coast: [t_rows.len]dvui.Point.Physical = t_rows; + for (0..600) |_| { + try frameAfter(gap_ms); + if (t_snapping) break; + last_coast = t_rows; + } else return error.NeverSnapped; + var jump: f32 = 0; + for (last_coast, t_rows) |a, b| jump = @max(jump, @abs(a.x - b.x), @abs(a.y - b.y)); + return jump; +} + +test "the end of a coast draws where the rest frames do" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 300, .h = 400 } }); + defer t.deinit(); + t_use_dvui = false; + t_column = true; + defer t_column = false; + // The area sits between pixels, so rounding the viewport alone would not settle it. + t_top = 0.5; + defer t_top = 0; + _ = try swipe(); + const jump = try restJump(8); + std.debug.print("coast end: largest row move into rest={d:.6} px\n", .{jump}); + try std.testing.expectApproxEqAbs(@as(f32, 0), jump, 0.001); +} + +test "a press that stops a coast leaves the rows where they were drawn" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 300, .h = 400 } }); + defer t.deinit(); + t_use_dvui = false; + t_column = true; + defer t_column = false; + t_top = 0.5; + defer t_top = 0; + _ = try swipe(); + for (0..7) |_| try frameAfter(16); + try std.testing.expect(!t_snapping); + _ = try dvui.currentWindow().addEventPointer(.{ .button = .touch0, .action = .press, .xynorm = .{ .x = 0.5, .y = 0.5 } }); + const jump = try restJump(16); + try std.testing.expectApproxEqAbs(@as(f32, 0), jump, 0.001); +} + 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(); From 85487161012ea90f157011f4095fd898755436b8 Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:11:14 -0500 Subject: [PATCH 2/2] core: skip settle when no coast ended; drop a test print Co-Authored-By: Claude Sonnet 5.5 Agent-Team: linux Worker: claude --- core/widgets/ScrollArea.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/widgets/ScrollArea.zig b/core/widgets/ScrollArea.zig index 9d56b994e..f621f8cfd 100644 --- a/core/widgets/ScrollArea.zig +++ b/core/widgets/ScrollArea.zig @@ -216,6 +216,7 @@ fn coast(self: *ScrollArea) void { /// snapped; at a fractional offset the snap would round every rect and texture to another /// pixel, so panes and cards would shift or change size by a pixel the moment the coast stops. fn settle(self: *ScrollArea, x: bool, y: bool) void { + if (!x and !y) return; const rs = self.area.scroll.?.data().contentRectScale(); if (rs.s == 0) return; const si = self.si; @@ -466,7 +467,6 @@ test "the end of a coast draws where the rest frames do" { defer t_top = 0; _ = try swipe(); const jump = try restJump(8); - std.debug.print("coast end: largest row move into rest={d:.6} px\n", .{jump}); try std.testing.expectApproxEqAbs(@as(f32, 0), jump, 0.001); }