Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 113 additions & 7 deletions core/widgets/ScrollArea.zig
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,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| {
Expand All @@ -88,10 +91,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;
}

Expand Down Expand Up @@ -230,6 +232,34 @@ 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 {
if (!x and !y) return;
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 {
Expand Down Expand Up @@ -260,6 +290,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 });
Expand All @@ -270,14 +305,23 @@ 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();
t_release_velocity = scroll.touch.y.last_debug.vel;
}
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;
Expand Down Expand Up @@ -439,6 +483,68 @@ 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);
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();
Expand Down
Loading