diff options
Diffstat (limited to 'src/animation.zig')
| -rw-r--r-- | src/animation.zig | 124 |
1 files changed, 124 insertions, 0 deletions
diff --git a/src/animation.zig b/src/animation.zig new file mode 100644 index 00000000..aef6fe6d --- /dev/null +++ b/src/animation.zig @@ -0,0 +1,124 @@ +//! Small, backend-neutral fixed-step animations. +//! +//! A transition always interpolates from its saved endpoints. It never folds +//! the rounded value from one frame into the next, so channels are monotonic, +//! completion is exact, and a different backend cadence cannot accumulate a +//! different rounding error. Values opt in by providing +//! `interpolate(from, to, step, steps)`. +const std = @import("std"); + +/// Frontends aim for one animation step per display frame. Ten 16 ms steps is +/// deliberately short: enough to make a palette change legible without +/// turning theme browsing into something the user has to wait through. +pub const frame_ms: u32 = 16; +pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms; +pub const transition_steps: u16 = 10; + +pub fn Transition(comptime Value: type) type { + return struct { + const Self = @This(); + + from: Value, + to: Value, + displayed: Value, + step: u16 = transition_steps, + + pub fn init(value: Value) Self { + return .{ .from = value, .to = value, .displayed = value }; + } + + pub fn isActive(a: *const Self) bool { + return a.step < transition_steps; + } + + /// Begin again from the value on screen, not the old target. This is + /// what makes a mid-flight retarget continuous. + pub fn retarget(a: *Self, target: Value) void { + 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; + } + + pub fn advance(a: *Self) void { + if (!a.isActive()) return; + a.step += 1; + // Assign the endpoint directly. Besides documenting the contract, + // this keeps exact completion independent of an interpolator's + // internal rounding choices. + a.displayed = if (a.step == transition_steps) + a.to + else + Value.interpolate(a.from, a.to, a.step, transition_steps); + } + + /// Initialization and dump restore use snap: their first frame is the + /// selected theme, never an animation from a compiled-in default. + pub fn snap(a: *Self, value: Value) void { + a.* = init(value); + } + }; +} + +/// Linear RGB interpolation with nearest-integer rounding. The weighted-sum +/// form stays unsigned for both rising and falling channels. +pub fn interpolateRgb(from: [3]u8, to: [3]u8, step: u16, steps: u16) [3]u8 { + if (step == 0) return from; + if (step >= steps) return to; + var out: [3]u8 = undefined; + for (&out, from, to) |*dst, a, b| { + const numerator = @as(u32, a) * (steps - step) + @as(u32, b) * step; + dst.* = @intCast((numerator + steps / 2) / steps); + } + return out; +} + +const TestColor = struct { + rgb: [3]u8, + + pub fn interpolate(from: TestColor, to: TestColor, step: u16, steps: u16) TestColor { + return .{ .rgb = interpolateRgb(from.rgb, to.rgb, step, steps) }; + } +}; + +test "fixed-step interpolation has exact monotonic endpoints" { + const Tween = Transition(TestColor); + const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; + const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; + var tween = Tween.init(from); + tween.retarget(to); + try std.testing.expectEqual(from, tween.displayed); + + var previous = tween.displayed; + for (0..transition_steps) |_| { + tween.advance(); + try std.testing.expect(tween.displayed.rgb[0] <= previous.rgb[0]); + try std.testing.expect(tween.displayed.rgb[1] >= previous.rgb[1]); + try std.testing.expectEqual(@as(u8, 90), tween.displayed.rgb[2]); + previous = tween.displayed; + } + try std.testing.expect(!tween.isActive()); + try std.testing.expectEqual(to, tween.displayed); + tween.advance(); + try std.testing.expectEqual(to, tween.displayed); +} + +test "retarget starts at the currently displayed value" { + const Tween = Transition(TestColor); + const first: TestColor = .{ .rgb = .{ 0, 40, 200 } }; + const second: TestColor = .{ .rgb = .{ 200, 140, 0 } }; + const third: TestColor = .{ .rgb = .{ 20, 10, 250 } }; + var tween = Tween.init(first); + tween.retarget(second); + tween.advance(); + tween.advance(); + tween.advance(); + const on_screen = tween.displayed; + + tween.retarget(third); + try std.testing.expectEqual(on_screen, tween.from); + try std.testing.expectEqual(on_screen, tween.displayed); + try std.testing.expect(tween.isActive()); + for (0..transition_steps) |_| tween.advance(); + try std.testing.expectEqual(third, tween.displayed); +} |
