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>
This commit is contained in:
parent
4d27a7b13d
commit
7c6515765e
4 changed files with 847 additions and 0 deletions
19
build.zig
19
build.zig
|
|
@ -137,4 +137,23 @@ pub fn build(b: *std.Build) void {
|
||||||
run_input_demo.step.dependOn(b.getInstallStep());
|
run_input_demo.step.dependOn(b.getInstallStep());
|
||||||
const input_demo_step = b.step("input-demo", "Run input demo");
|
const input_demo_step = b.step("input-demo", "Run input demo");
|
||||||
input_demo_step.dependOn(&run_input_demo.step);
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
220
examples/animation_demo.zig
Normal file
220
examples/animation_demo.zig
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
601
src/animation.zig
Normal file
601
src/animation.zig
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -160,6 +160,13 @@ pub const cursor = @import("cursor.zig");
|
||||||
pub const Cursor = cursor.Cursor;
|
pub const Cursor = cursor.Cursor;
|
||||||
pub const CursorStyle = cursor.CursorStyle;
|
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
|
// Tests
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue