zcatgui/src/render/animation.zig
reugenio 91e13f6956 feat: zcatgui Gio parity - 12 new widgets + gesture system
New widgets (12):
- Switch: Toggle switch with animation
- IconButton: Circular icon button (filled/outlined/ghost/tonal)
- Divider: Horizontal/vertical separator with optional label
- Loader: 7 spinner styles (circular/dots/bars/pulse/bounce/ring/square)
- Surface: Elevated container with shadow layers
- Grid: Layout grid with scrolling and selection
- Resize: Draggable resize handle (horizontal/vertical/both)
- AppBar: Application bar (top/bottom) with actions
- NavDrawer: Navigation drawer with items, icons, badges
- Sheet: Side/bottom sliding panel with modal support
- Discloser: Expandable/collapsible container (3 icon styles)
- Selectable: Clickable region with selection modes

Core systems added:
- GestureRecognizer: Tap, double-tap, long-press, drag, swipe
- Velocity tracking and fling detection
- Spring physics for fluid animations

Integration:
- All widgets exported via widgets.zig
- GestureRecognizer exported via zcatgui.zig
- Spring/SpringConfig exported from animation.zig
- Color.withAlpha() method added to style.zig

Stats: 47 widget files, 338+ tests, +5,619 LOC
Full Gio UI parity achieved.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 17:21:15 +01:00

588 lines
16 KiB
Zig

