diff --git a/core/Fling.zig b/core/Fling.zig index 2edc7b091..8b0e1ee24 100644 --- a/core/Fling.zig +++ b/core/Fling.zig @@ -56,6 +56,49 @@ pub fn unrubber(overscroll: f32, r: Rubber) f32 { return std.math.copysign(o * r.limit / (r.c * (r.limit - o)), overscroll); } +/// A place of interest in a scroll area, in the area's own units. +pub const Notch = struct { + at: f32, + /// How far from `at` a release may land, or a lift come to rest, and still be drawn in. + capture: f32 = 64, + /// Release speeds at or above this pass the notch (units/s). + pass_speed: f32 = 1500, +}; + +/// The decay a coast needs to end on a notch. +pub const Aim = struct { at: f32, decay: f32 }; + +/// Where a coast released at `origin` with `v` ends: `origin` when it does not start. +pub fn landing(origin: f32, v: f32, t: Tuning) f32 { + if (@abs(v) <= t.stop) return origin; + return origin + (v - std.math.sign(v) * t.stop) / t.decay; +} + +/// The notch a release at `origin` with `v` lands within reach of, and the decay that ends the +/// coast on it exactly, or null for a release that passes: too fast, or no notch ahead within +/// its capture of the landing. The nearest to the landing wins. +pub fn aim(origin: f32, v: f32, t: Tuning, notches: []const Notch) ?Aim { + if (@abs(v) <= t.stop) return null; + const end = landing(origin, v, t); + var best: ?Notch = null; + for (notches) |n| { + if ((n.at - origin) * v <= 0 or @abs(end - n.at) > n.capture or @abs(v) >= n.pass_speed) continue; + if (best == null or @abs(end - n.at) < @abs(end - best.?.at)) best = n; + } + const n = best orelse return null; + return .{ .at = n.at, .decay = (@abs(v) - t.stop) / @abs(n.at - origin) }; +} + +/// The position of the notch within whose capture `pos` is, the nearest to it, or null. +pub fn nearest(pos: f32, notches: []const Notch) ?f32 { + var best: ?f32 = null; + for (notches) |n| { + if (@abs(pos - n.at) > n.capture) continue; + if (best == null or @abs(pos - n.at) < @abs(pos - best.?)) best = n.at; + } + return best; +} + /// True while a release coast is still running. coasting: bool = false, /// Current coast velocity (units/s). @@ -208,3 +251,40 @@ test "the rubber band shows a third of the travel at 120 points and never reache try std.testing.expectApproxEqAbs(x, unrubber(rubber(x, rubber_band), rubber_band), 0.01); } } + +/// Where a coast released at `origin` with `v` stops, from its closed form. +fn coastEnd(origin: f32, v: f32, decay: f32, t: Tuning) f32 { + return origin + v / decay * (1 - t.stop / @abs(v)); +} + +test "landing is where the coast ends" { + const t: Tuning = .{ .decay = 2, .stop = 20 }; + try std.testing.expectEqual(@as(f32, 290), landing(0, 600, t)); + try std.testing.expectEqual(@as(f32, 7), landing(7, 15, t)); + for ([_]f32{ 600, -600, 3000, -3000 }) |v| { + try std.testing.expectApproxEqAbs(landing(0, v, t), coastEnd(0, v, t.decay, t), 1e-3); + } +} + +test "aim lands the coast on the notch" { + const t: Tuning = .{ .decay = 2, .stop = 20 }; + const a = aim(0, 600, t, &.{.{ .at = 300 }}).?; + try std.testing.expectApproxEqAbs(@as(f32, 580.0 / 300.0), a.decay, 1e-6); + try std.testing.expectApproxEqAbs(@as(f32, 300), coastEnd(0, 600, a.decay, t), 1e-3); + const back = aim(0, -600, t, &.{.{ .at = -300 }}).?; + try std.testing.expectApproxEqAbs(@as(f32, -300), coastEnd(0, -600, back.decay, t), 1e-3); +} + +test "aim lets a fast, a far or a behind release go" { + const t: Tuning = .{ .decay = 2, .stop = 20 }; + try std.testing.expectEqual(@as(?Aim, null), aim(0, 2000, t, &.{.{ .at = 1000 }})); + try std.testing.expectEqual(@as(?Aim, null), aim(0, 600, t, &.{.{ .at = 400 }})); + try std.testing.expectEqual(@as(?Aim, null), aim(0, 600, t, &.{.{ .at = -50 }})); +} + +test "the nearest notch to the landing wins" { + const t: Tuning = .{ .decay = 2, .stop = 20 }; + try std.testing.expectEqual(@as(f32, 280), aim(0, 600, t, &.{ .{ .at = 330 }, .{ .at = 280 } }).?.at); + try std.testing.expectEqual(@as(?f32, 280), nearest(290, &.{ .{ .at = 330 }, .{ .at = 280 } })); + try std.testing.expectEqual(@as(?f32, null), nearest(400, &.{.{ .at = 330 }})); +} diff --git a/core/widgets/ScrollArea.zig b/core/widgets/ScrollArea.zig index 42f821948..f3cd23aad 100644 --- a/core/widgets/ScrollArea.zig +++ b/core/widgets/ScrollArea.zig @@ -41,6 +41,8 @@ si: *dvui.ScrollInfo, touch: *Touch, /// dvui's `snap_to_pixels` as the area found it, put back by `deinit`. snap: bool, +/// The places of interest this frame (`notches`). +notch_list: []const Fling.Notch = &.{}, /// Per-area state that outlives a frame: a fling per axis and whether a finger is dragging. const Touch = struct { @@ -52,6 +54,13 @@ const Touch = struct { coast_start_ns: i128 = 0, coast_origin: dvui.Point = .{}, coast_velocity: dvui.Point = .{}, + /// The decay each axis's coast runs on: `tuning.decay`, or the one that ends it on a notch. + coast_decay: dvui.Point = .{ .x = tuning.decay, .y = tuning.decay }, + /// While seating, the notch the content is drawn onto, and the spring that draws it there + /// (`pos.y` is how far the viewport is from `seat_at`). + seat_at: f32 = 0, + seating: bool = false, + seat_spring: Spring = .{}, /// The finger that owns the drag; another finger's release or motion is not this area's. finger: dvui.enums.Button = .none, /// How far the content is drawn past its ends this frame: negative past the start, positive @@ -68,6 +77,10 @@ const Touch = struct { /// release 300 points past an end. pub const spring_back: Spring.Tune = .{ .hz = 5, .playful_damping = 1 }; +/// The seat: a lift near a notch that starts no coast comes to rest on it, critically damped at +/// every motion level, about 500 ms after a lift 30 points off. +pub const seat: Spring.Tune = .{ .hz = 3, .playful_damping = 1 }; + /// Seconds of recent motion the release velocity averages over: native scrollers read the last /// 50 to 100 ms. pub const window_s: f32 = 0.1; @@ -98,7 +111,8 @@ pub fn init(src: std.builtin.SourceLocation, init_opts: dvui.ScrollAreaWidget.In // 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 or self.touch.over.nonZero()) _ = dvui.snapToPixelsSet(false); + const was_seating = self.touch.seating; + if (was_coasting_x or was_coasting_y or was_seating or self.touch.over.nonZero()) _ = dvui.snapToPixelsSet(false); // A press while coasting or springing back only stops the motion: eat it so it doesn't also // tap what is under it. @@ -106,10 +120,11 @@ pub fn init(src: std.builtin.SourceLocation, init_opts: dvui.ScrollAreaWidget.In for (dvui.events()) |*e| { if (!dvui.eventMatch(e, .{ .id = c.data().id, .r = c.data().borderRectScale().r })) continue; if (e.evt != .mouse or e.evt.mouse.action != .press or !e.evt.mouse.button.touch()) continue; - if (!t.x.coasting and !t.y.coasting and !t.springing) continue; + if (!t.x.coasting and !t.y.coasting and !t.springing and !t.seating) continue; e.handle(@src(), c.data()); t.x.cancel(); t.y.cancel(); + t.seating = false; if (t.springing) { // Caught past an end, the content stays where it is and the finger drags it from // there: the press begins the drag its release ends. @@ -131,9 +146,10 @@ pub fn init(src: std.builtin.SourceLocation, init_opts: dvui.ScrollAreaWidget.In if (!t.dragging) { self.coast(); self.springBack(); + self.seatStep(); } // A bounce ends its coast past an edge; only in-range axes settle onto pixels. - self.settle(was_coasting_x and !t.x.coasting and t.over.x == 0, was_coasting_y and !t.y.coasting and t.over.y == 0); + self.settle(was_coasting_x and !t.x.coasting and t.over.x == 0, (was_coasting_y and !t.y.coasting or was_seating and !t.seating) and t.over.y == 0); self.si.viewport.x += t.over.x; self.si.viewport.y += t.over.y; if (c.init_opts.frame_viewport == null) c.frame_viewport = self.si.viewport.topLeft(); @@ -149,6 +165,14 @@ pub fn overscroll(si: dvui.ScrollInfo) dvui.Point { }; } +/// Places a touch scroll comes to rest on, in content points down from the top, for this frame +/// (the slice must outlive `deinit`). A slow flick that would land near one is carried onto it; +/// a lift near one, with no flick, settles onto it; a fast flick passes. Decided at the release +/// alone: a finger on the content, the wheel and `scrollTo` never see them. None by default. +pub fn notches(self: *ScrollArea, n: []const Fling.Notch) void { + self.notch_list = n; +} + pub fn data(self: *const ScrollArea) *dvui.WidgetData { return self.area.data(); } @@ -211,8 +235,10 @@ pub fn deinit(self: *ScrollArea) void { t.springing = true; t.back = .{ .pos = .{ .x = t.over.x, .y = t.over.y }, .placed = true }; } - // The release may contain no motion, so it must schedule the first coast or spring frame itself. - if (t.x.coasting or t.y.coasting or t.springing) dvui.refresh(null, @src(), self.data().id); + t.coast_decay = .{ .x = tuning.decay, .y = tuning.decay }; + if (t.over.y == 0) self.aimOrSeat(); + // The release may contain no motion, so it must schedule the first coast, spring or seat frame itself. + if (t.x.coasting or t.y.coasting or t.springing or t.seating) dvui.refresh(null, @src(), self.data().id); } // dvui's own deinit clamps and bounces a viewport out of range: hand it the edge. @@ -292,6 +318,47 @@ fn drag(pos: *f32, excess: *f32, over: *f32, max: f32, by: f32, may_overscroll: return pos.* - before; } +/// The most notches within the content's range a release looks at. +const max_notches = 32; + +/// At a release inside the range: end a coast on the notch it was headed for, or, when none +/// started, seat the content on the notch it was lifted near. +fn aimOrSeat(self: *ScrollArea) void { + const t = self.touch; + const y = self.si.viewport.y; + const max = self.si.scrollMax(.vertical); + var buf: [max_notches]Fling.Notch = undefined; + var n: usize = 0; + for (self.notch_list) |notch| { + if (notch.at < 0 or notch.at > max or n == buf.len) continue; + buf[n] = notch; + n += 1; + } + if (t.y.coasting) { + if (Fling.aim(y, t.y.vel, tuning, buf[0..n])) |a| t.coast_decay.y = a.decay; + } else if (Fling.nearest(y, buf[0..n])) |at| { + // A lift with no coast is slow or paused: the seat starts from rest. + t.seating = true; + t.seat_at = at; + t.seat_spring = .{ .pos = .{ .y = y - at }, .placed = true }; + } +} + +/// Carry a seating viewport onto its notch, on the frame clock. +fn seatStep(self: *ScrollArea) void { + const t = self.touch; + if (!t.seating) return; + const max_step_s = @as(f32, @floatFromInt(FrameClock.max_step_ns)) / std.time.ns_per_s; + const moving = t.seat_spring.step(.{}, @min(dvui.secondsSinceLastFrame(), max_step_s), seat); + const to = if (moving) t.seat_at + t.seat_spring.pos.y else t.seat_at; + self.scrollBy(.{ .y = to - self.si.viewport.y }); + if (moving) { + dvui.refresh(null, @src(), self.data().id); + } else { + t.seating = false; + } +} + /// Advance the coast one frame (`processVelocityAfter`). Past an end the coast ends, and how far /// it went past and its speed there start the spring back: out once, and home. fn coast(self: *ScrollArea) void { @@ -299,8 +366,8 @@ fn coast(self: *ScrollArea) void { const si = self.si; var moved: dvui.Point = .{}; const elapsed = @as(f64, @floatFromInt(dvui.frameTimeNS() - t.coast_start_ns)) / std.time.ns_per_s; - if (t.x.coasting) moved.x = coastPosition(&t.x, t.coast_origin.x, t.coast_velocity.x, elapsed) - si.viewport.x; - if (t.y.coasting) moved.y = coastPosition(&t.y, t.coast_origin.y, t.coast_velocity.y, elapsed) - si.viewport.y; + if (t.x.coasting) moved.x = coastPosition(&t.x, t.coast_origin.x, t.coast_velocity.x, t.coast_decay.x, elapsed) - si.viewport.x; + if (t.y.coasting) moved.y = coastPosition(&t.y, t.coast_origin.y, t.coast_velocity.y, t.coast_decay.y, elapsed) - si.viewport.y; if (t.last_coast.x != 0) profile.scrollJump(moved.x, @abs(t.last_coast.x) * 3); if (t.last_coast.y != 0) profile.scrollJump(moved.y, @abs(t.last_coast.y) * 3); @@ -389,8 +456,8 @@ fn settleBy(area_px: f32, viewport: f32, max: f32, scale: f32) f32 { // 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 { - const decay: f64 = tuning.decay; +fn coastPosition(fling: *Fling, origin: f32, velocity: f32, coast_decay: f32, elapsed: f64) f32 { + const decay: f64 = coast_decay; const duration = @log(@as(f64, @abs(velocity)) / tuning.stop) / decay; const time = std.math.clamp(elapsed, 0, duration); const keep = @exp(-decay * time); @@ -424,6 +491,9 @@ var t_over_y: f32 = 0; var t_column: bool = false; var t_top: f32 = 0; var t_rows: [400]dvui.Point.Physical = undefined; +/// The notches the test area sets, and the highest viewport it reached while `restAfter` ran. +var t_notches: []const Fling.Notch = &.{}; +var t_peak: f32 = 0; fn testFrame() !dvui.App.Result { var root = dvui.box(@src(), .{}, .{ .expand = .both }); @@ -435,6 +505,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, .margin = .{ .y = t_top } }); + scroll.notches(t_notches); // Read the final viewport after the area's event handling and edge correction. const si = scroll.si; defer t_viewport_y = si.viewport.y; @@ -472,6 +543,11 @@ fn frameAfter(ms: u64) !void { /// A flick up the screen at 10 points a frame (about 600 points/s), whose last move is one /// point and whose release comes a frame later. Returns the viewport's position at the release. fn swipe() !f32 { + return swipeAt(10, 10); +} + +/// A flick of `frames` moves of `step` points a frame, then as `swipe` ends. +fn swipeAt(step: f32, frames: usize) !f32 { const cw = dvui.currentWindow(); try dvui.testing.settle(testFrame); const x = 0.5; @@ -479,9 +555,9 @@ fn swipe() !f32 { const h = cw.data().rect.h; _ = try cw.addEventPointer(.{ .button = .touch0, .action = .press, .xynorm = .{ .x = x, .y = y } }); try frameAfter(16); - for (0..10) |_| { - _ = try cw.addEventTouchMotion(.touch0, x, y - 10 / h, 0, -10 / h); - y -= 10 / h; + for (0..frames) |_| { + _ = try cw.addEventTouchMotion(.touch0, x, y - step / h, 0, -step / h); + y -= step / h; try frameAfter(16); } _ = try cw.addEventTouchMotion(.touch0, x, y - 1 / h, 0, -1 / h); @@ -578,13 +654,13 @@ test "coast position matches the release integral through jitter and a long stal for ([_]f64{ 0.008, 0.025, 0.008, 0.025, 0.5, 0.008, 0.025 }) |gap| { elapsed += gap; const expected = origin + velocity / 2.0 * (1 - @exp(-2.0 * elapsed)); - try std.testing.expectApproxEqAbs(@as(f32, @floatCast(expected)), coastPosition(&fling, origin, velocity, elapsed), 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, @floatCast(expected)), coastPosition(&fling, origin, velocity, tuning.decay, elapsed), 0.0001); try std.testing.expect(fling.coasting); } - const stopped = coastPosition(&fling, origin, velocity, 10); + const stopped = coastPosition(&fling, origin, velocity, tuning.decay, 10); try std.testing.expectApproxEqAbs(origin + (velocity + tuning.stop) / tuning.decay, stopped, 0.0001); try std.testing.expect(!fling.coasting); - try std.testing.expectEqual(stopped, coastPosition(&fling, origin, velocity, 20)); + try std.testing.expectEqual(stopped, coastPosition(&fling, origin, velocity, tuning.decay, 20)); } test "a slow coast draws at a new place every frame, then snaps at rest" { @@ -976,3 +1052,150 @@ test "steady event times release at steady speed across bursts of 3, 0, 1" { try frameAfter(16); try std.testing.expectApproxEqAbs(@as(f32, 1000), t_release_velocity, 0.1); } + +/// Where a flick of `frames` moves of `step` points a frame comes to rest with `n` set. +fn restAfter(n: []const Fling.Notch, step: f32, frames: usize) !f32 { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 300, .h = 400 } }); + defer t.deinit(); + t_use_dvui = false; + t_notches = n; + defer t_notches = &.{}; + t_peak = 0; + _ = try swipeAt(step, frames); + for (0..120) |_| { + try frameAfter(16); + t_peak = @max(t_peak, t_viewport_y); + } + return t_viewport_y; +} + +test "a slow flick comes to rest on a notch" { + const at = 400; + try std.testing.expectApproxEqAbs(@as(f32, at), try restAfter(&.{.{ .at = at, .capture = 1000 }}, 10, 10), 0.5); + try std.testing.expect(t_peak <= at + 0.01); +} + +test "a fast flick passes a notch" { + const free = try restAfter(&.{}, 48, 6); + const passed = try restAfter(&.{.{ .at = free + 20 }}, 48, 6); + try std.testing.expectApproxEqAbs(free, passed, 0.01); +} + +test "an aimed coast ends where the landing says" { + var fling: Fling = .{ .coasting = true }; + const a = Fling.aim(0, 600, tuning, &.{.{ .at = 300 }}).?; + try std.testing.expectApproxEqAbs(@as(f32, 300), coastPosition(&fling, 0, 600, a.decay, 60), 1e-3); + try std.testing.expectApproxEqAbs(Fling.landing(0, 600, tuning), coastPosition(&fling, 0, 600, tuning.decay, 60), 1e-3); +} + +test "an aimed coast is a function of time" { + t_notches = &.{.{ .at = 400, .capture = 1000 }}; + defer t_notches = &.{}; + var steady: [150]f32 = undefined; + try coastTrace(&.{ 16, 17 }, &steady); + var jittery: [150]f32 = undefined; + try coastTrace(&.{ 25, 8 }, &jittery); + for (steady, jittery) |a, b| try std.testing.expectApproxEqAbs(a, b, 0.01); +} + +/// Press, drag the content `points` up in 10-point steps, and hold still `hold` frames. +fn dragIn(points: f32, hold: usize) !void { + try dvui.testing.settle(testFrame); + try dragBy(-points, 10, true, hold); +} + +test "a finger on a notch is never moved" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 300, .h = 400 } }); + defer t.deinit(); + t_use_dvui = false; + t_notches = &.{.{ .at = 100 }}; + defer t_notches = &.{}; + try dragIn(100, 0); + const held = t_viewport_y; + for (0..10) |_| try frameAfter(16); + try std.testing.expectEqual(held, t_viewport_y); +} + +test "a slow lift inside a window seats, and only ever moves toward the notch" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 300, .h = 400 } }); + defer t.deinit(); + t_use_dvui = false; + t_notches = &.{.{ .at = 70 }}; + defer t_notches = &.{}; + try dragIn(100, 6); + try lift(); + var prev = t_viewport_y; + for (0..44) |_| { + try frameAfter(16); + try std.testing.expect(t_viewport_y <= prev); + prev = t_viewport_y; + } + try std.testing.expectApproxEqAbs(@as(f32, 70), t_viewport_y, 0.5); +} + +test "a slow lift outside every window stays" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 300, .h = 400 } }); + defer t.deinit(); + t_use_dvui = false; + t_notches = &.{.{ .at = 20 }}; + defer t_notches = &.{}; + try dragIn(100, 6); + try lift(); + for (0..44) |_| try frameAfter(16); + try std.testing.expectApproxEqAbs(@as(f32, 100), t_viewport_y, 0.01); +} + +test "the wheel never notches" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 300, .h = 400 } }); + defer t.deinit(); + t_use_dvui = false; + try dvui.testing.settle(testFrame); + const cw = dvui.currentWindow(); + _ = try cw.addEventMouseMotion(.{ .pt = .{ .x = 150, .y = 200 } }); + for (0..3) |_| { + _ = try cw.addEventMouseWheel(-10, .vertical, null); + try frameAfter(16); + } + for (0..60) |_| try frameAfter(16); + const at = t_viewport_y; + try std.testing.expect(at > 0); + t_notches = &.{.{ .at = at + 10 }}; + defer t_notches = &.{}; + for (0..30) |_| try frameAfter(16); + try std.testing.expectEqual(at, t_viewport_y); +} + +test "a press stops an aimed coast where it is 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; + t_notches = &.{.{ .at = 400, .capture = 1000 }}; + defer t_notches = &.{}; + _ = 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 } }); + try std.testing.expectApproxEqAbs(@as(f32, 0), try restJump(16), 0.001); +} + +test "a press stops a seat where it is 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; + t_notches = &.{.{ .at = 70 }}; + defer t_notches = &.{}; + try dragIn(100, 6); + try lift(); + for (0..3) |_| try frameAfter(16); + try std.testing.expect(!t_snapping); + _ = try dvui.currentWindow().addEventPointer(.{ .button = .touch0, .action = .press, .xynorm = .{ .x = 0.5, .y = 0.5 } }); + try std.testing.expectApproxEqAbs(@as(f32, 0), try restJump(16), 0.001); +}