diff options
Diffstat (limited to 'src/animation.zig')
| -rw-r--r-- | src/animation.zig | 189 |
1 files changed, 0 insertions, 189 deletions
diff --git a/src/animation.zig b/src/animation.zig deleted file mode 100644 index c22396b8..00000000 --- a/src/animation.zig +++ /dev/null @@ -1,189 +0,0 @@ -//! 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); - } - }; -} - -/// `Transition`'s interface with the animation taken OUT: a value that is only ever the one it was -/// last set to. -/// -/// This exists so that a build which never fades does not carry the machinery for fading. A runtime -/// flag around the same `Transition` cannot achieve that - the endpoints stay in the struct and -/// `Value.interpolate` stays in the binary, reachable and therefore emitted. Selecting a different -/// type at comptime is what makes the interpolator genuinely unreachable, and on a target whose whole -/// display is a 115200-baud serial line, absent code and unspent frames are the same saving twice. -/// -/// Every method here is the trivial one, and `retarget` is deliberately `snap` rather than an error: -/// callers ask for a new palette and get it, on the next frame, in one step. Nothing about the -/// interface says how many frames the arrival takes. -pub fn Immediate(comptime Value: type) type { - return struct { - const Self = @This(); - - displayed: Value, - - pub fn init(value: Value) Self { - return .{ .displayed = value }; - } - - pub fn isActive(_: *const Self) bool { - return false; - } - - pub fn retarget(a: *Self, target: Value) void { - a.displayed = target; - } - - pub fn advance(_: *Self) void {} - - pub fn snap(a: *Self, value: Value) void { - a.displayed = 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) }; - } -}; - -// The substitute has to be interchangeable, and the property that matters is the one a caller could -// otherwise get wrong: it must arrive at the SAME palette a completed fade arrives at. A fade whose -// endpoint differed by a rounding step would make the build option a visible change of colors rather -// than a change of how long they take. -test "Immediate lands where a completed Transition lands" { - const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; - const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; - - var faded = Transition(TestColor).init(from); - faded.retarget(to); - for (0..transition_steps) |_| faded.advance(); - - var instant = Immediate(TestColor).init(from); - try std.testing.expect(!instant.isActive()); - instant.retarget(to); - try std.testing.expectEqual(faded.displayed, instant.displayed); - - // Never active, so a frontend that renders only while something is animating stops immediately - // rather than spending ten frames discovering there is nothing to draw. - try std.testing.expect(!instant.isActive()); - instant.advance(); - try std.testing.expectEqual(to, instant.displayed); - - instant.snap(from); - try std.testing.expectEqual(from, instant.displayed); -} - -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); -} |
