diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-29 07:41:35 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:16 -0300 |
| commit | 411d0b6059192b75853e220e1242b694ae461a3e (patch) | |
| tree | 50f8bb207f3cdda497bdd84658a2d3ea74b4bc2b /src/animation.zig | |
| parent | da920ca49e3f5d1649e811bbdc3ddba482c6aae4 (diff) | |
| parent | 5526c68beec454cfe607a579723b97b4848e7d3a (diff) | |
| download | pardes-411d0b6059192b75853e220e1242b694ae461a3e.tar.gz pardes-411d0b6059192b75853e220e1242b694ae461a3e.zip | |
Merge: 9P rounds 9-17 + render DPI, dark tints, G4-G10
Diffstat (limited to 'src/animation.zig')
| -rw-r--r-- | src/animation.zig | 70 |
1 files changed, 65 insertions, 5 deletions
diff --git a/src/animation.zig b/src/animation.zig index 9002a2c6..713a8a30 100644 --- a/src/animation.zig +++ b/src/animation.zig @@ -136,6 +136,10 @@ pub const Track = extern struct { frame_count: u16 = 0, from: Box = .{}, to: Box = .{}, + /// The move's speed as it starts, in its lengths per unit of progress: + /// 0 sets off as the flavour does; a move retargeted mid-way carries + /// the old one's speed on (Presentation.sync), so it never stops dead. + launch: f32 = 0, pub fn active(track: Track) bool { return track.effect != .off and track.frame < track.frames(); @@ -151,7 +155,7 @@ pub const Track = extern struct { if (track.phase == .closing and track.effect == .vertical) return progressEased(.in_cubic, track.frames(), track.frame); if (track.flavour()) |motion| - return motion.ease(track.progress()); + return motion.easeFrom(track.progress(), track.launch); return progressEased(track.effect.easing(), track.frames(), track.frame); } @@ -163,6 +167,16 @@ pub const Track = extern struct { return Motion.of(which); } + /// How fast its presented box moves now, in cells per frame of its + /// own (x, y, w, h); zero when it follows no flavour or has landed. + pub fn velocity(track: Track) Box { + const motion = track.flavour() orelse return .{}; + const u = track.progress(); + if (u >= 1 or track.frames() <= 1) return .{}; + const rate = motion.speedFrom(u, track.launch) / @as(f32, @floatFromInt(track.frames() - 1)); + return .{ .x = (track.to.x - track.from.x) * rate, .y = (track.to.y - track.from.y) * rate, .w = (track.to.w - track.from.w) * rate, .h = (track.to.h - track.from.h) * rate }; + } + fn progress(track: Track) f32 { if (track.frames() <= 1 or track.frame >= track.frames() - 1) return 1; return @as(f32, @floatFromInt(track.frame)) / @as(f32, @floatFromInt(track.frames() - 1)); @@ -180,7 +194,7 @@ pub const Track = extern struct { // box, so a click lands on the glyph it is drawn as). const motion = track.flavour() orelse return box; if (motion.stretch == 0) return box; - const factor = 1 + motion.stretch * std.math.clamp(motion.speed(track.progress()) / 4, -1, 1); + const factor = 1 + motion.stretch * std.math.clamp(motion.speedFrom(track.progress(), track.launch) / 4, -1, 1); if (@abs(track.to.x - track.from.x) >= @abs(track.to.y - track.from.y)) { const w = box.w * factor; box.x -= (w - box.w) / 2; @@ -685,16 +699,27 @@ pub const Motion = struct { /// flavour's spring (wind-up, overshoot and all), landing exactly on 1 /// at its end. `instant` is there at once. pub fn ease(motion: Motion, u: f32) f32 { + return motion.easeFrom(u, 0); + } + + /// `ease` for a move that starts already moving at `launch` lengths + /// per unit of `u` (a retarget): no wind-up then, the speed carried on. + pub fn easeFrom(motion: Motion, u: f32, launch: f32) f32 { if (motion.instant or u >= 1) return 1; if (u <= 0) return 0; - const spring: Spring = .{ .omega = motion.norm, .zeta = motion.zeta, .target = 1, .from = 0, .velocity = (motion.launch - 2 * motion.anticipate) * motion.norm, .settled = false }; + const kick = if (launch != 0) launch else (motion.launch - 2 * motion.anticipate) * motion.norm; + const spring: Spring = .{ .omega = motion.norm, .zeta = motion.zeta, .target = 1, .from = 0, .velocity = kick, .settled = false }; return spring.value(@intFromFloat(u * std.time.ns_per_s)); } /// The same move's speed at `u`, in lengths per unit of `u`. pub fn speed(motion: Motion, u: f32) f32 { + return motion.speedFrom(u, 0); + } + + pub fn speedFrom(motion: Motion, u: f32, launch: f32) f32 { const h: f32 = 1.0 / 256.0; - return (motion.ease(@min(1, u + h)) - motion.ease(@max(0, u - h))) / (@min(1, u + h) - @max(0, u - h)); + return (motion.easeFrom(@min(1, u + h), launch) - motion.easeFrom(@max(0, u - h), launch)) / (@min(1, u + h) - @max(0, u - h)); } }; @@ -744,6 +769,14 @@ pub const Spring = struct { return spring.at(now_ns).value; } + /// Moves what it shows by `by` at `now_ns`, its speed kept, toward the + /// same target (a scroll's picture falling behind the text it follows), + /// at `motion`'s pace, never past its mark. + pub fn carry(spring: *Spring, by: f32, now_ns: u64, motion: Motion) void { + const state = spring.at(now_ns); + spring.* = .{ .omega = motion.omega, .zeta = 1, .target = spring.target, .from = state.value + by, .velocity = state.velocity, .from_ns = now_ns, .settled = false }; + } + /// Heads for `target` from wherever it is at `now_ns`, moving as it was, /// at the pace and damping `motion` gives: at once when it is `off`. pub fn retarget(spring: *Spring, target: f32, now_ns: u64, motion: Motion) void { @@ -769,6 +802,24 @@ pub const Spring = struct { } }; +test "a carried spring keeps its speed, heads back to its mark and never passes it" { + const ms = std.time.ns_per_ms; + var spring: Spring = .{}; + const smooth = Motion.of(.smooth); + spring.carry(-3, 0, smooth); + try std.testing.expectEqual(@as(f32, -3), spring.value(0)); + const mid = spring.at(40 * ms); + try std.testing.expect(mid.value > -3 and mid.value < 0 and mid.velocity > 0); + // Another notch mid-way: one more row behind, still moving back. + spring.carry(-1, 40 * ms, smooth); + const after = spring.at(40 * ms); + try std.testing.expectApproxEqAbs(mid.value - 1, after.value, 1e-5); + try std.testing.expectApproxEqAbs(mid.velocity, after.velocity, 1e-4); + var t: u64 = 40 * ms; + while (spring.step(t)) : (t += frame_ns) try std.testing.expect(spring.value(t) <= 1e-4); + try std.testing.expect(t < 400 * ms); +} + test "a spring settles without overshoot and keeps its velocity when retargeted" { var spring: Spring = .{}; const ms = std.time.ns_per_ms; @@ -1081,6 +1132,9 @@ pub fn Fade(comptime Value: type) type { to: Value, displayed: Value, step: u16 = transition_steps, + /// Set off from partway through another fade: it eases out (moving + /// at once) instead of in and out, so it never stalls (§7.2). + retargeted: bool = false, pub fn init(value: Value) Self { return .{ .from = value, .to = value, .displayed = value }; @@ -1091,19 +1145,25 @@ pub fn Fade(comptime Value: type) type { } pub fn retarget(a: *Self, target: Value) void { + a.retargeted = a.isActive(); a.from = a.displayed; a.to = target; a.step = if (std.meta.eql(a.from, target)) transition_steps else 0; if (a.step == transition_steps) a.displayed = target; } + /// A state change eases in and out (§8.1); a retargeted one out. + const resolution: u16 = 1000; + pub fn advance(a: *Self) void { if (!a.isActive()) return; a.step += 1; + const t = @as(f32, @floatFromInt(a.step)) / @as(f32, @floatFromInt(transition_steps)); + const eased = sample(if (a.retargeted) .out_cubic else .smooth, t); a.displayed = if (a.step == transition_steps) a.to else - Value.interpolate(a.from, a.to, a.step, transition_steps); + Value.interpolate(a.from, a.to, @intFromFloat(@round(eased * resolution)), resolution); } pub fn snap(a: *Self, value: Value) void { |
