From f17cb33f914dc32e6ed97fbaef347583768096c8 Mon Sep 17 00:00:00 2001 From: albi Date: Thu, 17 Sep 2026 19:17:59 +0200 Subject: [PATCH 1/3] feat: add implicit animation widgets and gallery --- CHANGELOG.md | 4 + README.md | 3 +- app_examples/docs-examples/Cargo.toml | 1 + .../docs-examples/src/examples/animation.rs | 48 ++- app_examples/docs-examples/tests/behavior.rs | 2 + crates/argui-animation/src/controller.rs | 9 +- crates/argui-animation/src/easing.rs | 74 +++++ crates/argui-animation/src/lib.rs | 4 +- crates/argui-animation/tests/easing.rs | 29 +- .../argui-widget-gallery/src/pages/motion.rs | 273 ++++++++++------- .../src/pages/motion/advanced.rs | 105 +++++++ .../src/pages/motion/implicit.rs | 162 +++++++++++ .../src/pages/motion/keyframes.rs | 169 +++++++++++ .../src/pages/motion/physics.rs | 91 ++++++ .../tests/pages/motion.rs | 39 ++- crates/argui-widgets/Cargo.toml | 2 + .../argui-widgets/src/implicit_animation.rs | 274 ++++++++++++++++++ crates/argui-widgets/src/lib.rs | 4 + .../argui-widgets/tests/implicit_animation.rs | 157 ++++++++++ crates/argui/Cargo.toml | 1 + docs/ui/animation.md | 148 ++++++++++ docs/widgets/shadcn.md | 1 + scripts/check-widget-features.py | 20 +- tests/scripts/test_shadcn_catalogue.py | 6 +- .../app/data/doc-example-sources.generated.ts | 2 +- website/app/data/docs.ts | 28 +- 26 files changed, 1526 insertions(+), 130 deletions(-) create mode 100644 crates/argui-widget-gallery/src/pages/motion/advanced.rs create mode 100644 crates/argui-widget-gallery/src/pages/motion/implicit.rs create mode 100644 crates/argui-widget-gallery/src/pages/motion/keyframes.rs create mode 100644 crates/argui-widget-gallery/src/pages/motion/physics.rs create mode 100644 crates/argui-widgets/src/implicit_animation.rs create mode 100644 crates/argui-widgets/tests/implicit_animation.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 70fb8e64..c09c0860 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ that do not use them. ### Added +- Added `AnimatedOpacity` and `AnimatedContainer` implicit target-value widgets, + a public user-implementable `Curve` trait, nine named curve presets, and a + twenty-example Animation Laboratory covering keyframes, orchestration, + physics, composition, interruption and reduced motion. - Added incremental `TextEdit` delivery for `Input` and `TextArea`, including `on_edit`, context callback helpers, UTF-8 validation, fragment-based history, and lazy compatibility delivery for the existing `on_input(String)` API. diff --git a/README.md b/README.md index 156cf80b..6237454b 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,8 @@ Available widget flags are `widget-accordion`, `widget-alert`, `widget-command-palette`, `widget-context-menu`, `widget-data-table`, `widget-date-picker`, `widget-dialog`, `widget-direction`, `widget-drawer`, `widget-empty`, `widget-field`, `widget-file-picker`, `widget-hover-card`, -`widget-icons`, `widget-input`, `widget-input-group`, `widget-input-otp`, +`widget-icons`, `widget-implicit-animation`, `widget-input`, `widget-input-group`, +`widget-input-otp`, `widget-item`, `widget-kbd`, `widget-label`, `widget-list`, `widget-marker`, `widget-menu`, `widget-menubar`, `widget-message`, `widget-message-scroller`, `widget-native-select`, `widget-navigation-menu`, `widget-pagination`, diff --git a/app_examples/docs-examples/Cargo.toml b/app_examples/docs-examples/Cargo.toml index ae546eb5..14fe4f30 100644 --- a/app_examples/docs-examples/Cargo.toml +++ b/app_examples/docs-examples/Cargo.toml @@ -17,6 +17,7 @@ argui = { workspace = true, features = [ "tasks", "widget-button", "widget-dialog", + "widget-implicit-animation", "widget-popover", "widget-range", "widget-textarea", diff --git a/app_examples/docs-examples/src/examples/animation.rs b/app_examples/docs-examples/src/examples/animation.rs index c7dbdafa..9355e08e 100644 --- a/app_examples/docs-examples/src/examples/animation.rs +++ b/app_examples/docs-examples/src/examples/animation.rs @@ -1,4 +1,5 @@ use argui::{ + animation::{Duration, curves}, core::{ColorInterpolation, Transform2D, TransformOrigin}, paint::{Border, CornerRadii, LayerStyle, Shadow, VectorAsset, VectorId}, runtime::{Context, LayoutSnapshot, Render}, @@ -8,7 +9,7 @@ use argui::{ percent, }, vector::VectorLibrary, - widgets::{Button, WidgetTheme, default_theme}, + widgets::{AnimatedContainer, AnimatedOpacity, Button, WidgetTheme, default_theme}, }; const ORBIT_SVG: &[u8] = br#" @@ -24,6 +25,7 @@ pub struct Example { running: bool, reduced_motion: bool, compact: bool, + expanded: bool, vectors: VectorLibrary, orbit: VectorId, spark: VectorId, @@ -39,6 +41,7 @@ impl Default for Example { running: true, reduced_motion: false, compact: false, + expanded: true, vectors, orbit, spark, @@ -221,7 +224,40 @@ impl Example { .radius(CornerRadii::all(999.0)) .transform(Transform2D::IDENTITY.scale(pulse, pulse)); + let implicit_opacity = AnimatedOpacity::new( + "docs-implicit-opacity", + if self.expanded { 1.0 } else { 0.12 }, + self.text("Target-driven fade", 17.0, 700, theme.primary), + ) + .duration(Duration::from_millis(260)) + .curve(curves::EASE_OUT) + .build(); + let implicit_container = AnimatedContainer::new("docs-implicit-container", []) + .width(length(if self.expanded { 188.0 } else { 72.0 })) + .height(length(if self.expanded { 76.0 } else { 46.0 })) + .background(if self.expanded { + theme.destructive + } else { + theme.primary + }) + .radius(CornerRadii::all(if self.expanded { 30.0 } else { 10.0 })) + .duration(Duration::from_millis(420)) + .curve(curves::EMPHASIZED) + .build(); + Element::row([ + self.card( + "AnimatedOpacity", + "A rebuild changes only the target; retained state owns the fade.", + implicit_opacity, + theme, + ), + self.card( + "AnimatedContainer", + "Size, color and radius transition with one declarative policy.", + implicit_container, + theme, + ), self.card( "Composed transform", "Translate, rotate, scale, and radius run in one two-second loop.", @@ -291,7 +327,7 @@ impl Render for Example { let status = if self.reduced_motion { "Reduced motion is active" } else if self.running { - "Six loops · 2 seconds" + "Six loops · two implicit examples" } else { "Animations paused" }; @@ -314,6 +350,14 @@ impl Render for Example { .enabled(!self.reduced_motion) .on_click(cx.callback(|app| app.running = !app.running)) .build(), + Button::new( + "toggle-implicit", + "Retarget implicit examples", + theme.outline_button(), + ) + .enabled(!self.reduced_motion) + .on_click(cx.callback(|app| app.expanded = !app.expanded)) + .build(), ]) .width(percent(1.0)) .padding(Sides::length(14.0)) diff --git a/app_examples/docs-examples/tests/behavior.rs b/app_examples/docs-examples/tests/behavior.rs index cf306fc7..6437b9d7 100644 --- a/app_examples/docs-examples/tests/behavior.rs +++ b/app_examples/docs-examples/tests/behavior.rs @@ -22,6 +22,8 @@ fn accessibility_example_announces_each_activation() { #[test] fn animation_example_toggles_locally() { let mut app = TestApp::new(examples::animation::Example::default()); + app.click("toggle-implicit").unwrap(); + app.assert_text("Retarget implicit examples"); app.click("toggle-animation").unwrap(); app.assert_text("Animations paused"); app.assert_text("Resume animations"); diff --git a/crates/argui-animation/src/controller.rs b/crates/argui-animation/src/controller.rs index 4be53994..3289b475 100644 --- a/crates/argui-animation/src/controller.rs +++ b/crates/argui-animation/src/controller.rs @@ -1,5 +1,5 @@ use crate::{ - Composition, Duration, Easing, FillMode, Interpolate, Keyframe, Keyframes, MotionValue, + Composition, Curve, Duration, Easing, FillMode, Interpolate, Keyframe, Keyframes, MotionValue, PlaybackState, Spring, SpringConfig, Time, Timeline, Timing, TimingError, }; use std::{ @@ -43,6 +43,13 @@ impl Tween { self.easing = easing; self } + + /// Sets a built-in or user-defined curve for this tween. + #[must_use] + pub fn curve(mut self, curve: impl Curve) -> Self { + self.easing = Easing::curve(curve); + self + } } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] diff --git a/crates/argui-animation/src/easing.rs b/crates/argui-animation/src/easing.rs index c279e3b1..29c1690f 100644 --- a/crates/argui-animation/src/easing.rs +++ b/crates/argui-animation/src/easing.rs @@ -2,6 +2,27 @@ use std::{fmt, sync::Arc}; type CustomFunction = dyn Fn(f32) -> f32 + Send + Sync + 'static; +/// Maps normalized time to animation progress. +/// +/// Argui clamps the input supplied through [`Easing::curve`] to `0.0..=1.0`. +/// Implementations may return values outside that interval to create anticipation +/// or overshoot. Curves should return finite values and preserve the endpoints +/// when they are intended to finish exactly on their animated target. +pub trait Curve: Send + Sync + 'static { + /// Samples the curve at normalized `progress` and returns transformed progress. + #[must_use] + fn sample(&self, progress: f32) -> f32; +} + +impl Curve for F +where + F: Fn(f32) -> f32 + Send + Sync + 'static, +{ + fn sample(&self, progress: f32) -> f32 { + self(progress) + } +} + #[derive(Clone, Default)] /// Maps normalized animation progress to eased progress. pub enum Easing { @@ -25,6 +46,12 @@ impl Easing { Self::Custom(Arc::new(function)) } + /// Wraps a user-defined [`Curve`] as an easing value. + #[must_use] + pub fn curve(curve: impl Curve) -> Self { + Self::Custom(Arc::new(move |progress| curve.sample(progress))) + } + /// Creates piecewise-linear easing from sorted stops spanning input zero to one. /// /// # Errors @@ -61,6 +88,12 @@ impl Easing { } } +impl Curve for Easing { + fn sample(&self, progress: f32) -> f32 { + Easing::sample(self, progress) + } +} + impl fmt::Debug for Easing { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -138,6 +171,12 @@ impl CubicBezier { } } +impl Curve for CubicBezier { + fn sample(&self, progress: f32) -> f32 { + (*self).sample(progress) + } +} + fn cubic(parameter: f32, first: f32, second: f32) -> f32 { let inverse = 1.0 - parameter; 3.0 * inverse * inverse * parameter * first @@ -193,6 +232,41 @@ impl Steps { } } +impl Curve for Steps { + fn sample(&self, progress: f32) -> f32 { + (*self).sample(progress) + } +} + +/// Ready-to-use curves for common interface motion. +pub mod curves { + use super::{CubicBezier, Easing}; + + /// Constant-speed progress. + pub const LINEAR: Easing = Easing::Linear; + /// Gentle acceleration from rest. + pub const EASE_IN: Easing = cubic(0.42, 0.0, 1.0, 1.0); + /// Gentle deceleration into the target. + pub const EASE_OUT: Easing = cubic(0.0, 0.0, 0.58, 1.0); + /// Symmetric acceleration and deceleration. + pub const EASE_IN_OUT: Easing = cubic(0.42, 0.0, 0.58, 1.0); + /// Balanced material-style movement for most interface changes. + pub const STANDARD: Easing = cubic(0.2, 0.0, 0.0, 1.0); + /// Fast exit from the current state followed by a soft arrival. + pub const EMPHASIZED: Easing = cubic(0.2, 0.8, 0.2, 1.0); + /// Quickly leaves the initial value. + pub const ACCELERATE: Easing = cubic(0.3, 0.0, 0.8, 0.15); + /// Quickly becomes visible and then settles gently. + pub const DECELERATE: Easing = cubic(0.05, 0.7, 0.1, 1.0); + /// Overshoots the target before settling on it. + pub const BACK_OUT: Easing = cubic(0.34, 1.56, 0.64, 1.0); + + /// Creates a constant cubic Bézier easing for the preset catalog. + const fn cubic(x1: f32, y1: f32, x2: f32, y2: f32) -> Easing { + Easing::CubicBezier(CubicBezier { x1, y1, x2, y2 }) + } +} + #[derive(Clone, Copy, Debug, PartialEq)] /// Input/output pair defining a point on piecewise-linear easing. pub struct LinearStop { diff --git a/crates/argui-animation/src/lib.rs b/crates/argui-animation/src/lib.rs index e9b804ed..ef060b2c 100644 --- a/crates/argui-animation/src/lib.rs +++ b/crates/argui-animation/src/lib.rs @@ -20,7 +20,9 @@ pub use clock::{Clock, ManualClock}; pub use composition::{Compose, Composition, Contribution, compose}; pub use controller::{Motion, MotionBinding, MotionState, MotionTrack, Tween}; pub use decay::{Decay, DecayConfig}; -pub use easing::{CubicBezier, Easing, EasingError, LinearStop, StepPosition, Steps}; +pub use easing::{ + CubicBezier, Curve, Easing, EasingError, LinearStop, StepPosition, Steps, curves, +}; pub use inertia::{Inertia, InertiaConfig, InertiaState}; pub use interpolate::Interpolate; pub use keyframe::{Keyframe, Keyframes}; diff --git a/crates/argui-animation/tests/easing.rs b/crates/argui-animation/tests/easing.rs index 1b685885..1829648f 100644 --- a/crates/argui-animation/tests/easing.rs +++ b/crates/argui-animation/tests/easing.rs @@ -1,4 +1,6 @@ -use argui_animation::{CubicBezier, Easing, EasingError, LinearStop, StepPosition, Steps}; +use argui_animation::{ + CubicBezier, Curve, Easing, EasingError, LinearStop, StepPosition, Steps, Tween, curves, +}; fn close(actual: f32, expected: f32) { assert!((actual - expected).abs() < 1.0e-4, "{actual} != {expected}"); @@ -106,3 +108,28 @@ fn piecewise_linear_easing_interpolates_and_supports_hard_stops() { )); } } + +#[derive(Clone, Copy)] +struct Quadratic; + +impl Curve for Quadratic { + fn sample(&self, progress: f32) -> f32 { + progress * progress + } +} + +#[test] +fn named_and_user_curves_share_the_easing_interface() { + close(Easing::curve(Quadratic).sample(0.5), 0.25); + close(curves::LINEAR.sample(0.4), 0.4); + close(Curve::sample(&curves::LINEAR, 0.4), 0.4); + assert!(curves::EASE_OUT.sample(0.5) > 0.5); + assert!(curves::BACK_OUT.sample(0.7) > 1.0); + let bezier = CubicBezier::new(0.0, 0.0, 1.0, 1.0).unwrap(); + close(Curve::sample(&bezier, 0.35), 0.35); + let steps = Steps::new(2, StepPosition::JumpEnd).unwrap(); + close(Curve::sample(&steps, 0.75), 0.5); + + let tween = Tween::new(argui_animation::Duration::from_millis(120)).curve(Quadratic); + close(tween.easing.sample(0.5), 0.25); +} diff --git a/crates/argui-widget-gallery/src/pages/motion.rs b/crates/argui-widget-gallery/src/pages/motion.rs index a289de99..955c0514 100644 --- a/crates/argui-widget-gallery/src/pages/motion.rs +++ b/crates/argui-widget-gallery/src/pages/motion.rs @@ -1,24 +1,36 @@ +mod advanced; +mod implicit; +mod keyframes; +mod physics; + use argui::{ animation::{ - CubicBezier, Duration, Easing, FillMode, Interpolate, Keyframe, Keyframes, Spring, - SpringConfig, Timeline, Timing, + Direction, Duration, Easing, FillMode, Inertia, InertiaConfig, Interpolate, Iterations, + Keyframe, Keyframes, Spring, SpringConfig, Timeline, Timing, curves, }, - core::{Color, Transform2D, TransformOrigin}, + core::{Color, Transform2D}, paint::{Border, CornerRadii}, runtime::{Context, Render}, - ui::{AlignItems, Axes, Element, EventType, Overflow, Sides, UiEventKind, length, percent}, + ui::{ + AlignItems, Axes, Element, EventType, FlexWrap, Overflow, Sides, UiEventKind, length, + percent, + }, widgets::{Button, WidgetTheme, shadcn}, }; pub(crate) struct MotionDemo { spring: Spring, - spring_value: f32, + pub(super) spring_value: f32, + inertia: Inertia, + pub(super) inertia_value: f32, timeline: Timeline, - timeline_value: f32, + alternate: Timeline, + pub(super) timeline_value: f32, + pub(super) alternate_value: f32, timeline_from: f32, - target: f32, + pub(super) target: f32, pending: bool, - reduced_motion: bool, + pub(super) reduced_motion: bool, } impl Default for MotionDemo { @@ -26,8 +38,12 @@ impl Default for MotionDemo { Self { spring: spring(0.0, 0.0), spring_value: 0.0, + inertia: inertia(0.0), + inertia_value: 0.0, timeline: timeline(), + alternate: alternate_timeline(), timeline_value: 0.0, + alternate_value: 0.0, timeline_from: 0.0, target: 1.0, pending: true, @@ -52,112 +68,68 @@ impl MotionDemo { self.spring = spring(self.target, self.target); self.spring_value = self.target; self.timeline_value = self.target; - } - - fn stage(&self, label: &str, detail: &str, art: Element, theme: &WidgetTheme) -> Element { - Element::column([ - Element::column([ - super::super::app::text(label, 16.0, theme.foreground, 650), - super::super::app::text(detail, 13.0, theme.muted_foreground, 400), - ]) - .gap(2.0), - Element::row([art]) - .keyed(format!( - "motion-stage-{}", - label.to_lowercase().replace(' ', "-") - )) - .width(percent(1.0)) - .height(length(104.0)) - .padding(Sides::length(14.0)) - .align_items(AlignItems::CENTER) - .background(theme.muted) - .border(Border::all(1.0, theme.border)) - .radius(CornerRadii::all(12.0)) - .overflow(Axes { - x: Overflow::Hidden, - y: Overflow::Hidden, - }), - ]) - .gap(10.0) - .width(percent(1.0)) + self.alternate_value = self.target; + self.inertia_value = self.target * 150.0; + self.inertia = inertia(self.inertia_value); } fn content(&self, theme: &WidgetTheme) -> Element { - let spring = self.spring_value; - let tween = self.timeline_value; - let spring_square = Element::container([]) - .keyed("motion-spring-square") - .width(length(58.0)) - .height(length(58.0)) - .background(theme.primary) - .radius(CornerRadii::all(10.0 + spring.clamp(0.0, 1.0) * 18.0)) - .transform( - Transform2D::IDENTITY - .translate(spring * 150.0, 0.0) - .rotate(spring * 1.15), - ) - .transform_origin(TransformOrigin::CENTER); - let morph_color = - Color::srgb(0.18, 0.48, 0.98).interpolate(Color::srgb(0.94, 0.28, 0.58), tween); - let morph = Element::container([]) - .keyed("motion-morph") - .width(length(212.0)) - .height(length(76.0)) - .background(morph_color) - .radius(CornerRadii::all(8.0 + tween * 30.0)) - .transform( - Transform2D::IDENTITY - .scale((62.0 + tween * 150.0) / 212.0, (48.0 + tween * 28.0) / 76.0), - ) - .transform_origin(TransformOrigin::CENTER); - let transforms = Element::row([ - square( - "motion-translate", - theme.primary, - Transform2D::IDENTITY.translate(tween * 34.0, 0.0), - ), - square( - "motion-rotate", - Color::srgb(0.55, 0.30, 0.96), - Transform2D::IDENTITY.rotate(tween * std::f32::consts::PI), - ), - square( - "motion-scale", - Color::srgb(0.12, 0.72, 0.62), - Transform2D::IDENTITY.scale(0.62 + tween * 0.5, 0.62 + tween * 0.5), + let controls = Element::row([ + Button::new("motion-replay", "Run all animations", theme.button()).build(), + super::super::app::text( + if self.reduced_motion { + "Reduced motion: every example snaps to its destination" + } else { + "20 live examples · interrupt at any time to test retargeting" + }, + 13.0, + theme.muted_foreground, + 450, ), ]) - .gap(24.0) + .gap(12.0) + .flex_wrap(FlexWrap::Wrap) .align_items(AlignItems::CENTER); + Element::column([ - Button::new("motion-replay", "Run all animations", theme.button()).build(), - self.stage( - "Spring physics", - "Retargeting keeps velocity, including the overshoot.", - spring_square, + controls, + section( + "Implicit animation", + "Change the target; Argui retains the presented value and handles the transition.", + implicit::cards(self, theme), + theme, + ), + section( + "Keyframes & orchestration", + "Typed values, holds, steps, alternate playback and stagger share one clock.", + keyframes::cards(self, theme), theme, ), - self.stage( - "Resize, radius and Oklab color", - "Layout and paint values share one typed timeline.", - morph, + section( + "Physics", + "Analytical springs and bounded inertia remain stable across uneven frames.", + physics::cards(self, theme), theme, ), - self.stage( - "Transform composition", - "Translate, rotate and scale remain independent.", - transforms, + section( + "Composition & rendering", + "Perceptual color, additive tracks and custom curves reach the compositor directly.", + advanced::cards(self, theme), theme, ), ]) - .gap(18.0) + .gap(28.0) } } impl Render for MotionDemo { fn wants_animation_frame(&self) -> bool { !self.reduced_motion - && (self.pending || self.spring.is_active() || self.timeline.needs_frame()) + && (self.pending + || self.spring.is_active() + || self.inertia.is_active() + || self.timeline.needs_frame() + || self.alternate.needs_frame()) } fn animation_frame(&mut self, frame: argui::animation::Frame, cx: &mut Context) { @@ -165,8 +137,12 @@ impl Render for MotionDemo { if self.pending { self.pending = false; self.spring.retarget(self.target); + let velocity = if self.target > 0.5 { 720.0 } else { -720.0 }; + self.inertia.launch(self.inertia_value, velocity); self.timeline = timeline(); self.timeline.restart(frame.now); + self.alternate = alternate_timeline(); + self.alternate.restart(frame.now); changed = true; } let elapsed = frame.elapsed.min(Duration::from_millis(34)); @@ -174,11 +150,19 @@ impl Render for MotionDemo { self.spring_value = self.spring.value(); changed = true; } + if self.inertia.advance(elapsed) { + self.inertia_value = self.inertia.value(); + changed = true; + } if let Some(progress) = self.timeline.sample(frame.now).value { let next = self.timeline_from.interpolate(self.target, progress); changed |= next != self.timeline_value; self.timeline_value = next; } + if let Some(progress) = self.alternate.sample(frame.now).value { + changed |= progress != self.alternate_value; + self.alternate_value = progress; + } if changed { cx.notify(); } @@ -196,7 +180,7 @@ impl Render for MotionDemo { let theme = themes.resolve(cx.environment().color_scheme); super::preview( "Animation laboratory", - "One replay combines spring physics, layout interpolation, Oklab color and composed transforms. Reduced motion snaps every track to its destination.", + "From one-line implicit transitions to keyframes, orchestration, physics and custom curves. Every card uses the public Argui API.", self.content(theme), theme, ) @@ -211,7 +195,67 @@ impl Render for MotionDemo { } } -fn square(key: &str, color: Color, transform: Transform2D) -> Element { +/// Wraps one live example in the shared animation-gallery card treatment. +pub(super) fn card( + key: &'static str, + label: &'static str, + detail: &'static str, + art: Element, + theme: &WidgetTheme, +) -> Element { + Element::column([ + Element::column([ + super::super::app::text(label, 15.0, theme.foreground, 650), + super::super::app::text(detail, 12.0, theme.muted_foreground, 400), + ]) + .gap(2.0), + Element::row([art]) + .keyed(format!("motion-stage-{key}")) + .width(percent(1.0)) + .height(length(112.0)) + .padding(Sides::length(14.0)) + .align_items(AlignItems::CENTER) + .background(theme.muted) + .border(Border::all(1.0, theme.border)) + .radius(CornerRadii::all(12.0)) + .overflow(Axes { + x: Overflow::Hidden, + y: Overflow::Hidden, + }), + ]) + .keyed(format!("motion-card-{key}")) + .width(length(300.0)) + .grow(1.0) + .gap(10.0) + .padding(Sides::length(14.0)) + .background(theme.card) + .border(Border::all(1.0, theme.border)) + .radius(CornerRadii::all(14.0)) +} + +/// Groups related animation cards below a titled introduction. +fn section( + title: &'static str, + detail: &'static str, + cards: Vec, + theme: &WidgetTheme, +) -> Element { + Element::column([ + Element::column([ + super::super::app::text(title, 19.0, theme.foreground, 720), + super::super::app::text(detail, 13.0, theme.muted_foreground, 400), + ]) + .gap(3.0), + Element::row(cards) + .width(percent(1.0)) + .gap(12.0) + .flex_wrap(FlexWrap::Wrap), + ]) + .gap(12.0) +} + +/// Builds the shared transformed square used by several motion examples. +pub(super) fn square(key: &str, color: Color, transform: Transform2D) -> Element { Element::container([]) .keyed(key) .width(length(48.0)) @@ -219,7 +263,6 @@ fn square(key: &str, color: Color, transform: Transform2D) -> Element { .background(color) .radius(CornerRadii::all(10.0)) .transform(transform) - .transform_origin(TransformOrigin::CENTER) } fn spring(value: f32, target: f32) -> Spring { @@ -238,13 +281,25 @@ fn spring(value: f32, target: f32) -> Spring { .expect("the gallery spring is physical") } +/// Creates the bounded inertia simulation used by the gallery. +fn inertia(value: f32) -> Inertia { + Inertia::new( + value, + 0.0, + InertiaConfig { + bounds: Some((0.0, 150.0)), + ..InertiaConfig::default() + }, + ) + .expect("the gallery inertia is bounded by valid values") +} + fn timeline() -> Timeline { - let easing = Easing::CubicBezier( - CubicBezier::new(0.22, 1.0, 0.36, 1.0).expect("the motion curve is valid"), - ); Timeline::new( Keyframes::new([ - Keyframe::new(0.0, 0.0).easing(easing), + Keyframe::new(0.0, 0.0).easing(curves::EASE_OUT), + Keyframe::new(0.42, 0.68).easing(curves::EASE_IN_OUT), + Keyframe::new(0.72, 0.86).easing(curves::DECELERATE), Keyframe::new(1.0, 1.0), ]) .expect("motion keyframes are ordered"), @@ -252,3 +307,19 @@ fn timeline() -> Timeline { ) .expect("the motion timeline is valid") } + +/// Creates the two-pass timeline used to demonstrate alternate direction. +fn alternate_timeline() -> Timeline { + Timeline::new( + Keyframes::new([ + Keyframe::new(0.0, 0.0).easing(Easing::curve(|value| value * value)), + Keyframe::new(1.0, 1.0), + ]) + .expect("alternate keyframes are ordered"), + Timing::new(Duration::from_millis(430)) + .iterations(Iterations::Finite(2.0)) + .direction(Direction::Alternate) + .fill(FillMode::Forwards), + ) + .expect("the alternate timeline is valid") +} diff --git a/crates/argui-widget-gallery/src/pages/motion/advanced.rs b/crates/argui-widget-gallery/src/pages/motion/advanced.rs new file mode 100644 index 00000000..938cfceb --- /dev/null +++ b/crates/argui-widget-gallery/src/pages/motion/advanced.rs @@ -0,0 +1,105 @@ +use argui::{ + animation::{Composition, Contribution, Curve, Interpolate, compose}, + core::{Color, Transform2D}, + paint::{CornerRadii, LayerStyle, Shadow}, + ui::{AlignItems, Element, length}, + widgets::WidgetTheme, +}; + +use super::{MotionDemo, card, square}; + +/// Builds the three curve, composition and rendering gallery cards. +pub(super) fn cards(demo: &MotionDemo, theme: &WidgetTheme) -> Vec { + let progress = demo.timeline_value.clamp(0.0, 1.0); + let mut contributions = [ + Contribution::replace(progress * 88.0, 0, 0), + Contribution { + value: demo.alternate_value * 42.0, + composition: Composition::Add, + priority: 10, + order: 1, + completed_iterations: 0, + }, + ]; + let composed = compose(0.0, &mut contributions); + let transforms = Element::row([ + square( + "motion-translate", + theme.primary, + Transform2D::IDENTITY.translate(composed, 0.0), + ), + square( + "motion-rotate", + Color::srgb(0.55, 0.30, 0.96), + Transform2D::IDENTITY.rotate(progress * std::f32::consts::PI), + ), + square( + "motion-scale", + Color::srgb(0.12, 0.72, 0.62), + Transform2D::IDENTITY.scale(0.62 + progress * 0.5, 0.62 + progress * 0.5), + ), + ]) + .gap(14.0) + .align_items(AlignItems::CENTER); + + let custom = Anticipate.sample(progress).clamp(-0.2, 1.2); + let custom_curve = Element::container([]) + .width(length(54.0)) + .height(length(54.0)) + .background(Color::srgb(0.93, 0.32, 0.56)) + .radius(CornerRadii::all(16.0)) + .transform( + Transform2D::IDENTITY + .translate(custom * 138.0, 0.0) + .rotate(custom * 1.4), + ); + + let glow_color = theme + .primary + .interpolate(Color::srgb(0.93, 0.32, 0.56), progress); + let compositor = Element::container([]) + .width(length(118.0)) + .height(length(64.0)) + .background(glow_color) + .radius(CornerRadii::all(14.0 + progress * 18.0)) + .opacity(0.35 + progress * 0.65) + .layer(LayerStyle::new(Default::default()).shadow(Shadow::glow( + 8.0 + progress * 26.0, + glow_color.with_alpha(0.25 + progress * 0.35), + ))) + .transform(Transform2D::IDENTITY.scale(0.82 + progress * 0.18, 0.82 + progress * 0.18)); + + vec![ + card( + "composition", + "Additive composition", + "Replace and additive tracks resolve by stable priority.", + transforms, + theme, + ), + card( + "custom-curve", + "User-defined Curve", + "A tiny trait implementation adds anticipation and overshoot.", + custom_curve, + theme, + ), + card( + "compositor", + "Layer & compositor", + "Opacity, transform, Oklab color and glow stay synchronized.", + compositor, + theme, + ), + ] +} + +struct Anticipate; + +impl Curve for Anticipate { + fn sample(&self, progress: f32) -> f32 { + let progress = progress.clamp(0.0, 1.0); + let tension = 1.7; + progress * progress * ((tension + 1.0) * progress - tension) + } +} diff --git a/crates/argui-widget-gallery/src/pages/motion/implicit.rs b/crates/argui-widget-gallery/src/pages/motion/implicit.rs new file mode 100644 index 00000000..79b264c2 --- /dev/null +++ b/crates/argui-widget-gallery/src/pages/motion/implicit.rs @@ -0,0 +1,162 @@ +use argui::{ + animation::{Duration, curves}, + core::{Color, Rect, Transform2D}, + paint::{Border, CornerRadii, LayerStyle, Shadow}, + ui::{AlignItems, Element, Sides, length}, + widgets::{AnimatedContainer, AnimatedOpacity, WidgetTheme}, +}; + +use super::{MotionDemo, card}; + +/// Builds the seven implicit-animation gallery cards for the current state. +pub(super) fn cards(demo: &MotionDemo, theme: &WidgetTheme) -> Vec { + let expanded = demo.target > 0.5; + let primary = theme.primary; + let accent = Color::srgb(0.93, 0.32, 0.56); + let fade = AnimatedOpacity::new( + "motion-implicit-opacity", + if expanded { 1.0 } else { 0.12 }, + Element::container([]) + .width(length(76.0)) + .height(length(76.0)) + .background(primary) + .radius(CornerRadii::all(22.0)), + ) + .duration(Duration::from_millis(260)) + .curve(curves::EASE_OUT) + .build(); + + let size = AnimatedContainer::new("motion-implicit-size", []) + .width(length(if expanded { 190.0 } else { 72.0 })) + .height(length(if expanded { 76.0 } else { 46.0 })) + .background(primary) + .radius(CornerRadii::all(18.0)) + .duration(Duration::from_millis(420)) + .curve(curves::EMPHASIZED) + .build(); + + let color_radius = AnimatedContainer::new("motion-implicit-color", []) + .width(length(92.0)) + .height(length(72.0)) + .background(if expanded { accent } else { primary }) + .radius(CornerRadii::all(if expanded { 36.0 } else { 8.0 })) + .duration(Duration::from_millis(380)) + .curve(curves::STANDARD) + .build(); + + let dots = Element::row((0..3).map(|index| { + Element::container([]) + .width(length(12.0)) + .height(length(12.0)) + .background(if index == 1 { accent } else { primary }) + .radius(CornerRadii::all(999.0)) + })) + .align_items(AlignItems::CENTER); + let spacing = AnimatedContainer::from_element(dots.keyed("motion-implicit-spacing")) + .padding(Sides::length(if expanded { 20.0 } else { 6.0 })) + .gap(if expanded { 24.0 } else { 5.0 }) + .background(theme.card) + .border(Border::all(1.0, theme.border)) + .radius(CornerRadii::all(999.0)) + .duration(Duration::from_millis(360)) + .curve(curves::EASE_IN_OUT) + .build(); + + let transform = AnimatedContainer::new("motion-implicit-transform", []) + .width(length(62.0)) + .height(length(62.0)) + .background(primary) + .radius(CornerRadii::all(14.0)) + .transform(if expanded { + Transform2D::IDENTITY + .translate(130.0, 0.0) + .rotate(std::f32::consts::PI * 0.75) + .scale(1.12, 1.12) + } else { + Transform2D::IDENTITY + }) + .duration(Duration::from_millis(520)) + .curve(curves::BACK_OUT) + .build(); + + let layer = LayerStyle::new(Rect::default()).shadow( + Shadow::drop( + [0.0, if expanded { 12.0 } else { 2.0 }], + if expanded { 26.0 } else { 4.0 }, + primary.with_alpha(if expanded { 0.42 } else { 0.12 }), + ) + .spread(if expanded { 4.0 } else { 0.0 }), + ); + let depth = AnimatedContainer::new("motion-implicit-depth", []) + .width(length(112.0)) + .height(length(66.0)) + .background(theme.card) + .border(Border::all(if expanded { 3.0 } else { 1.0 }, primary)) + .radius(CornerRadii::all(if expanded { 24.0 } else { 10.0 })) + .layer(layer) + .duration(Duration::from_millis(420)) + .curve(curves::DECELERATE) + .build(); + + let interrupted = AnimatedContainer::new("motion-implicit-retarget", []) + .width(length(54.0)) + .height(length(54.0)) + .background(accent) + .radius(CornerRadii::all(16.0)) + .transform(Transform2D::IDENTITY.translate(if expanded { 145.0 } else { 0.0 }, 0.0)) + .duration(Duration::from_millis(700)) + .curve(curves::EASE_IN_OUT) + .build(); + + vec![ + card( + "implicit-opacity", + "AnimatedOpacity", + "One target fades the complete child subtree.", + fade, + theme, + ), + card( + "implicit-size", + "Animated size", + "Compatible layout dimensions interpolate automatically.", + size, + theme, + ), + card( + "implicit-color", + "Color & radius", + "Oklab color and four radii share one transition.", + color_radius, + theme, + ), + card( + "implicit-spacing", + "Padding & gap", + "Layout spacing changes without manual frame math.", + spacing, + theme, + ), + card( + "implicit-transform", + "Composed transform", + "Translate, rotate and scale remain compositor-friendly.", + transform, + theme, + ), + card( + "implicit-depth", + "Border & shadow", + "Paint and layer properties stay synchronized.", + depth, + theme, + ), + card( + "implicit-retarget", + "Interruptible retargeting", + "Replay mid-flight: motion continues from the visible value.", + interrupted, + theme, + ), + ] +} diff --git a/crates/argui-widget-gallery/src/pages/motion/keyframes.rs b/crates/argui-widget-gallery/src/pages/motion/keyframes.rs new file mode 100644 index 00000000..1c729ae5 --- /dev/null +++ b/crates/argui-widget-gallery/src/pages/motion/keyframes.rs @@ -0,0 +1,169 @@ +use std::sync::OnceLock; + +use argui::{ + animation::{ + CueId, Duration, Easing, Interpolate, Keyframe, Keyframes, Schedule, StepPosition, Steps, + curves, + }, + core::{Color, Transform2D}, + paint::CornerRadii, + ui::{AlignItems, Element, length}, + widgets::WidgetTheme, +}; + +use super::{MotionDemo, card, square}; + +/// Builds the six keyframe and orchestration gallery cards. +pub(super) fn cards(demo: &MotionDemo, theme: &WidgetTheme) -> Vec { + let progress = demo.timeline_value.clamp(0.0, 1.0); + let alternate = demo.alternate_value.clamp(0.0, 1.0); + let accent = Color::srgb(0.93, 0.32, 0.56); + let morph_color = Color::srgb(0.18, 0.48, 0.98).interpolate(accent, progress); + let morph = Element::container([]) + .keyed("motion-morph") + .width(length(212.0)) + .height(length(76.0)) + .background(morph_color) + .radius(CornerRadii::all(8.0 + progress * 30.0)) + .transform(Transform2D::IDENTITY.scale( + (62.0 + progress * 150.0) / 212.0, + (48.0 + progress * 28.0) / 76.0, + )); + + let multi_stop = Element::row((0..4).map(|index| { + let local = ((progress * 4.0) - index as f32).clamp(0.0, 1.0); + Element::container([]) + .width(length(32.0)) + .height(length(32.0 + local * 44.0)) + .background(theme.primary.interpolate(accent, local)) + .radius(CornerRadii::all(8.0 + local * 12.0)) + })) + .gap(10.0) + .align_items(AlignItems::CENTER); + + let held = held_keyframes().sample(progress); + let hold = Element::row([ + square( + "motion-hold-left", + theme.primary.with_alpha(1.0 - held), + Transform2D::IDENTITY.translate(held * 110.0, 0.0), + ), + Element::container([]) + .width(length(8.0)) + .height(length(62.0)) + .background(theme.border) + .radius(CornerRadii::all(999.0)), + ]) + .gap(10.0) + .align_items(AlignItems::CENTER); + + let stepped = + Easing::Steps(Steps::new(5, StepPosition::JumpEnd).expect("five steps form a valid curve")) + .sample(progress); + let steps = Element::row((0..5).map(|index| { + let active = stepped * 5.0 > index as f32; + Element::container([]) + .width(length(24.0)) + .height(length(24.0 + index as f32 * 9.0)) + .background(if active { theme.primary } else { theme.border }) + .radius(CornerRadii::all(7.0)) + })) + .gap(8.0) + .align_items(AlignItems::CENTER); + + let alternate_art = Element::row([ + square( + "motion-alternate-a", + theme.primary, + Transform2D::IDENTITY.translate(alternate * 112.0, 0.0), + ), + square( + "motion-alternate-b", + accent, + Transform2D::IDENTITY.translate((1.0 - alternate) * 24.0, 0.0), + ), + ]) + .gap(10.0); + + let elapsed = Duration::from_nanos((progress * 1_000_000_000.0) as u64); + let stagger = Element::row((0..5).map(|index| { + let cue = stagger_schedule() + .cue(CueId::from_index(index)) + .expect("the gallery schedule contains five cues"); + let local = cue.progress(elapsed); + Element::container([]) + .width(length(26.0)) + .height(length(26.0)) + .background(theme.primary.interpolate(accent, local)) + .radius(CornerRadii::all(13.0)) + .transform(Transform2D::IDENTITY.translate(0.0, -local * 34.0)) + })) + .gap(9.0) + .align_items(AlignItems::CENTER); + + vec![ + card( + "typed-morph", + "Typed multi-property timeline", + "One f32 timeline drives size, radius and Oklab color.", + morph, + theme, + ), + card( + "multi-stop", + "Multiple keyframes", + "Four phases reveal distinct intermediate poses.", + multi_stop, + theme, + ), + card( + "holds", + "Held keyframes", + "The value waits, then jumps without polling while idle.", + hold, + theme, + ), + card( + "steps", + "Step easing", + "Discrete progress is useful for meters and sprite-like motion.", + steps, + theme, + ), + card( + "alternate", + "Alternate direction", + "Two iterations reverse automatically on the same timeline.", + alternate_art, + theme, + ), + card( + "stagger", + "Stagger schedule", + "Five cues overlap with deterministic phase offsets.", + stagger, + theme, + ), + ] +} + +/// Returns the lazily constructed keyframes used by the hold example. +fn held_keyframes() -> &'static Keyframes { + static FRAMES: OnceLock> = OnceLock::new(); + FRAMES.get_or_init(|| { + Keyframes::new([ + Keyframe::new(0.0, 0.0).hold(), + Keyframe::new(0.58, 0.0).easing(curves::BACK_OUT), + Keyframe::new(1.0, 1.0), + ]) + .expect("held gallery keyframes cover the normalized interval") + }) +} + +/// Returns the lazily constructed five-item stagger schedule. +fn stagger_schedule() -> &'static Schedule { + static SCHEDULE: OnceLock = OnceLock::new(); + SCHEDULE.get_or_init(|| { + Schedule::stagger(5, Duration::from_millis(520), Duration::from_millis(110)) + }) +} diff --git a/crates/argui-widget-gallery/src/pages/motion/physics.rs b/crates/argui-widget-gallery/src/pages/motion/physics.rs new file mode 100644 index 00000000..a8f7377a --- /dev/null +++ b/crates/argui-widget-gallery/src/pages/motion/physics.rs @@ -0,0 +1,91 @@ +use argui::{ + core::Transform2D, + paint::CornerRadii, + ui::{Element, length}, + widgets::WidgetTheme, +}; + +use super::{MotionDemo, card}; + +/// Builds the four physics-driven animation gallery cards. +pub(super) fn cards(demo: &MotionDemo, theme: &WidgetTheme) -> Vec { + let spring = demo.spring_value; + let clamped = spring.clamp(0.0, 1.0); + let spring_square = Element::container([]) + .keyed("motion-spring-square") + .width(length(58.0)) + .height(length(58.0)) + .background(theme.primary) + .radius(CornerRadii::all(10.0 + clamped * 18.0)) + .transform( + Transform2D::IDENTITY + .translate(spring * 150.0, 0.0) + .rotate(spring * 1.15), + ); + + let inertia = Element::container([]) + .keyed("motion-inertia") + .width(length(42.0)) + .height(length(42.0)) + .background(theme.primary) + .radius(CornerRadii::all(999.0)) + .transform(Transform2D::IDENTITY.translate(demo.inertia_value, 0.0)); + + let tail = (spring - clamped).abs().min(0.35); + let velocity = Element::row((0..3).map(|index| { + let lag = index as f32 * 12.0; + Element::container([]) + .width(length(38.0 - index as f32 * 6.0)) + .height(length(38.0 - index as f32 * 6.0)) + .background(theme.primary.with_alpha(1.0 - index as f32 * 0.25)) + .radius(CornerRadii::all(999.0)) + .transform( + Transform2D::IDENTITY + .translate(clamped * 112.0 - lag - tail * index as f32 * 34.0, 0.0), + ) + })); + + let squash = (1.0 - (spring - clamped).abs() * 0.9).clamp(0.72, 1.18); + let stretch = (2.0 - squash).clamp(0.82, 1.28); + let squash_art = Element::container([]) + .width(length(82.0)) + .height(length(58.0)) + .background(theme.primary) + .radius(CornerRadii::all(22.0)) + .transform( + Transform2D::IDENTITY + .translate(clamped * 108.0, 0.0) + .scale(stretch, squash), + ); + + vec![ + card( + "spring", + "Spring physics", + "Analytical motion overshoots and settles exactly.", + spring_square, + theme, + ), + card( + "inertia", + "Bounded inertia", + "Decay crosses a bound and hands velocity to a bounce spring.", + inertia, + theme, + ), + card( + "velocity", + "Velocity-preserving retarget", + "Interrupt the run: the new spring keeps current momentum.", + velocity, + theme, + ), + card( + "squash", + "Squash & stretch", + "Physical overshoot becomes a restrained secondary deformation.", + squash_art, + theme, + ), + ] +} diff --git a/crates/argui-widget-gallery/tests/pages/motion.rs b/crates/argui-widget-gallery/tests/pages/motion.rs index 81401245..63f0c93e 100644 --- a/crates/argui-widget-gallery/tests/pages/motion.rs +++ b/crates/argui-widget-gallery/tests/pages/motion.rs @@ -20,7 +20,34 @@ fn animation_lab_replays_spring_layout_color_and_transform_tracks() { }; click("nav::motion"); let initial = gallery.render(Default::default()).unwrap(); - assert!(contains_text(&initial, "Spring physics")); + for label in [ + "AnimatedOpacity", + "Animated size", + "Color & radius", + "Padding & gap", + "Composed transform", + "Border & shadow", + "Interruptible retargeting", + "Typed multi-property timeline", + "Multiple keyframes", + "Held keyframes", + "Step easing", + "Alternate direction", + "Stagger schedule", + "Spring physics", + "Bounded inertia", + "Velocity-preserving retarget", + "Squash & stretch", + "Additive composition", + "User-defined Curve", + "Layer & compositor", + ] { + assert!( + contains_text(&initial, label), + "missing motion example: {label}" + ); + } + assert_eq!(count_cards(&initial), 20); let initial_spring = keyed(&initial, "motion-spring-square").unwrap().transform; let initial_scale = keyed(&initial, "motion-morph").unwrap().transform.scale.x; @@ -58,6 +85,16 @@ fn animation_lab_replays_spring_layout_color_and_transform_tracks() { assert!(keyed(&reversed, "motion-morph").unwrap().transform.scale.x < scale); } +/// Counts animation example cards recursively in the rendered tree. +fn count_cards(element: &Element) -> usize { + usize::from( + element + .key + .as_deref() + .is_some_and(|key| key.starts_with("motion-card-")), + ) + element.children.iter().map(count_cards).sum::() +} + #[test] fn reduced_motion_snaps_the_animation_lab_to_its_destination() { let gallery = Entity::new(WidgetGallery::default()); diff --git a/crates/argui-widgets/Cargo.toml b/crates/argui-widgets/Cargo.toml index faa227d8..830328e5 100644 --- a/crates/argui-widgets/Cargo.toml +++ b/crates/argui-widgets/Cargo.toml @@ -13,6 +13,7 @@ description = "Feature-gated accessible widgets for Argui interfaces" default = [] updater = ["dialog", "button", "progress", "dep:argui-updater"] all = [ + "implicit-animation", "animated-text", "accordion", "alert", @@ -87,6 +88,7 @@ all = [ "typography", "vlist", ] +implicit-animation = [] toggle = ["button"] animated-text = ["dep:argui-runtime", "dep:unicode-segmentation"] toggle-group = ["toggle"] diff --git a/crates/argui-widgets/src/implicit_animation.rs b/crates/argui-widgets/src/implicit_animation.rs new file mode 100644 index 00000000..9d68e4cb --- /dev/null +++ b/crates/argui-widgets/src/implicit_animation.rs @@ -0,0 +1,274 @@ +use argui_animation::{Curve, Duration, Easing, Transition, Tween, curves}; +use argui_core::Transform2D; +use argui_paint::{Border, Color, CornerRadii, Fill, LayerStyle}; +use argui_ui::{ + Dimension, Element, LengthPercentage, LengthPercentageAuto, PropertyKey, Sides, + StyleTransition, TransitionRule, +}; + +const DEFAULT_OPACITY_DURATION: Duration = Duration::from_millis(200); +const DEFAULT_CONTAINER_DURATION: Duration = Duration::from_millis(300); + +/// Implicitly transitions the group opacity of one element and its descendants. +/// +/// The first build presents `opacity` immediately. Later builds with the same +/// retained identity animate from the currently presented opacity to the new +/// target. An opacity of zero does not disable pointer or semantic interaction; +/// configure those policies on `child` when hidden content must be inert. +#[derive(Clone, Debug)] +pub struct AnimatedOpacity { + child: Element, + opacity: f32, + tween: Tween, +} + +impl AnimatedOpacity { + /// Creates an implicitly animated group opacity. + /// + /// * `key` — stable identity retained across target changes. + /// * `opacity` — target group opacity, clamped to `0.0..=1.0` when built. + /// * `child` — element whose complete rendered subtree fades. + #[must_use] + pub fn new(key: impl Into, opacity: f32, child: Element) -> Self { + Self { + child: child.keyed(key), + opacity, + tween: Tween::new(DEFAULT_OPACITY_DURATION).easing(curves::EASE_OUT), + } + } + + /// Sets the interpolation duration. + /// + /// * `duration` — active time between the presented and target opacity. + #[must_use] + pub const fn duration(mut self, duration: Duration) -> Self { + self.tween.duration = duration; + self + } + + /// Sets the delay before interpolation begins. + /// + /// * `delay` — time for which the currently presented opacity is retained. + #[must_use] + pub const fn delay(mut self, delay: Duration) -> Self { + self.tween.delay = delay; + self + } + + /// Sets the easing curve used between the presented and target opacity. + /// + /// * `curve` — built-in easing or user-defined curve sampled by the transition. + #[must_use] + pub fn curve(mut self, curve: impl Curve) -> Self { + self.tween.easing = Easing::curve(curve); + self + } + + /// Replaces the complete tween used when the opacity target changes. + /// + /// * `tween` — duration, delay and easing policy for the transition. + #[must_use] + pub fn tween(mut self, tween: Tween) -> Self { + self.tween = tween; + self + } + + /// Builds the decorated element with its retained opacity transition. + #[must_use] + pub fn build(self) -> Element { + let immediate = Transition::tween(Tween::new(Duration::ZERO)); + let opacity = Transition::tween(self.tween); + self.child.opacity(self.opacity).transition( + StyleTransition::new(immediate) + .rule(TransitionRule::new(opacity).property(PropertyKey::LayerOpacity)), + ) + } +} + +/// Container whose compatible visual and layout properties animate after changes. +/// +/// The first build presents its authored values immediately. Retained rebuilds +/// interpolate scalar properties such as size, padding, colors, radii, transforms, +/// shadows and group opacity. Changes between incompatible representations, such +/// as pixels and percentages or different gradient kinds, switch discretely. +#[derive(Clone, Debug)] +pub struct AnimatedContainer { + element: Element, + tween: Tween, +} + +impl AnimatedContainer { + /// Creates an animated container retaining `children` under `key`. + /// + /// * `key` — stable identity retained across property changes. + /// * `children` — elements laid out inside the container. + #[must_use] + pub fn new(key: impl Into, children: impl IntoIterator) -> Self { + Self { + element: Element::container(children).keyed(key), + tween: Tween::new(DEFAULT_CONTAINER_DURATION).easing(curves::STANDARD), + } + } + + /// Turns an existing element into an implicitly animated container. + /// + /// `element` keeps its kind, children, identity and authored styles. The + /// animation policy installed by [`AnimatedContainer::build`] replaces any + /// earlier style-transition policy on that element. + #[must_use] + pub fn from_element(element: Element) -> Self { + Self { + element, + tween: Tween::new(DEFAULT_CONTAINER_DURATION).easing(curves::STANDARD), + } + } + + /// Applies arbitrary element configuration before installing the transition. + /// + /// * `configure` — function receiving and returning the underlying element. + #[must_use] + pub fn configure(mut self, configure: impl FnOnce(Element) -> Element) -> Self { + self.element = configure(self.element); + self + } + + /// Sets the target preferred width. + #[must_use] + pub fn width(mut self, width: Dimension) -> Self { + self.element = self.element.width(width); + self + } + + /// Sets the target preferred height. + #[must_use] + pub fn height(mut self, height: Dimension) -> Self { + self.element = self.element.height(height); + self + } + + /// Sets the target minimum width. + #[must_use] + pub fn min_width(mut self, width: LengthPercentageAuto) -> Self { + self.element = self.element.min_width(width); + self + } + + /// Sets the target minimum height. + #[must_use] + pub fn min_height(mut self, height: LengthPercentageAuto) -> Self { + self.element = self.element.min_height(height); + self + } + + /// Sets the target maximum width. + #[must_use] + pub fn max_width(mut self, width: LengthPercentageAuto) -> Self { + self.element = self.element.max_width(width); + self + } + + /// Sets the target maximum height. + #[must_use] + pub fn max_height(mut self, height: LengthPercentageAuto) -> Self { + self.element = self.element.max_height(height); + self + } + + /// Sets the target inner spacing on each side. + #[must_use] + pub fn padding(mut self, padding: Sides) -> Self { + self.element = self.element.padding(padding); + self + } + + /// Sets the target row and column gap in logical pixels. + #[must_use] + pub fn gap(mut self, gap: f32) -> Self { + self.element = self.element.gap(gap); + self + } + + /// Sets the target solid background color. + #[must_use] + pub fn background(mut self, color: Color) -> Self { + self.element = self.element.background(color); + self + } + + /// Sets the target background fill. + #[must_use] + pub fn fill(mut self, fill: Fill) -> Self { + self.element = self.element.fill(fill); + self + } + + /// Sets the target border. + #[must_use] + pub fn border(mut self, border: Border) -> Self { + self.element = self.element.border(border); + self + } + + /// Sets the target corner radii. + #[must_use] + pub fn radius(mut self, radii: CornerRadii) -> Self { + self.element = self.element.radius(radii); + self + } + + /// Sets the target transform. + #[must_use] + pub fn transform(mut self, transform: Transform2D) -> Self { + self.element = self.element.transform(transform); + self + } + + /// Sets the target group opacity for the container and its descendants. + #[must_use] + pub fn opacity(mut self, opacity: f32) -> Self { + self.element = self.element.opacity(opacity); + self + } + + /// Sets the target compositing layer, including masks, shadows and effects. + #[must_use] + pub fn layer(mut self, layer: LayerStyle) -> Self { + self.element = self.element.layer(layer); + self + } + + /// Sets the interpolation duration for compatible property changes. + #[must_use] + pub const fn duration(mut self, duration: Duration) -> Self { + self.tween.duration = duration; + self + } + + /// Sets the delay before compatible property changes begin interpolating. + #[must_use] + pub const fn delay(mut self, delay: Duration) -> Self { + self.tween.delay = delay; + self + } + + /// Sets the built-in or user-defined curve shared by compatible changes. + #[must_use] + pub fn curve(mut self, curve: impl Curve) -> Self { + self.tween.easing = Easing::curve(curve); + self + } + + /// Replaces the complete tween used by compatible property changes. + #[must_use] + pub fn tween(mut self, tween: Tween) -> Self { + self.tween = tween; + self + } + + /// Builds the container and installs its retained transition policy. + #[must_use] + pub fn build(self) -> Element { + self.element + .transition(StyleTransition::new(Transition::tween(self.tween))) + } +} diff --git a/crates/argui-widgets/src/lib.rs b/crates/argui-widgets/src/lib.rs index 8eb8b90a..d2bb3595 100644 --- a/crates/argui-widgets/src/lib.rs +++ b/crates/argui-widgets/src/lib.rs @@ -1,4 +1,8 @@ //! Optional, accessible widgets built from Argui engine primitives. +#[cfg(feature = "implicit-animation")] +mod implicit_animation; +#[cfg(feature = "implicit-animation")] +pub use implicit_animation::{AnimatedContainer, AnimatedOpacity}; #[cfg(feature = "animated-text")] mod animated_text; #[cfg(feature = "animated-text")] diff --git a/crates/argui-widgets/tests/implicit_animation.rs b/crates/argui-widgets/tests/implicit_animation.rs new file mode 100644 index 00000000..805ce76a --- /dev/null +++ b/crates/argui-widgets/tests/implicit_animation.rs @@ -0,0 +1,157 @@ +#![cfg(feature = "implicit-animation")] + +use argui_animation::{Duration, Time, Tween, curves}; +use argui_core::{Color, Rect, Transform2D}; +use argui_paint::{Border, CornerRadii, Fill, LayerStyle}; +use argui_ui::{Element, Sides, TreeUpdate, UiTree, length}; +use argui_widgets::{AnimatedContainer, AnimatedOpacity}; + +/// Returns the resolved group opacity for the root test element. +fn group_opacity(tree: &UiTree, element: &Element) -> f32 { + let node = tree.node_ids()[0]; + let layer = element.layer.as_deref().expect("animated group layer"); + tree.resolved_layer(node, element, layer).opacity +} + +#[test] +fn animated_opacity_snaps_on_mount_and_retargets_as_a_composite_change() { + let visible = AnimatedOpacity::new("fade", 1.0, Element::text("Hello")) + .duration(Duration::from_millis(200)) + .curve(curves::LINEAR) + .build(); + let mut tree = UiTree::new(visible.clone()); + assert_eq!(group_opacity(&tree, &visible), 1.0); + assert!(!tree.wants_animation_frame()); + + let hidden = AnimatedOpacity::new("fade", 0.0, Element::text("Hello")) + .duration(Duration::from_millis(200)) + .curve(curves::LINEAR) + .build(); + assert_eq!(tree.update(hidden.clone()), TreeUpdate::Paint); + assert_eq!(group_opacity(&tree, &hidden), 1.0); + assert!(tree.wants_animation_frame()); + tree.advance_animations(Time::from_nanos(1)); + assert_eq!( + tree.advance_animations(Time::from_nanos(100_000_001)), + TreeUpdate::Composite + ); + assert!((group_opacity(&tree, &hidden) - 0.5).abs() < 0.01); + + let visible_again = AnimatedOpacity::new("fade", 1.0, Element::text("Hello")) + .duration(Duration::from_millis(200)) + .curve(curves::LINEAR) + .build(); + tree.update(visible_again.clone()); + assert!((group_opacity(&tree, &visible_again) - 0.5).abs() < 0.01); +} + +#[test] +fn animated_opacity_clamps_targets_and_reduced_motion_finishes_immediately() { + let start = AnimatedOpacity::new("fade", -5.0, Element::text("Hello")).build(); + let mut tree = UiTree::new(start.clone()); + assert_eq!(group_opacity(&tree, &start), 0.0); + tree.set_reduced_motion(true); + + let target = AnimatedOpacity::new("fade", 5.0, Element::text("Hello")).build(); + tree.update(target.clone()); + assert_eq!(group_opacity(&tree, &target), 1.0); + assert!(!tree.wants_animation_frame()); +} + +#[test] +fn animated_container_interpolates_compatible_layout_and_paint_values() { + let start = AnimatedContainer::new("card", []) + .width(length(100.0)) + .height(length(40.0)) + .background(Color::BLACK) + .radius(CornerRadii::all(4.0)) + .duration(Duration::from_millis(200)) + .curve(curves::LINEAR) + .build(); + let mut tree = UiTree::new(start); + let target = AnimatedContainer::new("card", []) + .width(length(200.0)) + .height(length(80.0)) + .background(Color::WHITE) + .radius(CornerRadii::all(20.0)) + .duration(Duration::from_millis(200)) + .curve(curves::LINEAR) + .build(); + assert_eq!(tree.update(target.clone()), TreeUpdate::Layout); + tree.advance_animations(Time::from_nanos(1)); + tree.advance_animations(Time::from_nanos(100_000_001)); + + let node = tree.node_ids()[0]; + let layout = tree.resolved_layout_style(node, &target); + let quad = tree.resolved_quad(node, &target); + assert!((layout.size.width.value() - 150.0).abs() < 0.01); + assert!((layout.size.height.value() - 60.0).abs() < 0.01); + assert_eq!(quad.radii, CornerRadii::all(12.0)); + assert_eq!( + quad.background, + Some(argui_paint::Fill::Solid(Color::BLACK.mix( + Color::WHITE, + 0.5, + argui_core::ColorInterpolation::Oklab, + ))) + ); +} + +#[test] +fn animated_container_configures_existing_elements_and_switches_units_discretely() { + let row = Element::row([Element::text("A"), Element::text("B")]); + let start = AnimatedContainer::from_element(row.clone().keyed("row")) + .width(length(100.0)) + .configure(|element| element.gap(8.0)) + .build(); + let mut tree = UiTree::new(start); + let target = AnimatedContainer::from_element(row.keyed("row")) + .width(argui_ui::percent(0.5)) + .configure(|element| element.gap(16.0)) + .build(); + tree.update(target.clone()); + let node = tree.node_ids()[0]; + assert_eq!( + tree.resolved_layout_style(node, &target).size.width, + argui_ui::percent(0.5) + ); + assert!( + tree.wants_animation_frame(), + "the compatible gap still animates" + ); +} + +#[test] +fn implicit_animation_builders_expose_the_complete_surface_policy() { + let opacity = AnimatedOpacity::new("fade", 0.4, Element::text("Hello")) + .delay(Duration::from_millis(20)) + .duration(Duration::from_millis(180)) + .curve(|progress: f32| progress * progress) + .build(); + assert_eq!(opacity.key.as_deref(), Some("fade")); + assert_eq!(opacity.layer.as_deref().unwrap().opacity, 0.4); + + let layer = LayerStyle::new(Rect::default()).opacity(0.8); + let container = AnimatedContainer::new("surface", [Element::text("Body")]) + .min_width(length(40.0)) + .min_height(length(30.0)) + .max_width(length(400.0)) + .max_height(length(300.0)) + .padding(Sides::length(12.0)) + .fill(Fill::Solid(Color::BLACK)) + .border(Border::all(2.0, Color::WHITE)) + .transform(Transform2D::IDENTITY.translate(4.0, 6.0)) + .opacity(0.8) + .layer(layer) + .delay(Duration::from_millis(15)) + .tween(Tween::new(Duration::from_millis(240)).easing(curves::EASE_OUT)) + .build(); + assert_eq!(container.key.as_deref(), Some("surface")); + assert_eq!(container.style.min_size.width, length(40.0)); + assert_eq!(container.style.min_size.height, length(30.0)); + assert_eq!(container.style.max_size.width, length(400.0)); + assert_eq!(container.style.max_size.height, length(300.0)); + assert_eq!(container.style.padding, Sides::length(12.0)); + assert_eq!(container.transform.translation.x, 4.0); + assert_eq!(container.layer.as_deref().unwrap().opacity, 0.8); +} diff --git a/crates/argui/Cargo.toml b/crates/argui/Cargo.toml index f05fa2ba..05f84366 100644 --- a/crates/argui/Cargo.toml +++ b/crates/argui/Cargo.toml @@ -108,6 +108,7 @@ widget-dialog = ["argui-widgets/dialog"] widget-range = ["argui-widgets/range"] widget-slider = ["argui-widgets/slider"] widget-color-picker = ["argui-widgets/color-picker"] +widget-implicit-animation = ["argui-widgets/implicit-animation"] widget-animated-text = ["argui-widgets/animated-text"] widget-spinner = ["argui-widgets/spinner"] widget-text-selection = ["argui-widgets/text-selection"] diff --git a/docs/ui/animation.md b/docs/ui/animation.md index dc5657ac..8fbef7cb 100644 --- a/docs/ui/animation.md +++ b/docs/ui/animation.md @@ -4,6 +4,154 @@ native and WASM. The runtime supplies one monotonic frame clock; an idle application requests no animation frames. +Argui exposes four layers. Start with the first one and move down only when the +interaction needs more control: + +1. `AnimatedOpacity` and `AnimatedContainer` animate target changes implicitly. +2. `Motion` gives application code pause, retarget and spring control. +3. `Timeline` and `Keyframes` describe multi-stage playback. +4. `Spring`, `Decay`, `Inertia`, schedules and composition build physical or + coordinated systems. + +## Five-minute start + +Enable `widget-implicit-animation` on the `argui` facade, or +`implicit-animation` when depending on `argui-widgets` directly. + +```toml +argui = { version = "0.3", features = ["widget-implicit-animation"] } +``` + +### Fade a complete subtree + +Rebuild the same keyed element with a different target opacity. The first build +is immediate; every later change begins at the value currently on screen. + +```rust +use argui::{ + animation::{Duration, curves}, + ui::Element, + widgets::AnimatedOpacity, +}; + +fn details(visible: bool) -> Element { + AnimatedOpacity::new( + "details-fade", + if visible { 1.0 } else { 0.0 }, + Element::text("Saved locally"), + ) + .duration(Duration::from_millis(220)) + .curve(curves::EASE_OUT) + .build() +} +``` + +`AnimatedOpacity` uses group opacity, so the element and all descendants fade +together. Opacity does not change hit testing or semantics. Disable interaction +or hide semantics explicitly when invisible content must be inert. + +### Animate a container + +Compatible values animate together. This example changes layout, paint and +corner geometry without maintaining an animation controller in the model. + +```rust +use argui::{ + animation::{Duration, curves}, + core::Color, + paint::CornerRadii, + ui::{Element, length}, + widgets::AnimatedContainer, +}; + +fn project_card(expanded: bool) -> Element { + AnimatedContainer::new("project-card", [Element::text("Argui")]) + .width(length(if expanded { 320.0 } else { 180.0 })) + .height(length(if expanded { 150.0 } else { 72.0 })) + .background(if expanded { + Color::srgb(0.55, 0.30, 0.96) + } else { + Color::srgb(0.18, 0.48, 0.98) + }) + .radius(CornerRadii::all(if expanded { 28.0 } else { 12.0 })) + .duration(Duration::from_millis(420)) + .curve(curves::EMPHASIZED) + .build() +} +``` + +Pixels interpolate with pixels and percentages with percentages. Switching +between incompatible units, gradient kinds or other discrete representations +snaps safely instead of inventing an ambiguous interpolation. Use +`AnimatedContainer::from_element` or `configure` when row, column, grid or +advanced element configuration is required. + +## Curves + +The `curves` module includes `LINEAR`, `EASE_IN`, `EASE_OUT`, `EASE_IN_OUT`, +`STANDARD`, `EMPHASIZED`, `ACCELERATE`, `DECELERATE` and `BACK_OUT`. A custom +curve implements one small trait. Input is normalized; output may overshoot. + +```rust +use argui::{ + animation::{Curve, Easing}, + ui::Element, + widgets::AnimatedOpacity, +}; + +struct Anticipate; + +impl Curve for Anticipate { + fn sample(&self, progress: f32) -> f32 { + progress * progress * (2.7 * progress - 1.7) + } +} + +let easing = Easing::curve(Anticipate); +assert!(easing.sample(0.25) < 0.0); + +let _element = AnimatedOpacity::new("notice", 1.0, Element::text("Ready")) + .curve(Anticipate) + .build(); +``` + +Springs intentionally remain separate from curves. A spring carries velocity +and physical state, whereas a curve only transforms normalized progress. + +## What can animate implicitly + +| Family | Compatible values | Update cost | +| --- | --- | --- | +| Group opacity and transform | scalar opacity, `Transform2D` components | composite | +| Surface paint | solid colors, border colors/widths, corner radii | paint | +| Gradients | matching kind, points and stop topology | paint | +| Layers | masks, shadow components and typed effect parameters | paint/composite | +| Layout | same-unit width/height/min/max, padding, gap, grow/shrink and pixel insets | layout | +| Scroll | `Point` offsets | scroll | + +The first transition to a composited value may require one paint to establish +its layer; subsequent opacity and transform frames remain compositor updates. + +Explicit `Element::bind` motions have final precedence over implicit style +transitions. This makes it safe to give one property direct application control +while the remaining container values animate implicitly. + +## Gallery cookbook + +The Widget Gallery's **Animation laboratory** contains twenty live examples: + +| Group | Examples | +| --- | --- | +| Implicit | subtree opacity; size; color and radius; padding and gap; composed transform; border and shadow; interrupted retargeting | +| Keyframes | typed multi-property morph; multiple stops; holds; steps; alternate direction; stagger schedule | +| Physics | analytical spring; bounded inertia; velocity-preserving retarget; squash and stretch | +| Composition | additive tracks; a user-defined `Curve`; synchronized layer, glow and compositor properties | + +Use **Run all animations** repeatedly while motion is active to see every +retarget continue from its presented value. Enabling the platform reduced-motion +preference snaps all twenty examples to their destination and returns the +application to an idle frame schedule. + ## Ownership and scheduling `argui-animation` owns interpolation and timing without Winit, Taffy or WGPU. diff --git a/docs/widgets/shadcn.md b/docs/widgets/shadcn.md index d932ab4c..97dcb3f3 100644 --- a/docs/widgets/shadcn.md +++ b/docs/widgets/shadcn.md @@ -17,6 +17,7 @@ updater dialog is a separate opt-in. | Accordion | [Accordion](../../crates/argui-widgets/src/accordion.rs) | `accordion` | Controlled single/multiple sections, arrow/Home/End navigation and disabled states; opening is not animated. | | Alert | [Alert](../../crates/argui-widgets/src/alert.rs) | `alert` | Inline standard/destructive presentation, icon and explicit announcement policy. | | Alert Dialog | [AlertDialog](../../crates/argui-widgets/src/alert_dialog.rs) | `alert-dialog` | Modal confirmation, initial Cancel focus, ignored outside clicks and a distinct confirmation action. | +| Animated Container / Opacity | [AnimatedContainer](../../crates/argui-widgets/src/implicit_animation.rs) | `implicit-animation` | `AnimatedContainer` and `AnimatedOpacity` provide retained target-value transitions with curves, interruption-safe retargeting and reduced-motion support; incompatible representations switch discretely. | | Aspect Ratio | [AspectRatio](../../crates/argui-widgets/src/aspect_ratio.rs) | `aspect-ratio` | Reserves height from width and a positive aspect ratio; fits content to the frame. | | Attachment | [Attachment](../../crates/argui-widgets/src/attachment.rs) | `attachment` | File metadata, media, actions, five transfer states and progress; the application owns the transfer. | | Avatar | [Avatar](../../crates/argui-widgets/src/avatar.rs) | `avatar` | Loaded image or controlled fallback, circular clip, size and a single accessible name; no avatar group. | diff --git a/scripts/check-widget-features.py b/scripts/check-widget-features.py index 2fbedaf5..8a7cb88f 100644 --- a/scripts/check-widget-features.py +++ b/scripts/check-widget-features.py @@ -25,7 +25,8 @@ def main(): facade = manifest("argui")["features"] assert widgets["default"] == facade["default"] == [], "Widgets must be opt-in" individual = set(widgets) - {"all", "default"} - assert set(widgets["all"]) == individual, "all must include every widget feature" + assert set(widgets["all"]) == individual - {"updater"}, \ + "all must include every standalone widget feature" assert facade["widgets-all"] == ["argui-widgets/all"] for feature in individual: assert facade[f"widget-{feature}"] == [f"argui-widgets/{feature}"] @@ -35,7 +36,8 @@ def main(): check("argui-widgets", "all") types = {feature: "".join(part.title() for part in feature.split("-")) for feature in individual} types.update({"textarea": "TextArea", "vlist": "VList", "range": "RangeState", - "icons": "WidgetAssets", "text-selection": "TextSelectionToolbar"}) + "icons": "WidgetAssets", "text-selection": "TextSelectionToolbar", + "implicit-animation": "AnimatedOpacity", "updater": "UpdateDialog"}) with tempfile.TemporaryDirectory(prefix="argui-features-") as directory: probe = pathlib.Path(directory) (probe / "src").mkdir() @@ -46,10 +48,16 @@ def main(): '[features]\ndefault=[]\nall=["argui/widgets-all"]\n' + "".join(f'{feature}=["argui/widget-{feature}"]\n' for feature in sorted(individual)) ) - (probe / "src/main.rs").write_text("#![allow(unused_imports)]\nfn main() {}\n" + "".join( - f'#[cfg(any(feature="{feature}",feature="all"))]\nuse argui::widgets::{widget} as _;\n' - for feature, widget in sorted(types.items()) - )) + imports = [] + for feature, widget in sorted(types.items()): + condition = (f'feature="{feature}"' if feature == "updater" + else f'any(feature="{feature}",feature="all")') + imports.append( + f'#[cfg({condition})]\nuse argui::widgets::{widget} as _;\n' + ) + (probe / "src/main.rs").write_text( + "#![allow(unused_imports)]\nfn main() {}\n" + "".join(imports) + ) for feature in [None, *sorted(individual), "all"]: print(f"features: external argui import / {feature or 'none'}", flush=True) command = ["cargo", "check", "--quiet", "--offline", "--manifest-path", str(probe / "Cargo.toml"), diff --git a/tests/scripts/test_shadcn_catalogue.py b/tests/scripts/test_shadcn_catalogue.py index c663491d..06618736 100644 --- a/tests/scripts/test_shadcn_catalogue.py +++ b/tests/scripts/test_shadcn_catalogue.py @@ -9,15 +9,15 @@ class ShadcnCatalogueTests(unittest.TestCase): - def test_all_64_entries_link_to_exported_apis_and_forwarded_features(self): + def test_all_65_entries_link_to_exported_apis_and_forwarded_features(self): document = ROOT / "docs/widgets/shadcn.md" rows = re.findall( r"^\| ([^|]+) \| \[([^]]+)\]\(([^)]+)\) \| `([^`]+)` \|", document.read_text(), re.MULTILINE, ) - self.assertEqual(len(rows), 64) - self.assertEqual(len({row[0] for row in rows}), 64) + self.assertEqual(len(rows), 65) + self.assertEqual(len({row[0] for row in rows}), 65) widgets = tomllib.loads((ROOT / "crates/argui-widgets/Cargo.toml").read_text()) facade = tomllib.loads((ROOT / "crates/argui/Cargo.toml").read_text()) exports = (ROOT / "crates/argui-widgets/src/lib.rs").read_text() diff --git a/website/app/data/doc-example-sources.generated.ts b/website/app/data/doc-example-sources.generated.ts index ebab080e..459737a8 100644 --- a/website/app/data/doc-example-sources.generated.ts +++ b/website/app/data/doc-example-sources.generated.ts @@ -3,7 +3,7 @@ export const docExampleSources = { accessibility: 'use argui::{\n accessibility::{LiveRegion, Role, Semantics},\n runtime::{Context, Render},\n text::TextStyle,\n ui::{Element, Sides, percent},\n widgets::{Button, default_theme},\n};\n\n#[derive(Default)]\npub struct Example {\n announcements: u32,\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let status = format!("{} accessible activations", self.announcements);\n Element::column([\n Element::text("Accessible by construction")\n .text_style(TextStyle {\n color: theme.foreground,\n ..TextStyle::default()\n })\n .semantics(Semantics::new(Role::Heading).level(1)),\n Button::new("announce", "Announce update", theme.button())\n .on_click(cx.callback(|app| {\n app.announcements = app.announcements.saturating_add(1);\n }))\n .build(),\n Element::text(status.clone())\n .text_style(TextStyle {\n color: theme.foreground,\n ..TextStyle::default()\n })\n .semantics(\n Semantics::new(Role::Status)\n .label(status)\n .live(LiveRegion::Polite),\n ),\n ])\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(28.0))\n .gap(16.0)\n .background(theme.background)\n }\n}\n', animation: - 'use argui::{\n core::{ColorInterpolation, Transform2D, TransformOrigin},\n paint::{Border, CornerRadii, LayerStyle, Shadow, VectorAsset, VectorId},\n runtime::{Context, LayoutSnapshot, Render},\n text::TextStyle,\n ui::{\n AlignItems, Axes, Element, FlexWrap, JustifyContent, Overflow, ScrollConfig, Sides, length,\n percent,\n },\n vector::VectorLibrary,\n widgets::{Button, WidgetTheme, default_theme},\n};\n\nconst ORBIT_SVG: &[u8] = br#"\n \n \n"#;\nconst SPARK_SVG: &[u8] = br#"\n \n"#;\n\npub struct Example {\n elapsed: f32,\n running: bool,\n reduced_motion: bool,\n compact: bool,\n vectors: VectorLibrary,\n orbit: VectorId,\n spark: VectorId,\n}\n\nimpl Default for Example {\n fn default() -> Self {\n let mut vectors = VectorLibrary::new();\n let orbit = vectors.insert_svg(ORBIT_SVG).expect("valid orbit SVG");\n let spark = vectors.insert_svg(SPARK_SVG).expect("valid spark SVG");\n Self {\n elapsed: 0.0,\n running: true,\n reduced_motion: false,\n compact: false,\n vectors,\n orbit,\n spark,\n }\n }\n}\n\nimpl Example {\n fn text(\n &self,\n value: impl Into,\n size: f32,\n weight: u16,\n color: argui::core::Color,\n ) -> Element {\n Element::text(value.into()).text_style(TextStyle {\n color,\n font_size: size,\n line_height: size * 1.35,\n weight,\n ..TextStyle::default()\n })\n }\n\n fn stage(&self, art: Element, theme: &WidgetTheme) -> Element {\n Element::container([art])\n .width(percent(1.0))\n .height(length(132.0))\n .padding(Sides::length(16.0))\n .align_items(AlignItems::CENTER)\n .justify_content(JustifyContent::CENTER)\n .background(theme.muted)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(12.0))\n .overflow(Axes {\n x: Overflow::Hidden,\n y: Overflow::Hidden,\n })\n }\n\n fn card(\n &self,\n title: &\'static str,\n detail: &\'static str,\n art: Element,\n theme: &WidgetTheme,\n ) -> Element {\n Element::column([\n self.text(title, 16.0, 700, theme.foreground),\n self.text(detail, 12.0, 450, theme.muted_foreground),\n self.stage(art, theme),\n ])\n .width(if self.compact {\n percent(1.0)\n } else {\n length(330.0)\n })\n .grow(1.0)\n .padding(Sides::length(16.0))\n .gap(9.0)\n .background(theme.card)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(14.0))\n }\n\n fn cards(&self, theme: &WidgetTheme) -> Element {\n let cycle = if self.reduced_motion {\n 0.35\n } else {\n (self.elapsed / 2.0).fract()\n };\n let angle = cycle * std::f32::consts::TAU;\n let wave = angle.sin() * 0.5 + 0.5;\n let travel = angle.sin() * if self.compact { 52.0 } else { 82.0 };\n let combined = Element::container([])\n .width(length(58.0))\n .height(length(58.0))\n .background(theme.primary)\n .radius(CornerRadii::all(12.0 + wave * 12.0))\n .transform(\n Transform2D::IDENTITY\n .translate(travel, 0.0)\n .rotate(angle)\n .scale(0.78 + wave * 0.42, 0.78 + wave * 0.42),\n )\n .transform_origin(TransformOrigin::CENTER);\n\n let aura = self\n .text("Production ready", 17.0, 700, theme.foreground)\n .padding(Sides::length(18.0))\n .background(theme.card)\n .border(Border::all(1.0 + wave * 2.0, theme.primary))\n .radius(CornerRadii::all(12.0))\n .layer(\n LayerStyle::new(Default::default()).shadow(\n Shadow::glow(\n 10.0 + wave * 24.0,\n theme.primary.with_alpha(0.18 + wave * 0.28),\n )\n .spread(wave * 4.0),\n ),\n );\n\n let morph_color = theme\n .primary\n .mix(theme.destructive, wave, ColorInterpolation::Oklab);\n let morph = Element::container([])\n .width(length(76.0 + wave * 112.0))\n .height(length(58.0 + wave * 28.0))\n .background(morph_color)\n .radius(CornerRadii {\n top_left: 8.0 + wave * 34.0,\n top_right: 38.0 - wave * 28.0,\n bottom_right: 8.0 + wave * 34.0,\n bottom_left: 38.0 - wave * 28.0,\n });\n\n let orbit = Element::vector(self.orbit)\n .vector_color(theme.primary)\n .paint_opacity(1.0 - wave)\n .width(length(72.0))\n .height(length(72.0))\n .transform(\n Transform2D::IDENTITY\n .rotate(angle)\n .scale(1.0 - wave * 0.28, 1.0 - wave * 0.28),\n );\n let spark = Element::vector(self.spark)\n .vector_color(theme.destructive)\n .paint_opacity(wave)\n .width(length(72.0))\n .height(length(72.0))\n .transform(\n Transform2D::IDENTITY\n .rotate(-angle * 0.35)\n .scale(0.72 + wave * 0.28, 0.72 + wave * 0.28),\n );\n let vector_transition = Element::container([\n orbit.absolute(Sides::length(0.0)),\n spark.absolute(Sides::length(0.0)),\n ])\n .width(length(72.0))\n .height(length(72.0));\n\n let loader = Element::row((0..5).map(|index| {\n let local =\n ((cycle + index as f32 * 0.14).fract() * std::f32::consts::TAU).sin() * 0.5 + 0.5;\n Element::container([])\n .width(length(12.0))\n .height(length(28.0 + local * 48.0))\n .background(\n theme\n .primary\n .mix(theme.destructive, local, ColorInterpolation::Oklab),\n )\n .radius(CornerRadii::all(999.0))\n }))\n .height(length(84.0))\n .gap(9.0)\n .align_items(AlignItems::CENTER);\n\n let pulse = 0.82 + wave * 0.18;\n let notification = Element::row([\n Element::container([])\n .width(length(12.0))\n .height(length(12.0))\n .background(theme.primary)\n .radius(CornerRadii::all(999.0))\n .layer(LayerStyle::new(Default::default()).shadow(Shadow::glow(\n 6.0 + wave * 18.0,\n theme.primary.with_alpha(0.34),\n ))),\n self.text("Sync complete", 15.0, 650, theme.foreground),\n ])\n .padding(Sides::length(16.0))\n .gap(10.0)\n .align_items(AlignItems::CENTER)\n .background(theme.card)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(999.0))\n .transform(Transform2D::IDENTITY.scale(pulse, pulse));\n\n Element::row([\n self.card(\n "Composed transform",\n "Translate, rotate, scale, and radius run in one two-second loop.",\n combined,\n theme,\n ),\n self.card(\n "Text aura",\n "A practical focus treatment combines border width and GPU glow.",\n aura,\n theme,\n ),\n self.card(\n "Layout morph",\n "Size, asymmetric radii, and Oklab color interpolate together.",\n morph,\n theme,\n ),\n self.card(\n "SVG transition",\n "Two retained vector assets crossfade, rotate, and scale.",\n vector_transition,\n theme,\n ),\n self.card(\n "Sequenced loader",\n "Phase offsets create a continuous multi-element loading rhythm.",\n loader,\n theme,\n ),\n self.card(\n "Status pulse",\n "A restrained scale and aura draw attention without moving layout.",\n notification,\n theme,\n ),\n ])\n .width(percent(1.0))\n .padding(Sides {\n left: length(16.0),\n right: length(16.0),\n top: length(16.0),\n bottom: length(28.0),\n })\n .gap(12.0)\n .flex_wrap(FlexWrap::Wrap)\n }\n}\n\nimpl Render for Example {\n fn wants_animation_frame(&self) -> bool {\n self.running && !self.reduced_motion\n }\n\n fn animation_frame(&mut self, frame: argui::animation::Frame, cx: &mut Context) {\n if self.running && !self.reduced_motion {\n self.elapsed = (self.elapsed + frame.elapsed.as_secs_f64().min(0.05) as f32) % 2.0;\n cx.notify();\n }\n }\n\n fn render(&mut self, cx: &mut Context) -> Element {\n let environment = cx.environment();\n self.reduced_motion = environment.reduced_motion;\n let themes = default_theme(&environment);\n let theme = themes.resolve(environment.color_scheme);\n let status = if self.reduced_motion {\n "Reduced motion is active"\n } else if self.running {\n "Six loops · 2 seconds"\n } else {\n "Animations paused"\n };\n let controls = Element::row([\n Element::column([\n self.text("Animation lab", 21.0, 760, theme.foreground),\n self.text(status, 12.0, 500, theme.muted_foreground),\n ])\n .gap(2.0)\n .grow(1.0),\n Button::new(\n "toggle-animation",\n if self.running {\n "Pause animations"\n } else {\n "Resume animations"\n },\n theme.button(),\n )\n .enabled(!self.reduced_motion)\n .on_click(cx.callback(|app| app.running = !app.running))\n .build(),\n ])\n .width(percent(1.0))\n .padding(Sides::length(14.0))\n .gap(12.0)\n .flex_wrap(FlexWrap::Wrap)\n .align_items(AlignItems::CENTER)\n .background(theme.card)\n .border(Border {\n widths: argui::paint::BorderWidths {\n bottom: 1.0,\n ..argui::paint::BorderWidths::default()\n },\n color: theme.border,\n });\n let gallery = self\n .cards(theme)\n .keyed("animation-gallery")\n .grow(1.0)\n .min_height(length(0.0))\n .overflow(Axes {\n x: Overflow::Hidden,\n y: Overflow::Auto,\n })\n .scroll_config(ScrollConfig::default().scrollbar(theme.scrollbar.clone()));\n\n Element::column([controls, gallery])\n .width(percent(1.0))\n .height(percent(1.0))\n .min_height(length(0.0))\n .background(theme.background)\n }\n\n fn layout_changed(&mut self, layout: &LayoutSnapshot, cx: &mut Context) {\n let compact = layout.viewport_size().width < 620.0;\n if self.compact != compact {\n self.compact = compact;\n cx.notify();\n }\n }\n\n fn vector_assets(&self) -> Vec {\n self.vectors.assets().to_vec()\n }\n}\n', + 'use argui::{\n animation::{Duration, curves},\n core::{ColorInterpolation, Transform2D, TransformOrigin},\n paint::{Border, CornerRadii, LayerStyle, Shadow, VectorAsset, VectorId},\n runtime::{Context, LayoutSnapshot, Render},\n text::TextStyle,\n ui::{\n AlignItems, Axes, Element, FlexWrap, JustifyContent, Overflow, ScrollConfig, Sides, length,\n percent,\n },\n vector::VectorLibrary,\n widgets::{AnimatedContainer, AnimatedOpacity, Button, WidgetTheme, default_theme},\n};\n\nconst ORBIT_SVG: &[u8] = br#"\n \n \n"#;\nconst SPARK_SVG: &[u8] = br#"\n \n"#;\n\npub struct Example {\n elapsed: f32,\n running: bool,\n reduced_motion: bool,\n compact: bool,\n expanded: bool,\n vectors: VectorLibrary,\n orbit: VectorId,\n spark: VectorId,\n}\n\nimpl Default for Example {\n fn default() -> Self {\n let mut vectors = VectorLibrary::new();\n let orbit = vectors.insert_svg(ORBIT_SVG).expect("valid orbit SVG");\n let spark = vectors.insert_svg(SPARK_SVG).expect("valid spark SVG");\n Self {\n elapsed: 0.0,\n running: true,\n reduced_motion: false,\n compact: false,\n expanded: true,\n vectors,\n orbit,\n spark,\n }\n }\n}\n\nimpl Example {\n fn text(\n &self,\n value: impl Into,\n size: f32,\n weight: u16,\n color: argui::core::Color,\n ) -> Element {\n Element::text(value.into()).text_style(TextStyle {\n color,\n font_size: size,\n line_height: size * 1.35,\n weight,\n ..TextStyle::default()\n })\n }\n\n fn stage(&self, art: Element, theme: &WidgetTheme) -> Element {\n Element::container([art])\n .width(percent(1.0))\n .height(length(132.0))\n .padding(Sides::length(16.0))\n .align_items(AlignItems::CENTER)\n .justify_content(JustifyContent::CENTER)\n .background(theme.muted)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(12.0))\n .overflow(Axes {\n x: Overflow::Hidden,\n y: Overflow::Hidden,\n })\n }\n\n fn card(\n &self,\n title: &\'static str,\n detail: &\'static str,\n art: Element,\n theme: &WidgetTheme,\n ) -> Element {\n Element::column([\n self.text(title, 16.0, 700, theme.foreground),\n self.text(detail, 12.0, 450, theme.muted_foreground),\n self.stage(art, theme),\n ])\n .width(if self.compact {\n percent(1.0)\n } else {\n length(330.0)\n })\n .grow(1.0)\n .padding(Sides::length(16.0))\n .gap(9.0)\n .background(theme.card)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(14.0))\n }\n\n fn cards(&self, theme: &WidgetTheme) -> Element {\n let cycle = if self.reduced_motion {\n 0.35\n } else {\n (self.elapsed / 2.0).fract()\n };\n let angle = cycle * std::f32::consts::TAU;\n let wave = angle.sin() * 0.5 + 0.5;\n let travel = angle.sin() * if self.compact { 52.0 } else { 82.0 };\n let combined = Element::container([])\n .width(length(58.0))\n .height(length(58.0))\n .background(theme.primary)\n .radius(CornerRadii::all(12.0 + wave * 12.0))\n .transform(\n Transform2D::IDENTITY\n .translate(travel, 0.0)\n .rotate(angle)\n .scale(0.78 + wave * 0.42, 0.78 + wave * 0.42),\n )\n .transform_origin(TransformOrigin::CENTER);\n\n let aura = self\n .text("Production ready", 17.0, 700, theme.foreground)\n .padding(Sides::length(18.0))\n .background(theme.card)\n .border(Border::all(1.0 + wave * 2.0, theme.primary))\n .radius(CornerRadii::all(12.0))\n .layer(\n LayerStyle::new(Default::default()).shadow(\n Shadow::glow(\n 10.0 + wave * 24.0,\n theme.primary.with_alpha(0.18 + wave * 0.28),\n )\n .spread(wave * 4.0),\n ),\n );\n\n let morph_color = theme\n .primary\n .mix(theme.destructive, wave, ColorInterpolation::Oklab);\n let morph = Element::container([])\n .width(length(76.0 + wave * 112.0))\n .height(length(58.0 + wave * 28.0))\n .background(morph_color)\n .radius(CornerRadii {\n top_left: 8.0 + wave * 34.0,\n top_right: 38.0 - wave * 28.0,\n bottom_right: 8.0 + wave * 34.0,\n bottom_left: 38.0 - wave * 28.0,\n });\n\n let orbit = Element::vector(self.orbit)\n .vector_color(theme.primary)\n .paint_opacity(1.0 - wave)\n .width(length(72.0))\n .height(length(72.0))\n .transform(\n Transform2D::IDENTITY\n .rotate(angle)\n .scale(1.0 - wave * 0.28, 1.0 - wave * 0.28),\n );\n let spark = Element::vector(self.spark)\n .vector_color(theme.destructive)\n .paint_opacity(wave)\n .width(length(72.0))\n .height(length(72.0))\n .transform(\n Transform2D::IDENTITY\n .rotate(-angle * 0.35)\n .scale(0.72 + wave * 0.28, 0.72 + wave * 0.28),\n );\n let vector_transition = Element::container([\n orbit.absolute(Sides::length(0.0)),\n spark.absolute(Sides::length(0.0)),\n ])\n .width(length(72.0))\n .height(length(72.0));\n\n let loader = Element::row((0..5).map(|index| {\n let local =\n ((cycle + index as f32 * 0.14).fract() * std::f32::consts::TAU).sin() * 0.5 + 0.5;\n Element::container([])\n .width(length(12.0))\n .height(length(28.0 + local * 48.0))\n .background(\n theme\n .primary\n .mix(theme.destructive, local, ColorInterpolation::Oklab),\n )\n .radius(CornerRadii::all(999.0))\n }))\n .height(length(84.0))\n .gap(9.0)\n .align_items(AlignItems::CENTER);\n\n let pulse = 0.82 + wave * 0.18;\n let notification = Element::row([\n Element::container([])\n .width(length(12.0))\n .height(length(12.0))\n .background(theme.primary)\n .radius(CornerRadii::all(999.0))\n .layer(LayerStyle::new(Default::default()).shadow(Shadow::glow(\n 6.0 + wave * 18.0,\n theme.primary.with_alpha(0.34),\n ))),\n self.text("Sync complete", 15.0, 650, theme.foreground),\n ])\n .padding(Sides::length(16.0))\n .gap(10.0)\n .align_items(AlignItems::CENTER)\n .background(theme.card)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(999.0))\n .transform(Transform2D::IDENTITY.scale(pulse, pulse));\n\n let implicit_opacity = AnimatedOpacity::new(\n "docs-implicit-opacity",\n if self.expanded { 1.0 } else { 0.12 },\n self.text("Target-driven fade", 17.0, 700, theme.primary),\n )\n .duration(Duration::from_millis(260))\n .curve(curves::EASE_OUT)\n .build();\n let implicit_container = AnimatedContainer::new("docs-implicit-container", [])\n .width(length(if self.expanded { 188.0 } else { 72.0 }))\n .height(length(if self.expanded { 76.0 } else { 46.0 }))\n .background(if self.expanded {\n theme.destructive\n } else {\n theme.primary\n })\n .radius(CornerRadii::all(if self.expanded { 30.0 } else { 10.0 }))\n .duration(Duration::from_millis(420))\n .curve(curves::EMPHASIZED)\n .build();\n\n Element::row([\n self.card(\n "AnimatedOpacity",\n "A rebuild changes only the target; retained state owns the fade.",\n implicit_opacity,\n theme,\n ),\n self.card(\n "AnimatedContainer",\n "Size, color and radius transition with one declarative policy.",\n implicit_container,\n theme,\n ),\n self.card(\n "Composed transform",\n "Translate, rotate, scale, and radius run in one two-second loop.",\n combined,\n theme,\n ),\n self.card(\n "Text aura",\n "A practical focus treatment combines border width and GPU glow.",\n aura,\n theme,\n ),\n self.card(\n "Layout morph",\n "Size, asymmetric radii, and Oklab color interpolate together.",\n morph,\n theme,\n ),\n self.card(\n "SVG transition",\n "Two retained vector assets crossfade, rotate, and scale.",\n vector_transition,\n theme,\n ),\n self.card(\n "Sequenced loader",\n "Phase offsets create a continuous multi-element loading rhythm.",\n loader,\n theme,\n ),\n self.card(\n "Status pulse",\n "A restrained scale and aura draw attention without moving layout.",\n notification,\n theme,\n ),\n ])\n .width(percent(1.0))\n .padding(Sides {\n left: length(16.0),\n right: length(16.0),\n top: length(16.0),\n bottom: length(28.0),\n })\n .gap(12.0)\n .flex_wrap(FlexWrap::Wrap)\n }\n}\n\nimpl Render for Example {\n fn wants_animation_frame(&self) -> bool {\n self.running && !self.reduced_motion\n }\n\n fn animation_frame(&mut self, frame: argui::animation::Frame, cx: &mut Context) {\n if self.running && !self.reduced_motion {\n self.elapsed = (self.elapsed + frame.elapsed.as_secs_f64().min(0.05) as f32) % 2.0;\n cx.notify();\n }\n }\n\n fn render(&mut self, cx: &mut Context) -> Element {\n let environment = cx.environment();\n self.reduced_motion = environment.reduced_motion;\n let themes = default_theme(&environment);\n let theme = themes.resolve(environment.color_scheme);\n let status = if self.reduced_motion {\n "Reduced motion is active"\n } else if self.running {\n "Six loops · two implicit examples"\n } else {\n "Animations paused"\n };\n let controls = Element::row([\n Element::column([\n self.text("Animation lab", 21.0, 760, theme.foreground),\n self.text(status, 12.0, 500, theme.muted_foreground),\n ])\n .gap(2.0)\n .grow(1.0),\n Button::new(\n "toggle-animation",\n if self.running {\n "Pause animations"\n } else {\n "Resume animations"\n },\n theme.button(),\n )\n .enabled(!self.reduced_motion)\n .on_click(cx.callback(|app| app.running = !app.running))\n .build(),\n Button::new(\n "toggle-implicit",\n "Retarget implicit examples",\n theme.outline_button(),\n )\n .enabled(!self.reduced_motion)\n .on_click(cx.callback(|app| app.expanded = !app.expanded))\n .build(),\n ])\n .width(percent(1.0))\n .padding(Sides::length(14.0))\n .gap(12.0)\n .flex_wrap(FlexWrap::Wrap)\n .align_items(AlignItems::CENTER)\n .background(theme.card)\n .border(Border {\n widths: argui::paint::BorderWidths {\n bottom: 1.0,\n ..argui::paint::BorderWidths::default()\n },\n color: theme.border,\n });\n let gallery = self\n .cards(theme)\n .keyed("animation-gallery")\n .grow(1.0)\n .min_height(length(0.0))\n .overflow(Axes {\n x: Overflow::Hidden,\n y: Overflow::Auto,\n })\n .scroll_config(ScrollConfig::default().scrollbar(theme.scrollbar.clone()));\n\n Element::column([controls, gallery])\n .width(percent(1.0))\n .height(percent(1.0))\n .min_height(length(0.0))\n .background(theme.background)\n }\n\n fn layout_changed(&mut self, layout: &LayoutSnapshot, cx: &mut Context) {\n let compact = layout.viewport_size().width < 620.0;\n if self.compact != compact {\n self.compact = compact;\n cx.notify();\n }\n }\n\n fn vector_assets(&self) -> Vec {\n self.vectors.assets().to_vec()\n }\n}\n', clean_code: 'use argui::{\n paint::{Border, CornerRadii},\n runtime::{Context, Render},\n text::TextStyle,\n ui::{AlignItems, Element, Sides, length, percent},\n widgets::{Button, default_theme},\n};\n\nconst CHECKS: [&str; 3] = [\n "State is explicit",\n "Views stay pure",\n "Effects have owners",\n];\n\n#[derive(Default)]\npub struct Example {\n completed: usize,\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let rows = CHECKS.into_iter().enumerate().map(|(index, label)| {\n let complete = index < self.completed;\n Element::row([\n Element::text(if complete { "Done" } else { "Pending" })\n .width(length(68.0))\n .text_style(TextStyle {\n color: if complete {\n theme.primary\n } else {\n theme.muted_foreground\n },\n font_size: 12.0,\n weight: 700,\n ..TextStyle::default()\n }),\n Element::text(label).text_style(TextStyle {\n color: theme.foreground,\n ..TextStyle::default()\n }),\n ])\n .width(percent(1.0))\n .align_items(AlignItems::CENTER)\n .padding(Sides::length(12.0))\n .gap(12.0)\n .background(theme.card)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(8.0))\n });\n let next_label = if self.completed == CHECKS.len() {\n "All complete".to_owned()\n } else {\n format!("Complete next ({} of {})", self.completed + 1, CHECKS.len())\n };\n Element::column(\n rows.chain([Button::new("next", next_label, theme.button())\n .enabled(self.completed < CHECKS.len())\n .on_click(cx.callback(|app| {\n app.completed = (app.completed + 1).min(CHECKS.len());\n }))\n .build()]),\n )\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(28.0))\n .gap(14.0)\n .background(theme.background)\n }\n}\n', counter: diff --git a/website/app/data/docs.ts b/website/app/data/docs.ts index 934b8de5..9123efd9 100644 --- a/website/app/data/docs.ts +++ b/website/app/data/docs.ts @@ -653,9 +653,9 @@ export const docs: DocGuide[] = [ minutes: 14, title: 'Animation and motion', description: - 'Compose transforms, layout morphs, color, vector transitions, and GPU aura effects in smooth loops.', + 'Start with implicit target changes, then compose keyframes, physics, color, and GPU layers when an interaction needs more control.', example: example('animation', 'animation'), - demoTitle: 'Six responsive motion patterns running together', + demoTitle: 'Implicit transitions and six responsive motion patterns', sources: [ 'docs/ui/animation.md', 'docs/rendering/effects.md', @@ -663,26 +663,30 @@ export const docs: DocGuide[] = [ ], sections: [ { - id: 'frame', - title: 'Drive one coherent frame', + id: 'implicit', + title: 'Animate a target, not a timer', paragraphs: [ - 'A component opts into animation frames only while motion is active. One time value can coordinate translation, rotation, scale, layout, radii, Oklab color, opacity, and GPU layers without independent timers drifting apart.', + 'AnimatedOpacity and AnimatedContainer retain the currently presented value. Rebuild with a new target and Argui performs the transition, including interruption-safe retargeting and automatic reduced-motion behavior.', ], + code: { + filename: 'src/view.rs', + code: 'AnimatedOpacity::new("details", if visible { 1.0 } else { 0.0 }, panel)\n .duration(Duration::from_millis(220))\n .curve(curves::EASE_OUT)\n .build()', + }, }, { - id: 'practical', - title: 'Compose practical motion', + id: 'levels', + title: 'Use the right level of control', paragraphs: [ - 'Use short loops to communicate status and longer one-shot transitions for state changes. The live lab combines a text aura, a layout morph, a vector crossfade, a sequenced loader, and a status pulse alongside a full composed transform.', + 'Use implicit widgets for ordinary state changes, Motion for directly controlled values, Timeline for multi-stage keyframes, and Spring or Inertia for velocity-aware movement. All four levels share one monotonic frame clock.', ], }, { - id: 'responsive', - title: 'Keep motion responsive and interruptible', + id: 'gallery', + title: 'Explore twenty focused examples', paragraphs: [ - 'The control bar remains visible while the cards scroll inside the Argui canvas. Cards wrap into one column on narrow viewports, scrolling stays available while every loop runs, and Pause stops frame requests immediately.', + 'The complete Widget Gallery groups twenty examples into implicit animation, keyframes and orchestration, physics, and composition. Repeatedly press Run all animations while they move to see retargeting continue from the visible frame.', ], - note: 'Reduced-motion preferences disable the loops and present a stable frame automatically.', + note: 'Settled animations request no frames. Reduced-motion preferences finish active values and keep future target changes immediate.', }, ], }, From 08ce483aefa7f38570dc2a0bbdddc1ff606f814f Mon Sep 17 00:00:00 2001 From: albi Date: Thu, 17 Sep 2026 19:20:24 +0200 Subject: [PATCH 2/3] fix: publish implicit animation in website catalogue --- website/app/data/catalogue.json | 9 +++++++++ website/scripts/catalogue.mjs | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/website/app/data/catalogue.json b/website/app/data/catalogue.json index 1ac89e83..fff72852 100644 --- a/website/app/data/catalogue.json +++ b/website/app/data/catalogue.json @@ -350,6 +350,15 @@ "category": "Components", "gallery": "Button" }, + { + "slug": "implicit-animation", + "name": "Implicit animation", + "description": "Retained opacity and container transitions with curves and reduced-motion support.", + "source": "crates/argui-widgets/src/implicit_animation.rs", + "feature": "implicit-animation", + "category": "Components", + "gallery": "Animation lab" + }, { "slug": "input", "name": "Input & Search", diff --git a/website/scripts/catalogue.mjs b/website/scripts/catalogue.mjs index 1988c0f5..ac738020 100644 --- a/website/scripts/catalogue.mjs +++ b/website/scripts/catalogue.mjs @@ -40,6 +40,12 @@ const extraWidgets = [ null, ], ['icons', 'Icons', 'Vector icons registered once and shared across widgets.', 'Button'], + [ + 'implicit-animation', + 'Implicit animation', + 'Retained opacity and container transitions with curves and reduced-motion support.', + 'Animation lab', + ], ] export async function createCatalogue() { From cb84578f5c0443e7dccf0008753f20b9277bced6 Mon Sep 17 00:00:00 2001 From: albi Date: Thu, 17 Sep 2026 23:41:56 +0200 Subject: [PATCH 3/3] feat: loop animation lab demos --- .../argui-widget-gallery/src/pages/motion.rs | 110 ++++++++++++------ .../src/pages/motion/implicit.rs | 2 +- .../src/pages/motion/keyframes.rs | 2 +- .../tests/pages/motion.mjs | 21 ++-- .../tests/pages/motion.rs | 89 ++++++++++++-- docs/ui/animation.md | 9 +- website/app/data/docs.ts | 4 +- 7 files changed, 176 insertions(+), 61 deletions(-) diff --git a/crates/argui-widget-gallery/src/pages/motion.rs b/crates/argui-widget-gallery/src/pages/motion.rs index 955c0514..fe00d6aa 100644 --- a/crates/argui-widget-gallery/src/pages/motion.rs +++ b/crates/argui-widget-gallery/src/pages/motion.rs @@ -5,8 +5,8 @@ mod physics; use argui::{ animation::{ - Direction, Duration, Easing, FillMode, Inertia, InertiaConfig, Interpolate, Iterations, - Keyframe, Keyframes, Spring, SpringConfig, Timeline, Timing, curves, + Direction, Duration, Easing, Inertia, InertiaConfig, Iterations, Keyframe, Keyframes, + Spring, SpringConfig, Time, Timeline, Timing, curves, }, core::{Color, Transform2D}, paint::{Border, CornerRadii}, @@ -27,9 +27,11 @@ pub(crate) struct MotionDemo { alternate: Timeline, pub(super) timeline_value: f32, pub(super) alternate_value: f32, - timeline_from: f32, pub(super) target: f32, pending: bool, + running: bool, + resume_pending: bool, + last_frame: Option