summaryrefslogtreecommitdiff
path: root/src/animation.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 07:41:35 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commit411d0b6059192b75853e220e1242b694ae461a3e (patch)
tree50f8bb207f3cdda497bdd84658a2d3ea74b4bc2b /src/animation.zig
parentda920ca49e3f5d1649e811bbdc3ddba482c6aae4 (diff)
parent5526c68beec454cfe607a579723b97b4848e7d3a (diff)
downloadpardes-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.zig70
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 {