summaryrefslogtreecommitdiff
path: root/src/animation.zig
blob: aef6fe6dd7ccb83483007ec09b46d1bea6f4972e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
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);
}