Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
1 change: 1 addition & 0 deletions app_examples/docs-examples/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ argui = { workspace = true, features = [
"tasks",
"widget-button",
"widget-dialog",
"widget-implicit-animation",
"widget-popover",
"widget-range",
"widget-textarea",
Expand Down
48 changes: 46 additions & 2 deletions app_examples/docs-examples/src/examples/animation.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use argui::{
animation::{Duration, curves},
core::{ColorInterpolation, Transform2D, TransformOrigin},
paint::{Border, CornerRadii, LayerStyle, Shadow, VectorAsset, VectorId},
runtime::{Context, LayoutSnapshot, Render},
Expand All @@ -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#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
Expand All @@ -24,6 +25,7 @@ pub struct Example {
running: bool,
reduced_motion: bool,
compact: bool,
expanded: bool,
vectors: VectorLibrary,
orbit: VectorId,
spark: VectorId,
Expand All @@ -39,6 +41,7 @@ impl Default for Example {
running: true,
reduced_motion: false,
compact: false,
expanded: true,
vectors,
orbit,
spark,
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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"
};
Expand All @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions app_examples/docs-examples/tests/behavior.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
9 changes: 8 additions & 1 deletion crates/argui-animation/src/controller.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down Expand Up @@ -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)]
Expand Down
74 changes: 74 additions & 0 deletions crates/argui-animation/src/easing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<F> 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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion crates/argui-animation/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
29 changes: 28 additions & 1 deletion crates/argui-animation/tests/easing.rs
Original file line number Diff line number Diff line change
@@ -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}");
Expand Down Expand Up @@ -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);
}
Loading
Loading