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>
588 lines
16 KiB
Zig
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);
|
|
}
|