zcatui/src/animation.zig
reugenio 7c6515765e Add animation system with easing functions and timers
New features:
- Easing functions: linear, ease-in/out, cubic, expo, bounce, elastic, back
- Animation struct for tweening values over time
- AnimationGroup for parallel/sequential animations
- Timer for frame-based updates (one-shot and repeating)
- Helper functions: lerp, inverseLerp, mapRange

Animation features:
- Configurable repeat count (-1 for infinite)
- Ping-pong mode (reverse on repeat)
- Delay before start
- Pause/resume control
- getValue as f64, i64, or u16

Example: animation_demo.zig comparing 5 easing functions

Tests: 12 animation tests, all pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 13:40:07 +01:00

601 lines
18 KiB
Zig

//! Animation system for zcatui.
//!
//! Provides tools for creating smooth animations and transitions:
//! - Easing functions (linear, ease-in, ease-out, etc.)
//! - Tween animations between values
//! - Animation sequences and timelines
//! - Frame-based animation state management
//!
//! ## Example
//!
//! ```zig
//! var anim = Animation.init(0, 100, 1000); // 0 to 100 over 1000ms
//! anim.easing = Easing.easeInOut;
//!
//! while (!anim.isComplete()) {
//! const value = anim.getValue();
//! // Use value for rendering
//! anim.advance(delta_ms);
//! }
//! ```
const std = @import("std");
// ============================================================================
// Easing Functions
// ============================================================================
/// Collection of easing functions for smooth animations.
///
/// All functions take a progress value t in [0, 1] and return
/// the eased value in [0, 1].
pub const Easing = struct {
/// Linear interpolation (no easing).
pub fn linear(t: f64) f64 {
return t;
}
/// Quadratic ease-in (starts slow, accelerates).
pub fn easeIn(t: f64) f64 {
return t * t;
}
/// Quadratic ease-out (starts fast, decelerates).
pub fn easeOut(t: f64) f64 {
return 1.0 - (1.0 - t) * (1.0 - t);
}
/// Quadratic ease-in-out (smooth start and end).
pub fn easeInOut(t: f64) f64 {
if (t < 0.5) {
return 2.0 * t * t;
} else {
return 1.0 - std.math.pow(f64, -2.0 * t + 2.0, 2) / 2.0;
}
}
/// Cubic ease-in.
pub fn easeInCubic(t: f64) f64 {
return t * t * t;
}
/// Cubic ease-out.
pub fn easeOutCubic(t: f64) f64 {
return 1.0 - std.math.pow(f64, 1.0 - t, 3);
}
/// Cubic ease-in-out.
pub fn easeInOutCubic(t: f64) f64 {
if (t < 0.5) {
return 4.0 * t * t * t;
} else {
return 1.0 - std.math.pow(f64, -2.0 * t + 2.0, 3) / 2.0;
}
}
/// Exponential ease-in.
pub fn easeInExpo(t: f64) f64 {
if (t == 0) return 0;
return std.math.pow(f64, 2, 10 * (t - 1));
}
/// Exponential ease-out.
pub fn easeOutExpo(t: f64) f64 {
if (t == 1) return 1;
return 1.0 - std.math.pow(f64, 2, -10 * t);
}
/// Bounce ease-out (bounces at the end).
pub fn easeOutBounce(t: f64) f64 {
const n1: f64 = 7.5625;
const d1: f64 = 2.75;
if (t < 1.0 / d1) {
return n1 * t * t;
} else if (t < 2.0 / d1) {
const t2 = t - 1.5 / d1;
return n1 * t2 * t2 + 0.75;
} else if (t < 2.5 / d1) {
const t2 = t - 2.25 / d1;
return n1 * t2 * t2 + 0.9375;
} else {
const t2 = t - 2.625 / d1;
return n1 * t2 * t2 + 0.984375;
}
}
/// Elastic ease-out (spring-like).
pub fn easeOutElastic(t: f64) f64 {
const c4: f64 = (2.0 * std.math.pi) / 3.0;
if (t == 0) return 0;
if (t == 1) return 1;
return std.math.pow(f64, 2, -10 * t) * @sin((t * 10 - 0.75) * c4) + 1.0;
}
/// Back ease-out (overshoots then returns).
pub fn easeOutBack(t: f64) f64 {
const c1: f64 = 1.70158;
const c3: f64 = c1 + 1;
const t2 = t - 1;
return 1.0 + c3 * std.math.pow(f64, t2, 3) + c1 * std.math.pow(f64, t2, 2);
}
};
/// Type alias for easing function pointer.
pub const EasingFn = *const fn (f64) f64;
// ============================================================================
// Animation
// ============================================================================
/// A single value animation (tween).
///
/// Animates a value from `start` to `end` over a specified duration.
pub const Animation = struct {
/// Starting value.
start_value: f64,
/// Ending value.
end_value: f64,
/// Total duration in milliseconds.
duration_ms: u64,
/// Current elapsed time in milliseconds.
elapsed_ms: u64 = 0,
/// Easing function to use.
easing: EasingFn = Easing.linear,
/// Whether the animation is paused.
paused: bool = false,
/// Number of times to repeat (-1 for infinite).
repeat_count: i32 = 0,
/// Current repeat iteration.
current_repeat: i32 = 0,
/// Whether to reverse on repeat (ping-pong).
reverse_on_repeat: bool = false,
/// Current direction (true = forward).
forward: bool = true,
/// Delay before starting (ms).
delay_ms: u64 = 0,
/// Remaining delay.
remaining_delay_ms: u64 = 0,
/// Creates a new animation.
pub fn init(start: f64, end: f64, duration_ms: u64) Animation {
return .{
.start_value = start,
.end_value = end,
.duration_ms = duration_ms,
.remaining_delay_ms = 0,
};
}
/// Creates an animation from an integer value.
pub fn fromInt(start: i64, end: i64, duration_ms: u64) Animation {
return init(@floatFromInt(start), @floatFromInt(end), duration_ms);
}
/// Sets the easing function.
pub fn setEasing(self: Animation, easing: EasingFn) Animation {
var anim = self;
anim.easing = easing;
return anim;
}
/// Sets the repeat count (-1 for infinite).
pub fn setRepeat(self: Animation, count: i32) Animation {
var anim = self;
anim.repeat_count = count;
return anim;
}
/// Sets ping-pong mode (reverse on repeat).
pub fn setPingPong(self: Animation, enabled: bool) Animation {
var anim = self;
anim.reverse_on_repeat = enabled;
return anim;
}
/// Sets the delay before starting.
pub fn setDelay(self: Animation, delay_ms: u64) Animation {
var anim = self;
anim.delay_ms = delay_ms;
anim.remaining_delay_ms = delay_ms;
return anim;
}
/// Advances the animation by the given delta time.
pub fn advance(self: *Animation, delta_ms: u64) void {
if (self.paused) return;
// Handle delay
if (self.remaining_delay_ms > 0) {
if (delta_ms >= self.remaining_delay_ms) {
const remaining = delta_ms - self.remaining_delay_ms;
self.remaining_delay_ms = 0;
if (remaining > 0) {
self.advance(remaining);
}
} else {
self.remaining_delay_ms -= delta_ms;
}
return;
}
// Advance time
if (self.forward) {
self.elapsed_ms +|= delta_ms;
} else {
if (delta_ms >= self.elapsed_ms) {
self.elapsed_ms = 0;
} else {
self.elapsed_ms -= delta_ms;
}
}
// Check for completion
if (self.forward and self.elapsed_ms >= self.duration_ms) {
self.elapsed_ms = self.duration_ms;
self.handleRepeat();
} else if (!self.forward and self.elapsed_ms == 0) {
self.handleRepeat();
}
}
fn handleRepeat(self: *Animation) void {
if (self.repeat_count == 0) return; // No repeat, stay complete
if (self.repeat_count < 0 or self.current_repeat < self.repeat_count) {
self.current_repeat += 1;
if (self.reverse_on_repeat) {
self.forward = !self.forward;
} else {
self.elapsed_ms = 0;
}
}
}
/// Returns the current progress (0.0 to 1.0).
pub fn getProgress(self: *const Animation) f64 {
if (self.duration_ms == 0) return 1.0;
const raw = @as(f64, @floatFromInt(self.elapsed_ms)) / @as(f64, @floatFromInt(self.duration_ms));
return @min(1.0, @max(0.0, raw));
}
/// Returns the current eased progress.
pub fn getEasedProgress(self: *const Animation) f64 {
return self.easing(self.getProgress());
}
/// Returns the current animated value.
pub fn getValue(self: *const Animation) f64 {
const t = self.getEasedProgress();
return self.start_value + (self.end_value - self.start_value) * t;
}
/// Returns the current value as an integer.
pub fn getValueInt(self: *const Animation) i64 {
return @intFromFloat(@round(self.getValue()));
}
/// Returns the current value as a u16.
pub fn getValueU16(self: *const Animation) u16 {
const val = self.getValueInt();
if (val < 0) return 0;
if (val > 65535) return 65535;
return @intCast(val);
}
/// Returns true if the animation is complete.
pub fn isComplete(self: *const Animation) bool {
if (self.repeat_count < 0) return false; // Infinite
if (self.repeat_count > 0 and self.current_repeat < self.repeat_count) return false;
// For ping-pong, also check if we're going backward (not yet done)
if (self.reverse_on_repeat and !self.forward) return false;
return self.elapsed_ms >= self.duration_ms;
}
/// Returns true if the animation is running.
pub fn isRunning(self: *const Animation) bool {
return !self.paused and !self.isComplete() and self.remaining_delay_ms == 0;
}
/// Pauses the animation.
pub fn pause(self: *Animation) void {
self.paused = true;
}
/// Resumes the animation.
pub fn unpause(self: *Animation) void {
self.paused = false;
}
/// Resets the animation to the beginning.
pub fn reset(self: *Animation) void {
self.elapsed_ms = 0;
self.current_repeat = 0;
self.forward = true;
self.remaining_delay_ms = self.delay_ms;
}
/// Seeks to a specific time.
pub fn seekTo(self: *Animation, time_ms: u64) void {
self.elapsed_ms = @min(time_ms, self.duration_ms);
}
};
// ============================================================================
// AnimationGroup
// ============================================================================
/// A group of animations that can be run in parallel or sequence.
pub const AnimationGroup = struct {
/// Animations in the group.
animations: []Animation,
/// Whether to run in parallel (true) or sequence (false).
is_parallel: bool = true,
/// Index of current animation (for sequential mode).
current_index: usize = 0,
/// Creates a parallel animation group.
pub fn parallel(animations: []Animation) AnimationGroup {
return .{
.animations = animations,
.is_parallel = true,
};
}
/// Creates a sequential animation group.
pub fn sequential(animations: []Animation) AnimationGroup {
return .{
.animations = animations,
.is_parallel = false,
};
}
/// Advances all animations.
pub fn advance(self: *AnimationGroup, delta_ms: u64) void {
if (self.is_parallel) {
for (self.animations) |*anim| {
anim.advance(delta_ms);
}
} else {
// Sequential
if (self.current_index < self.animations.len) {
self.animations[self.current_index].advance(delta_ms);
if (self.animations[self.current_index].isComplete()) {
self.current_index += 1;
}
}
}
}
/// Returns true if all animations are complete.
pub fn isComplete(self: *const AnimationGroup) bool {
if (self.is_parallel) {
for (self.animations) |*anim| {
if (!anim.isComplete()) return false;
}
return true;
} else {
return self.current_index >= self.animations.len;
}
}
/// Resets all animations.
pub fn reset(self: *AnimationGroup) void {
for (self.animations) |*anim| {
anim.reset();
}
self.current_index = 0;
}
};
// ============================================================================
// Timer
// ============================================================================
/// A simple timer for frame-based updates.
pub const Timer = struct {
/// Duration in milliseconds.
duration_ms: u64,
/// Elapsed time in milliseconds.
elapsed_ms: u64 = 0,
/// Whether the timer repeats.
is_repeating: bool = false,
/// Whether the timer is running.
running: bool = true,
/// Callback data (for user purposes).
user_data: usize = 0,
/// Creates a one-shot timer.
pub fn oneShot(duration_ms: u64) Timer {
return .{
.duration_ms = duration_ms,
.is_repeating = false,
};
}
/// Creates a repeating timer.
pub fn repeating(duration_ms: u64) Timer {
return .{
.duration_ms = duration_ms,
.is_repeating = true,
};
}
/// Advances the timer. Returns true if the timer triggered.
pub fn advance(self: *Timer, delta_ms: u64) bool {
if (!self.running) return false;
self.elapsed_ms +|= delta_ms;
if (self.elapsed_ms >= self.duration_ms) {
if (self.is_repeating) {
self.elapsed_ms = self.elapsed_ms % self.duration_ms;
} else {
self.running = false;
}
return true;
}
return false;
}
/// Returns the progress (0.0 to 1.0).
pub fn progress(self: *const Timer) f64 {
if (self.duration_ms == 0) return 1.0;
return @as(f64, @floatFromInt(self.elapsed_ms)) / @as(f64, @floatFromInt(self.duration_ms));
}
/// Resets the timer.
pub fn reset(self: *Timer) void {
self.elapsed_ms = 0;
self.running = true;
}
/// Stops the timer.
pub fn stop(self: *Timer) void {
self.running = false;
}
/// Starts/resumes the timer.
pub fn start(self: *Timer) void {
self.running = true;
}
};
// ============================================================================
// Interpolation helpers
// ============================================================================
/// Linear interpolation between two values.
pub fn lerp(a: f64, b: f64, t: f64) f64 {
return a + (b - a) * t;
}
/// Linear interpolation for integers.
pub fn lerpInt(a: i64, b: i64, t: f64) i64 {
return @intFromFloat(@round(lerp(@floatFromInt(a), @floatFromInt(b), t)));
}
/// Inverse linear interpolation (find t given value).
pub fn inverseLerp(a: f64, b: f64, value: f64) f64 {
if (b == a) return 0;
return (value - a) / (b - a);
}
/// Maps a value from one range to another.
pub fn mapRange(value: f64, in_min: f64, in_max: f64, out_min: f64, out_max: f64) f64 {
const t = inverseLerp(in_min, in_max, value);
return lerp(out_min, out_max, t);
}
// ============================================================================
// Tests
// ============================================================================
test "Easing.linear" {
try std.testing.expectEqual(@as(f64, 0.0), Easing.linear(0.0));
try std.testing.expectEqual(@as(f64, 0.5), Easing.linear(0.5));
try std.testing.expectEqual(@as(f64, 1.0), Easing.linear(1.0));
}
test "Easing.easeIn" {
try std.testing.expectEqual(@as(f64, 0.0), Easing.easeIn(0.0));
try std.testing.expectEqual(@as(f64, 0.25), Easing.easeIn(0.5));
try std.testing.expectEqual(@as(f64, 1.0), Easing.easeIn(1.0));
}
test "Easing.easeOut" {
try std.testing.expectEqual(@as(f64, 0.0), Easing.easeOut(0.0));
try std.testing.expectEqual(@as(f64, 0.75), Easing.easeOut(0.5));
try std.testing.expectEqual(@as(f64, 1.0), Easing.easeOut(1.0));
}
test "Animation basic" {
var anim = Animation.init(0, 100, 1000);
try std.testing.expectEqual(@as(f64, 0), anim.getValue());
try std.testing.expect(!anim.isComplete());
anim.advance(500);
try std.testing.expectEqual(@as(f64, 50), anim.getValue());
try std.testing.expect(!anim.isComplete());
anim.advance(500);
try std.testing.expectEqual(@as(f64, 100), anim.getValue());
try std.testing.expect(anim.isComplete());
}
test "Animation with easing" {
var anim = Animation.init(0, 100, 1000).setEasing(Easing.easeIn);
anim.advance(500);
// easeIn at 0.5 = 0.25, so value should be 25
try std.testing.expectEqual(@as(f64, 25), anim.getValue());
}
test "Animation repeat" {
var anim = Animation.init(0, 100, 100).setRepeat(2);
// First iteration
anim.advance(100);
try std.testing.expect(!anim.isComplete());
// Second iteration
anim.advance(100);
try std.testing.expect(!anim.isComplete());
// Third iteration (should complete)
anim.advance(100);
try std.testing.expect(anim.isComplete());
}
test "Animation ping-pong" {
var anim = Animation.init(0, 100, 100).setRepeat(1).setPingPong(true);
anim.advance(100); // Forward complete
try std.testing.expect(!anim.isComplete());
try std.testing.expect(!anim.forward);
anim.advance(50); // Half back
try std.testing.expectEqual(@as(f64, 50), anim.getValue());
}
test "Timer one-shot" {
var timer = Timer.oneShot(100);
try std.testing.expect(!timer.advance(50));
try std.testing.expect(timer.advance(50));
try std.testing.expect(!timer.running);
}
test "Timer repeating" {
var timer = Timer.repeating(100);
try std.testing.expect(timer.advance(100));
try std.testing.expect(timer.running);
try std.testing.expect(timer.advance(100));
try std.testing.expect(timer.running);
}
test "lerp" {
try std.testing.expectEqual(@as(f64, 0), lerp(0, 100, 0));
try std.testing.expectEqual(@as(f64, 50), lerp(0, 100, 0.5));
try std.testing.expectEqual(@as(f64, 100), lerp(0, 100, 1));
try std.testing.expectEqual(@as(f64, 25), lerp(0, 100, 0.25));
}
test "inverseLerp" {
try std.testing.expectEqual(@as(f64, 0), inverseLerp(0, 100, 0));
try std.testing.expectEqual(@as(f64, 0.5), inverseLerp(0, 100, 50));
try std.testing.expectEqual(@as(f64, 1), inverseLerp(0, 100, 100));
}
test "mapRange" {
// Map 0-100 to 0-1
try std.testing.expectEqual(@as(f64, 0.5), mapRange(50, 0, 100, 0, 1));
// Map 0-100 to 100-200
try std.testing.expectEqual(@as(f64, 150), mapRange(50, 0, 100, 100, 200));
}