From 7c6515765e8d29b0594798e47231f60b13376860 Mon Sep 17 00:00:00 2001 From: reugenio Date: Mon, 8 Dec 2025 13:40:07 +0100 Subject: [PATCH] Add animation system with easing functions and timers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- build.zig | 19 ++ examples/animation_demo.zig | 220 +++++++++++++ src/animation.zig | 601 ++++++++++++++++++++++++++++++++++++ src/root.zig | 7 + 4 files changed, 847 insertions(+) create mode 100644 examples/animation_demo.zig create mode 100644 src/animation.zig diff --git a/build.zig b/build.zig index 6543acb..ca79c1e 100644 --- a/build.zig +++ b/build.zig @@ -137,4 +137,23 @@ pub fn build(b: *std.Build) void { run_input_demo.step.dependOn(b.getInstallStep()); const input_demo_step = b.step("input-demo", "Run input demo"); input_demo_step.dependOn(&run_input_demo.step); + + // Ejemplo: animation_demo + const animation_demo_exe = b.addExecutable(.{ + .name = "animation-demo", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/animation_demo.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zcatui", .module = zcatui_mod }, + }, + }), + }); + b.installArtifact(animation_demo_exe); + + const run_animation_demo = b.addRunArtifact(animation_demo_exe); + run_animation_demo.step.dependOn(b.getInstallStep()); + const animation_demo_step = b.step("animation-demo", "Run animation demo"); + animation_demo_step.dependOn(&run_animation_demo.step); } diff --git a/examples/animation_demo.zig b/examples/animation_demo.zig new file mode 100644 index 0000000..97f9249 --- /dev/null +++ b/examples/animation_demo.zig @@ -0,0 +1,220 @@ +//! Animation demo for zcatui. +//! +//! Demonstrates the animation system with: +//! - Smooth gauge transitions +//! - Bouncing progress bar +//! - Multiple easing functions comparison +//! - Timer-based updates +//! +//! Run with: zig build animation-demo + +const std = @import("std"); +const zcatui = @import("zcatui"); + +const Terminal = zcatui.Terminal; +const Buffer = zcatui.Buffer; +const Rect = zcatui.Rect; +const Style = zcatui.Style; +const Color = zcatui.Color; +const Event = zcatui.Event; +const KeyCode = zcatui.KeyCode; +const Layout = zcatui.Layout; +const Constraint = zcatui.Constraint; +const Block = zcatui.widgets.Block; +const Borders = zcatui.widgets.Borders; +const Gauge = zcatui.widgets.Gauge; +const Animation = zcatui.Animation; +const Easing = zcatui.Easing; +const Timer = zcatui.Timer; + +const AppState = struct { + // Animations for different easing demos + linear_anim: Animation, + ease_in_anim: Animation, + ease_out_anim: Animation, + bounce_anim: Animation, + elastic_anim: Animation, + + // Timer for frame tracking + frame_timer: Timer, + tick_count: u64 = 0, + + running: bool = true, + last_time_ms: i64 = 0, + + fn init() AppState { + // All animations: 0 to 100 over 2 seconds, repeating ping-pong + return .{ + .linear_anim = Animation.init(0, 100, 2000) + .setEasing(Easing.linear) + .setRepeat(-1) + .setPingPong(true), + .ease_in_anim = Animation.init(0, 100, 2000) + .setEasing(Easing.easeInCubic) + .setRepeat(-1) + .setPingPong(true), + .ease_out_anim = Animation.init(0, 100, 2000) + .setEasing(Easing.easeOutCubic) + .setRepeat(-1) + .setPingPong(true), + .bounce_anim = Animation.init(0, 100, 2000) + .setEasing(Easing.easeOutBounce) + .setRepeat(-1) + .setPingPong(true), + .elastic_anim = Animation.init(0, 100, 2000) + .setEasing(Easing.easeOutElastic) + .setRepeat(-1) + .setPingPong(true), + .frame_timer = Timer.repeating(1000), // 1 second for FPS counting + }; + } + + fn update(self: *AppState, delta_ms: u64) void { + // Advance all animations + self.linear_anim.advance(delta_ms); + self.ease_in_anim.advance(delta_ms); + self.ease_out_anim.advance(delta_ms); + self.bounce_anim.advance(delta_ms); + self.elastic_anim.advance(delta_ms); + + // Update timer + if (self.frame_timer.advance(delta_ms)) { + self.tick_count += 1; + } + } + + fn restartAnimations(self: *AppState) void { + self.linear_anim.reset(); + self.ease_in_anim.reset(); + self.ease_out_anim.reset(); + self.bounce_anim.reset(); + self.elastic_anim.reset(); + } +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var term = try Terminal.init(allocator); + defer term.deinit(); + + var state = AppState.init(); + + // Get initial timestamp + state.last_time_ms = std.time.milliTimestamp(); + + while (state.running) { + // Calculate delta time + const current_time_ms = std.time.milliTimestamp(); + const delta_ms: u64 = @intCast(@max(0, current_time_ms - state.last_time_ms)); + state.last_time_ms = current_time_ms; + + // Update animations + state.update(delta_ms); + + // Render + try term.drawWithContext(&state, render); + + // Poll events with short timeout for smooth animation + if (try term.pollEvent(16)) |event| { // ~60 FPS target + handleEvent(&state, event); + } + } +} + +fn handleEvent(state: *AppState, event: Event) void { + switch (event) { + .key => |key| { + switch (key.code) { + .esc => state.running = false, + .char => |c| { + switch (c) { + 'q', 'Q' => state.running = false, + 'r', 'R' => state.restartAnimations(), + else => {}, + } + }, + else => {}, + } + }, + else => {}, + } +} + +fn render(state: *AppState, area: Rect, buf: *Buffer) void { + // Main layout + const chunks = Layout.vertical(&.{ + Constraint.length(3), // Title + Constraint.length(3), // Linear + Constraint.length(3), // Ease-in + Constraint.length(3), // Ease-out + Constraint.length(3), // Bounce + Constraint.length(3), // Elastic + Constraint.min(0), // Help + }).split(area); + + // Title + renderTitle(state, chunks.get(0), buf); + + // Animation gauges + renderGauge("Linear", state.linear_anim.getValueU16(), Color.cyan, chunks.get(1), buf); + renderGauge("Ease-In (Cubic)", state.ease_in_anim.getValueU16(), Color.green, chunks.get(2), buf); + renderGauge("Ease-Out (Cubic)", state.ease_out_anim.getValueU16(), Color.yellow, chunks.get(3), buf); + renderGauge("Bounce", state.bounce_anim.getValueU16(), Color.magenta, chunks.get(4), buf); + renderGauge("Elastic", state.elastic_anim.getValueU16(), Color.red, chunks.get(5), buf); + + // Help + renderHelp(chunks.get(6), buf); +} + +fn renderTitle(state: *AppState, area: Rect, buf: *Buffer) void { + const title_block = Block.init() + .title(" Animation Demo ") + .setBorders(Borders.all) + .style(Style.default.fg(Color.white)); + title_block.render(area, buf); + + const inner = title_block.inner(area); + var info_buf: [64]u8 = undefined; + const info = std.fmt.bufPrint(&info_buf, "Tick: {d} | All animations ping-pong 0-100 over 2s", .{state.tick_count}) catch "???"; + _ = buf.setString(inner.left(), inner.top(), info, Style.default); +} + +fn renderGauge(title: []const u8, value: u16, color: Color, area: Rect, buf: *Buffer) void { + // Clamp value to 0-100 + const percent: u16 = @min(100, value); + + const gauge = Gauge.init() + .setBlock(Block.init().title(title).setBorders(Borders.all)) + .percent(percent) + .gaugeStyle(Style.default.fg(color)); + gauge.render(area, buf); +} + +fn renderHelp(area: Rect, buf: *Buffer) void { + const help_block = Block.init() + .title(" Controls ") + .setBorders(Borders.all) + .style(Style.default.fg(Color.blue)); + help_block.render(area, buf); + + const inner = help_block.inner(area); + var y = inner.top(); + + const lines = [_][]const u8{ + "r - Restart all animations", + "q/ESC - Quit", + "", + "Compare how different easing functions", + "create different motion feels!", + }; + + for (lines) |line| { + if (y < inner.bottom()) { + _ = buf.setString(inner.left(), y, line, Style.default); + y += 1; + } + } +} diff --git a/src/animation.zig b/src/animation.zig new file mode 100644 index 0000000..5b28f5a --- /dev/null +++ b/src/animation.zig @@ -0,0 +1,601 @@ +//! 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)); +} diff --git a/src/root.zig b/src/root.zig index b7e43a2..8a39efe 100644 --- a/src/root.zig +++ b/src/root.zig @@ -160,6 +160,13 @@ pub const cursor = @import("cursor.zig"); pub const Cursor = cursor.Cursor; pub const CursorStyle = cursor.CursorStyle; +// Animation system +pub const animation = @import("animation.zig"); +pub const Animation = animation.Animation; +pub const AnimationGroup = animation.AnimationGroup; +pub const Easing = animation.Easing; +pub const Timer = animation.Timer; + // ============================================================================ // Tests // ============================================================================