//! Animation System
//!
//! Provides easing functions and animation management for smooth transitions.
//! Supports various easing curves and animation lifecycle.
const std = @import("std");
/// Easing function type
pub const EasingFn = *const fn (f32) f32;
/// Standard easing functions
pub const Easing = struct {
/// Linear interpolation (no easing)
pub fn linear(t: f32) f32 {
return t;
}
/// Quadratic ease-in
pub fn easeInQuad(t: f32) f32 {
return t * t;
}
/// Quadratic ease-out
pub fn easeOutQuad(t: f32) f32 {
return t * (2 - t);
}
/// Quadratic ease-in-out
pub fn easeInOutQuad(t: f32) f32 {
if (t < 0.5) {
return 2 * t * t;
} else {
return -1 + (4 - 2 * t) * t;
}
}
/// Cubic ease-in
pub fn easeInCubic(t: f32) f32 {
return t * t * t;
}
/// Cubic ease-out
pub fn easeOutCubic(t: f32) f32 {
const t1 = t - 1;
return t1 * t1 * t1 + 1;
}
/// Cubic ease-in-out
pub fn easeInOutCubic(t: f32) f32 {
if (t < 0.5) {
return 4 * t * t * t;
} else {
const t1 = 2 * t - 2;
return 0.5 * t1 * t1 * t1 + 1;
}
}
/// Quartic ease-in
pub fn easeInQuart(t: f32) f32 {
return t * t * t * t;
}
/// Quartic ease-out
pub fn easeOutQuart(t: f32) f32 {
const t1 = t - 1;
return 1 - t1 * t1 * t1 * t1;
}
/// Quartic ease-in-out
pub fn easeInOutQuart(t: f32) f32 {
if (t < 0.5) {
return 8 * t * t * t * t;
} else {
const t1 = t - 1;
return 1 - 8 * t1 * t1 * t1 * t1;
}
}
/// Sine ease-in
pub fn easeInSine(t: f32) f32 {
return 1 - @cos(t * std.math.pi / 2);
}
/// Sine ease-out
pub fn easeOutSine(t: f32) f32 {
return @sin(t * std.math.pi / 2);
}
/// Sine ease-in-out
pub fn easeInOutSine(t: f32) f32 {
return 0.5 * (1 - @cos(std.math.pi * t));
}
/// Exponential ease-in
pub fn easeInExpo(t: f32) f32 {
if (t == 0) return 0;
return std.math.pow(f32, 2, 10 * (t - 1));
}
/// Exponential ease-out
pub fn easeOutExpo(t: f32) f32 {
if (t == 1) return 1;
return 1 - std.math.pow(f32, 2, -10 * t);
}
/// Exponential ease-in-out
pub fn easeInOutExpo(t: f32) f32 {
if (t == 0) return 0;
if (t == 1) return 1;
if (t < 0.5) {
return 0.5 * std.math.pow(f32, 2, 20 * t - 10);
} else {
return 1 - 0.5 * std.math.pow(f32, 2, -20 * t + 10);
}
}
/// Elastic ease-in
pub fn easeInElastic(t: f32) f32 {
if (t == 0) return 0;
if (t == 1) return 1;
const p: f32 = 0.3;
return -std.math.pow(f32, 2, 10 * (t - 1)) * @sin((t - 1 - p / 4) * 2 * std.math.pi / p);
}
/// Elastic ease-out
pub fn easeOutElastic(t: f32) f32 {
if (t == 0) return 0;
if (t == 1) return 1;
const p: f32 = 0.3;
return std.math.pow(f32, 2, -10 * t) * @sin((t - p / 4) * 2 * std.math.pi / p) + 1;
}
/// Bounce ease-out
pub fn easeOutBounce(t: f32) f32 {
const n1: f32 = 7.5625;
const d1: f32 = 2.75;
if (t < 1 / d1) {
return n1 * t * t;
} else if (t < 2 / d1) {
const t1 = t - 1.5 / d1;
return n1 * t1 * t1 + 0.75;
} else if (t < 2.5 / d1) {
const t1 = t - 2.25 / d1;
return n1 * t1 * t1 + 0.9375;
} else {
const t1 = t - 2.625 / d1;
return n1 * t1 * t1 + 0.984375;
}
}
/// Bounce ease-in
pub fn easeInBounce(t: f32) f32 {
return 1 - easeOutBounce(1 - t);
}
/// Bounce ease-in-out
pub fn easeInOutBounce(t: f32) f32 {
if (t < 0.5) {
return 0.5 * easeInBounce(t * 2);
} else {
return 0.5 * easeOutBounce(t * 2 - 1) + 0.5;
}
}
/// Back ease-in (overshoots)
pub fn easeInBack(t: f32) f32 {
const s: f32 = 1.70158;
return t * t * ((s + 1) * t - s);
}
/// Back ease-out (overshoots)
pub fn easeOutBack(t: f32) f32 {
const s: f32 = 1.70158;
const t1 = t - 1;
return t1 * t1 * ((s + 1) * t1 + s) + 1;
}
/// Back ease-in-out (overshoots)
pub fn easeInOutBack(t: f32) f32 {
const s: f32 = 1.70158 * 1.525;
if (t < 0.5) {
return 0.5 * (4 * t * t * ((s + 1) * 2 * t - s));
} else {
const t1 = 2 * t - 2;
return 0.5 * (t1 * t1 * ((s + 1) * t1 + s) + 2);
}
}
};
/// An animation from start to end value
pub const Animation = struct {
/// Starting value
start_value: f32,
/// Target value
end_value: f32,
/// Duration in milliseconds
duration_ms: u32,
/// Easing function
easing: EasingFn = Easing.linear,
/// Start timestamp (ms)
start_time: i64 = 0,
/// Is animation running
running: bool = false,
/// Loop animation
looping: bool = false,
/// Ping-pong (reverse on loop)
pingpong: bool = false,
/// Current direction (for pingpong)
reverse: bool = false,
const Self = @This();
/// Create a new animation
pub fn create(start_val: f32, end_val: f32, duration_ms: u32, easing_fn: EasingFn) Self {
return .{
.start_value = start_val,
.end_value = end_val,
.duration_ms = duration_ms,
.easing = easing_fn,
};
}
/// Start the animation
pub fn start(self: *Self, current_time_ms: i64) void {
self.start_time = current_time_ms;
self.running = true;
self.reverse = false;
}
/// Stop the animation
pub fn stop(self: *Self) void {
self.running = false;
}
/// Get current animated value
pub fn getValue(self: Self, current_time_ms: i64) f32 {
if (!self.running) {
return self.start_value;
}
const elapsed = current_time_ms - self.start_time;
if (elapsed < 0) return self.start_value;
var t = @as(f32, @floatFromInt(elapsed)) / @as(f32, @floatFromInt(self.duration_ms));
if (t >= 1.0) {
if (self.looping) {
if (self.pingpong) {
// Would handle loop logic here
t = 1.0;
} else {
t = @mod(t, 1.0);
}
} else {
t = 1.0;
}
}
const eased = self.easing(t);
if (self.reverse) {
return self.end_value + (self.start_value - self.end_value) * eased;
} else {
return self.start_value + (self.end_value - self.start_value) * eased;
}
}
/// Check if animation is complete
pub fn isComplete(self: Self, current_time_ms: i64) bool {
if (!self.running) return true;
if (self.looping) return false;
const elapsed = current_time_ms - self.start_time;
return elapsed >= @as(i64, @intCast(self.duration_ms));
}
/// Get progress (0.0 - 1.0)
pub fn getProgress(self: Self, current_time_ms: i64) f32 {
if (!self.running) return 0;
const elapsed = current_time_ms - self.start_time;
if (elapsed < 0) return 0;
const t = @as(f32, @floatFromInt(elapsed)) / @as(f32, @floatFromInt(self.duration_ms));
return @min(1.0, @max(0.0, t));
}
};
/// Maximum concurrent animations
const MAX_ANIMATIONS = 128;
/// Animation manager for tracking multiple animations
pub const AnimationManager = struct {
animations: [MAX_ANIMATIONS]struct {
id: u64,
anim: Animation,
active: bool,
} = undefined,
count: usize = 0,
const Self = @This();
/// Initialize manager
pub fn init() Self {
var manager = Self{};
for (&manager.animations) |*slot| {
slot.active = false;
}
return manager;
}
/// Start a new animation
pub fn startAnimation(self: *Self, id: u64, anim: Animation, current_time_ms: i64) void {
// Check if already exists
var i: usize = 0;
while (i < self.count) : (i += 1) {
if (self.animations[i].active and self.animations[i].id == id) {
self.animations[i].anim = anim;
self.animations[i].anim.start(current_time_ms);
return;
}
}
// Add new
if (self.count < MAX_ANIMATIONS) {
self.animations[self.count] = .{
.id = id,
.anim = anim,
.active = true,
};
self.animations[self.count].anim.start(current_time_ms);
self.count += 1;
}
}
/// Stop an animation
pub fn stopAnimation(self: *Self, id: u64) void {
var i: usize = 0;
while (i < self.count) : (i += 1) {
if (self.animations[i].active and self.animations[i].id == id) {
self.animations[i].anim.stop();
self.animations[i].active = false;
return;
}
}
}
/// Get animation value
pub fn getValue(self: Self, id: u64, current_time_ms: i64) ?f32 {
var i: usize = 0;
while (i < self.count) : (i += 1) {
const slot = self.animations[i];
if (slot.active and slot.id == id) {
return slot.anim.getValue(current_time_ms);
}
}
return null;
}
/// Check if animation exists and is running
pub fn isRunning(self: Self, id: u64) bool {
var i: usize = 0;
while (i < self.count) : (i += 1) {
const slot = self.animations[i];
if (slot.active and slot.id == id) {
return slot.anim.running;
}
}
return false;
}
/// Update animations, removing completed ones
pub fn update(self: *Self, current_time_ms: i64) void {
var i: usize = 0;
while (i < self.count) : (i += 1) {
if (self.animations[i].active and
self.animations[i].anim.isComplete(current_time_ms) and
!self.animations[i].anim.looping)
{
self.animations[i].active = false;
}
}
// Compact array
self.compact();
}
/// Remove inactive animations
fn compact(self: *Self) void {
var write_idx: usize = 0;
var i: usize = 0;
while (i < self.count) : (i += 1) {
const slot = self.animations[i];
if (slot.active) {
self.animations[write_idx] = slot;
write_idx += 1;
}
}
self.count = write_idx;
}
/// Get count of active animations
pub fn activeCount(self: Self) usize {
var cnt: usize = 0;
var i: usize = 0;
while (i < self.count) : (i += 1) {
if (self.animations[i].active) cnt += 1;
}
return cnt;
}
};
/// Interpolate between two values
pub fn lerp(a: f32, b: f32, t: f32) f32 {
return a + (b - a) * t;
}
/// Interpolate between two integers
pub fn lerpInt(a: i32, b: i32, t: f32) i32 {
return a + @as(i32, @intFromFloat(@as(f32, @floatFromInt(b - a)) * t));
}
// =============================================================================
// Tests
// =============================================================================
test "Easing linear" {
try std.testing.expectEqual(@as(f32, 0.0), Easing.linear(0.0));
try std.testing.expectEqual(@as(f32, 0.5), Easing.linear(0.5));
try std.testing.expectEqual(@as(f32, 1.0), Easing.linear(1.0));
}
test "Easing quadratic" {
try std.testing.expectEqual(@as(f32, 0.0), Easing.easeInQuad(0.0));
try std.testing.expectEqual(@as(f32, 1.0), Easing.easeInQuad(1.0));
try std.testing.expect(Easing.easeInQuad(0.5) < 0.5); // Slower start
try std.testing.expectEqual(@as(f32, 0.0), Easing.easeOutQuad(0.0));
try std.testing.expectEqual(@as(f32, 1.0), Easing.easeOutQuad(1.0));
try std.testing.expect(Easing.easeOutQuad(0.5) > 0.5); // Faster start
}
test "Animation create and getValue" {
var anim = Animation.create(0, 100, 1000, Easing.linear);
anim.start(0);
try std.testing.expectEqual(@as(f32, 0), anim.getValue(0));
try std.testing.expectEqual(@as(f32, 50), anim.getValue(500));
try std.testing.expectEqual(@as(f32, 100), anim.getValue(1000));
try std.testing.expectEqual(@as(f32, 100), anim.getValue(1500)); // Clamped
}
test "Animation isComplete" {
var anim = Animation.create(0, 100, 1000, Easing.linear);
anim.start(0);
try std.testing.expect(!anim.isComplete(500));
try std.testing.expect(anim.isComplete(1000));
try std.testing.expect(anim.isComplete(1500));
}
test "AnimationManager basic" {
var manager = AnimationManager.init();
manager.startAnimation(1, Animation.create(0, 100, 1000, Easing.linear), 0);
try std.testing.expect(manager.isRunning(1));
try std.testing.expectEqual(@as(?f32, 50), manager.getValue(1, 500));
manager.stopAnimation(1);
try std.testing.expect(!manager.isRunning(1));
}
test "AnimationManager update" {
var manager = AnimationManager.init();
manager.startAnimation(1, Animation.create(0, 100, 100, Easing.linear), 0);
manager.startAnimation(2, Animation.create(0, 100, 200, Easing.linear), 0);
try std.testing.expectEqual(@as(usize, 2), manager.count);
manager.update(150);
// Animation 1 should be removed (complete), 2 still active
try std.testing.expectEqual(@as(usize, 1), manager.activeCount());
}
test "lerp" {
try std.testing.expectEqual(@as(f32, 0.0), lerp(0, 100, 0));
try std.testing.expectEqual(@as(f32, 50.0), lerp(0, 100, 0.5));
try std.testing.expectEqual(@as(f32, 100.0), lerp(0, 100, 1.0));
}
// =============================================================================
// Spring Physics (Gio parity)
// =============================================================================
/// Spring animation configuration
pub const SpringConfig = struct {
/// Spring stiffness (higher = faster)
stiffness: f32 = 100.0,
/// Damping factor (higher = less oscillation)
damping: f32 = 10.0,
/// Mass (higher = more momentum)
mass: f32 = 1.0,
};
/// Spring animation state for physics-based animations
pub const Spring = struct {
/// Current position
position: f32 = 0.0,
/// Current velocity
velocity: f32 = 0.0,
/// Target position
target: f32 = 0.0,
/// Configuration
config: SpringConfig = .{},
/// Threshold for considering settled
threshold: f32 = 0.001,
const Self = @This();
/// Create a spring from initial to target
pub fn create(initial: f32, target_val: f32, config: SpringConfig) Self {
return .{
.position = initial,
.target = target_val,
.config = config,
};
}
/// Update spring physics by delta time (seconds)
pub fn update(self: *Self, dt: f32) void {
const displacement = self.position - self.target;
const spring_force = -self.config.stiffness * displacement;
const damping_force = -self.config.damping * self.velocity;
const acceleration = (spring_force + damping_force) / self.config.mass;
self.velocity += acceleration * dt;
self.position += self.velocity * dt;
}
/// Check if spring has settled at target
pub fn isSettled(self: *const Self) bool {
const displacement = @abs(self.position - self.target);
return displacement < self.threshold and @abs(self.velocity) < self.threshold;
}
/// Set new target position
pub fn setTarget(self: *Self, new_target: f32) void {
self.target = new_target;
}
/// Snap to target immediately
pub fn snap(self: *Self) void {
self.position = self.target;
self.velocity = 0;
}
/// Get current value
pub fn getValue(self: *const Self) f32 {
return self.position;
}
};
test "Spring physics basic" {
var spring = Spring.create(0.0, 100.0, .{
.stiffness = 100.0,
.damping = 10.0,
});
// Simulate several frames
var i: usize = 0;
while (i < 200) : (i += 1) {
spring.update(0.016); // ~60fps
}
// Should be close to target and settled
try std.testing.expect(@abs(spring.position - spring.target) < 1.0);
try std.testing.expect(spring.isSettled());
}
test "Spring snap" {
var spring = Spring.create(0.0, 100.0, .{});
spring.snap();
try std.testing.expectEqual(@as(f32, 100.0), spring.position);
try std.testing.expectEqual(@as(f32, 0.0), spring.velocity);
}