Skip to main content

fission_core/
motion.rs

1//! Declarative widget motion.
2//!
3//! Motion is described during widget building and evaluated by the runtime from
4//! an explicit clock. Application code declares targets; shells never call back
5//! into user code per frame.
6
7use crate::ui::{Composite, Spacer, Widget};
8use crate::CurrentTime;
9use fission_ir::op::{BoxShadow, Color, Fill};
10use fission_ir::{CompositeScalar, WidgetId};
11use fission_layout::{LayoutPoint, LayoutSnapshot};
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet};
14use std::ops::Add;
15use std::sync::Arc;
16
17/// Converts user-facing motion identifiers into stable [`WidgetId`] values.
18///
19/// This is used by convenience helpers such as [`presence`] and [`appear`] so
20/// callers can pass either an explicit [`WidgetId`] or a stable string key.
21///
22/// ```rust,ignore
23/// let card = fission::motion::appear("welcome_card", fission::motion::fade(), child);
24/// ```
25pub trait IntoMotionId {
26    /// Converts this value into the widget identity used by the motion runtime.
27    fn into_motion_id(self) -> WidgetId;
28}
29
30impl IntoMotionId for WidgetId {
31    fn into_motion_id(self) -> WidgetId {
32        self
33    }
34}
35
36impl IntoMotionId for &'static str {
37    fn into_motion_id(self) -> WidgetId {
38        WidgetId::explicit(self)
39    }
40}
41
42impl IntoMotionId for String {
43    fn into_motion_id(self) -> WidgetId {
44        WidgetId::explicit(&self)
45    }
46}
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
49/// The rendering stage affected by a motion track.
50///
51/// Composite motion is the cheapest path and should be preferred for opacity,
52/// translate, scale, and rotation. Layout and paint tracks are declarative
53/// inputs for shells that can animate size, position, color, or drawing data.
54pub enum MotionPhase {
55    /// The track affects layout values such as width or height.
56    Layout,
57    /// The track affects compositor values such as opacity or transform.
58    Composite,
59    /// The track affects paint-only values such as color.
60    Paint,
61}
62
63#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
64/// Identifies the property a [`MotionTrack`] animates.
65///
66/// The property decides the default value, value unit, and shell binding used
67/// during rendering. Built-in properties are understood by Fission shells.
68/// [`MotionPropertyId::Custom`] is for widget-specific progress values that are
69/// consumed by custom renderers rather than by the standard compositor.
70///
71/// ```rust,ignore
72/// use fission::motion::{
73///     scalar, MotionPropertyId, MotionStartValue, MotionTrack,
74/// };
75///
76/// let fade = MotionTrack::composite(
77///     MotionPropertyId::Opacity,
78///     MotionStartValue::Explicit(scalar(0.0)),
79///     scalar(1.0),
80/// );
81/// ```
82pub enum MotionPropertyId {
83    /// Compositor opacity; scalar where `0.0` is transparent and `1.0` is opaque.
84    Opacity,
85    /// Horizontal compositor translation in logical pixels.
86    TranslateX,
87    /// Vertical compositor translation in logical pixels.
88    TranslateY,
89    /// Uniform compositor scale; scalar where `1.0` is unchanged size.
90    Scale,
91    /// Compositor rotation in degrees.
92    Rotation,
93    /// Layout width in logical pixels.
94    Width,
95    /// Layout height in logical pixels.
96    Height,
97    /// Absolute layout x-position in logical pixels.
98    LayoutX,
99    /// Absolute layout y-position in logical pixels.
100    LayoutY,
101    /// Layout snapshot width in logical pixels.
102    LayoutWidth,
103    /// Layout snapshot height in logical pixels.
104    LayoutHeight,
105    /// Intrinsic width of the tracked widget in logical pixels.
106    IntrinsicWidth,
107    /// Intrinsic height of the tracked widget in logical pixels.
108    IntrinsicHeight,
109    /// Paint/layout corner radius in logical pixels.
110    CornerRadius,
111    /// Paint background color.
112    BackgroundColor,
113    /// Discrete paint background fill, including gradients.
114    BackgroundFill,
115    /// Paint border color.
116    BorderColor,
117    /// Paint border width in logical pixels.
118    BorderWidth,
119    /// Paint text color.
120    TextColor,
121    /// Discrete ordered box-shadow layers.
122    BoxShadows,
123    /// Left padding in logical pixels.
124    PaddingLeft,
125    /// Right padding in logical pixels.
126    PaddingRight,
127    /// Top padding in logical pixels.
128    PaddingTop,
129    /// Bottom padding in logical pixels.
130    PaddingBottom,
131    /// Widget-defined property consumed by custom renderers or widget code.
132    Custom(Arc<str>),
133}
134
135impl MotionPropertyId {
136    /// Convenience constructor for [`MotionPropertyId::Opacity`].
137    pub fn opacity() -> Self {
138        Self::Opacity
139    }
140
141    /// Convenience constructor for [`MotionPropertyId::TranslateX`].
142    pub fn translate_x() -> Self {
143        Self::TranslateX
144    }
145
146    /// Convenience constructor for [`MotionPropertyId::TranslateY`].
147    pub fn translate_y() -> Self {
148        Self::TranslateY
149    }
150
151    /// Convenience constructor for [`MotionPropertyId::Scale`].
152    pub fn scale() -> Self {
153        Self::Scale
154    }
155
156    /// Convenience constructor for [`MotionPropertyId::Rotation`].
157    pub fn rotation() -> Self {
158        Self::Rotation
159    }
160
161    /// Creates a widget-defined motion property.
162    ///
163    /// Use a namespaced string such as `"my_crate::chart_progress"` to avoid
164    /// collisions with other widgets.
165    pub fn custom(name: impl Into<String>) -> Self {
166        Self::Custom(Arc::from(name.into()))
167    }
168
169    /// Returns the implicit starting value used when no runtime value exists.
170    pub fn default_value(&self) -> MotionValue {
171        match self {
172            Self::Opacity | Self::Scale => MotionValue::Scalar(1.0),
173            Self::BackgroundColor | Self::BorderColor | Self::TextColor => {
174                MotionValue::Color(Color {
175                    r: 0,
176                    g: 0,
177                    b: 0,
178                    a: 0,
179                })
180            }
181            Self::BackgroundFill => MotionValue::Fill(Fill::Solid(Color::TRANSPARENT)),
182            Self::BoxShadows => MotionValue::Shadows(Vec::new()),
183            Self::TranslateX
184            | Self::TranslateY
185            | Self::Width
186            | Self::Height
187            | Self::LayoutX
188            | Self::LayoutY
189            | Self::LayoutWidth
190            | Self::LayoutHeight
191            | Self::IntrinsicWidth
192            | Self::IntrinsicHeight
193            | Self::CornerRadius
194            | Self::BorderWidth
195            | Self::PaddingLeft
196            | Self::PaddingRight
197            | Self::PaddingTop
198            | Self::PaddingBottom => MotionValue::Px(0.0),
199            Self::Rotation => MotionValue::Deg(0.0),
200            Self::Custom(_) => MotionValue::Scalar(0.0),
201        }
202    }
203
204    /// Returns the default as a scalar-like value for compositor resolution.
205    ///
206    /// Color and boolean defaults resolve to `0.0`.
207    pub fn default_scalar_value(&self) -> f32 {
208        self.default_value().as_scalar_like().unwrap_or(0.0)
209    }
210}
211
212#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
213/// A typed value produced by a [`MotionExpr`] or held in [`MotionStateMap`].
214///
215/// Fission keeps units explicit so a color interpolation cannot accidentally be
216/// used as a transform, and pixel values can remain distinguishable from unit
217/// scalar progress values.
218pub enum MotionValue {
219    /// Boolean value for predicates or custom widget values.
220    Bool(bool),
221    /// Unitless number, commonly used for opacity, scale, or progress.
222    Scalar(f32),
223    /// Logical pixel value.
224    Px(f32),
225    /// Angle in degrees.
226    Deg(f32),
227    /// RGBA color value.
228    Color(Color),
229    /// A discrete solid or gradient fill.
230    Fill(Fill),
231    /// Discrete ordered box-shadow layers.
232    Shadows(Vec<BoxShadow>),
233}
234
235impl MotionValue {
236    /// Returns the contained number for scalar, pixel, or degree values.
237    pub fn as_scalar_like(&self) -> Option<f32> {
238        match self {
239            Self::Scalar(v) | Self::Px(v) | Self::Deg(v) => Some(*v),
240            Self::Bool(_) | Self::Color(_) | Self::Fill(_) | Self::Shadows(_) => None,
241        }
242    }
243
244    fn interpolate(&self, to: &Self, t: f32) -> Self {
245        let t = t.clamp(0.0, 1.0);
246        match (self, to) {
247            (Self::Scalar(a), Self::Scalar(b)) => Self::Scalar(lerp(*a, *b, t)),
248            (Self::Px(a), Self::Px(b)) => Self::Px(lerp(*a, *b, t)),
249            (Self::Deg(a), Self::Deg(b)) => Self::Deg(lerp(*a, *b, t)),
250            (Self::Color(a), Self::Color(b)) => Self::Color(Color {
251                r: lerp(a.r as f32, b.r as f32, t).round().clamp(0.0, 255.0) as u8,
252                g: lerp(a.g as f32, b.g as f32, t).round().clamp(0.0, 255.0) as u8,
253                b: lerp(a.b as f32, b.b as f32, t).round().clamp(0.0, 255.0) as u8,
254                a: lerp(a.a as f32, b.a as f32, t).round().clamp(0.0, 255.0) as u8,
255            }),
256            _ => {
257                if t >= 1.0 {
258                    to.clone()
259                } else {
260                    self.clone()
261                }
262            }
263        }
264    }
265}
266
267#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
268/// Runtime interaction predicate that can branch inside a [`MotionExpr`].
269///
270/// Predicates are evaluated by the shell from deterministic runtime state, not
271/// by calling user code every frame.
272pub enum MotionPredicate {
273    /// True when the widget is currently hovered.
274    Hovered(WidgetId),
275    /// True when the widget is currently pressed.
276    Pressed(WidgetId),
277    /// True when the widget is currently focused.
278    Focused(WidgetId),
279    /// True when the widget is disabled.
280    Disabled(WidgetId),
281}
282
283#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
284/// Declarative expression evaluated by the motion runtime.
285///
286/// Expressions are the closed IR for motion targets. They can reference layout
287/// snapshots, pointer position, interaction predicates, and numeric operators
288/// without requiring arbitrary per-frame callbacks.
289///
290/// ```rust,ignore
291/// use fission::motion::{
292///     scalar, MotionExpr, MotionPredicate, MotionPropertyId, MotionStartValue, MotionTrack,
293/// };
294/// use fission::WidgetId;
295///
296/// let button_id = WidgetId::explicit("save_button");
297/// let scale = MotionTrack::composite(
298///     MotionPropertyId::Scale,
299///     MotionStartValue::Current,
300///     MotionExpr::If {
301///         predicate: MotionPredicate::Pressed(button_id),
302///         then_expr: Box::new(scalar(0.96)),
303///         else_expr: Box::new(scalar(1.0)),
304///     },
305/// );
306/// ```
307pub enum MotionExpr {
308    /// Literal typed value.
309    Value(MotionValue),
310    /// Current widget intrinsic width.
311    IntrinsicWidth,
312    /// Current widget intrinsic height.
313    IntrinsicHeight,
314    /// X position of another widget from the latest layout snapshot.
315    LayoutX(WidgetId),
316    /// Y position of another widget from the latest layout snapshot.
317    LayoutY(WidgetId),
318    /// Width of another widget from the latest layout snapshot.
319    LayoutWidth(WidgetId),
320    /// Height of another widget from the latest layout snapshot.
321    LayoutHeight(WidgetId),
322    /// Pointer x-position local to the tracked widget.
323    PointerLocalX,
324    /// Pointer y-position local to the tracked widget.
325    PointerLocalY,
326    /// Conditional expression selected by a runtime predicate.
327    If {
328        /// Runtime predicate to test.
329        predicate: MotionPredicate,
330        /// Expression evaluated when the predicate is true.
331        then_expr: Box<MotionExpr>,
332        /// Expression evaluated when the predicate is false.
333        else_expr: Box<MotionExpr>,
334    },
335    /// Numeric addition.
336    Add(Box<MotionExpr>, Box<MotionExpr>),
337    /// Numeric subtraction.
338    Sub(Box<MotionExpr>, Box<MotionExpr>),
339    /// Numeric multiplication.
340    Mul(Box<MotionExpr>, Box<MotionExpr>),
341    /// Numeric division. Division by zero leaves the left value unchanged.
342    Div(Box<MotionExpr>, Box<MotionExpr>),
343    /// Numeric negation.
344    Neg(Box<MotionExpr>),
345    /// Absolute value.
346    Abs(Box<MotionExpr>),
347    /// Minimum of two scalar-like expressions.
348    Min(Box<MotionExpr>, Box<MotionExpr>),
349    /// Maximum of two scalar-like expressions.
350    Max(Box<MotionExpr>, Box<MotionExpr>),
351    /// Clamps a scalar-like expression between `min` and `max`.
352    Clamp {
353        /// Value to clamp.
354        value: Box<MotionExpr>,
355        /// Minimum allowed value.
356        min: Box<MotionExpr>,
357        /// Maximum allowed value.
358        max: Box<MotionExpr>,
359    },
360    /// Interpolates between two values using scalar-like `t`.
361    Lerp {
362        /// Start expression.
363        from: Box<MotionExpr>,
364        /// End expression.
365        to: Box<MotionExpr>,
366        /// Interpolation progress, normally `0.0..=1.0`.
367        t: Box<MotionExpr>,
368    },
369    /// Maps a scalar-like expression from one range to another.
370    MapRange {
371        /// Source expression.
372        value: Box<MotionExpr>,
373        /// Lower bound of the input range.
374        from_start: f32,
375        /// Upper bound of the input range.
376        from_end: f32,
377        /// Lower bound of the output range.
378        to_start: f32,
379        /// Upper bound of the output range.
380        to_end: f32,
381        /// Whether to clamp the mapped progress to `0.0..=1.0`.
382        clamp: bool,
383    },
384}
385
386impl MotionExpr {
387    /// Evaluates the expression against runtime and layout inputs.
388    ///
389    /// Application code usually does not call this directly; shells and tests
390    /// use it to turn declarative targets into concrete [`MotionValue`]s.
391    pub fn eval(&self, input: &MotionEvalInput<'_>) -> MotionValue {
392        match self {
393            Self::Value(value) => value.clone(),
394            Self::IntrinsicWidth => input
395                .self_rect
396                .map(|rect| MotionValue::Px(rect.width()))
397                .unwrap_or(MotionValue::Px(0.0)),
398            Self::IntrinsicHeight => input
399                .self_rect
400                .map(|rect| MotionValue::Px(rect.height()))
401                .unwrap_or(MotionValue::Px(0.0)),
402            Self::LayoutX(id) => input
403                .layout
404                .and_then(|layout| layout.get_node_rect(*id))
405                .map(|rect| MotionValue::Px(rect.x()))
406                .unwrap_or(MotionValue::Px(0.0)),
407            Self::LayoutY(id) => input
408                .layout
409                .and_then(|layout| layout.get_node_rect(*id))
410                .map(|rect| MotionValue::Px(rect.y()))
411                .unwrap_or(MotionValue::Px(0.0)),
412            Self::LayoutWidth(id) => input
413                .layout
414                .and_then(|layout| layout.get_node_rect(*id))
415                .map(|rect| MotionValue::Px(rect.width()))
416                .unwrap_or(MotionValue::Px(0.0)),
417            Self::LayoutHeight(id) => input
418                .layout
419                .and_then(|layout| layout.get_node_rect(*id))
420                .map(|rect| MotionValue::Px(rect.height()))
421                .unwrap_or(MotionValue::Px(0.0)),
422            Self::PointerLocalX => input
423                .pointer_local
424                .map(|point| MotionValue::Px(point.x))
425                .unwrap_or(MotionValue::Px(0.0)),
426            Self::PointerLocalY => input
427                .pointer_local
428                .map(|point| MotionValue::Px(point.y))
429                .unwrap_or(MotionValue::Px(0.0)),
430            Self::If {
431                predicate,
432                then_expr,
433                else_expr,
434            } => {
435                if input.predicate(predicate) {
436                    then_expr.eval(input)
437                } else {
438                    else_expr.eval(input)
439                }
440            }
441            Self::Add(a, b) => numeric_binary(a, b, input, |a, b| a + b),
442            Self::Sub(a, b) => numeric_binary(a, b, input, |a, b| a - b),
443            Self::Mul(a, b) => numeric_binary(a, b, input, |a, b| a * b),
444            Self::Div(a, b) => numeric_binary(a, b, input, |a, b| if b == 0.0 { a } else { a / b }),
445            Self::Neg(v) => numeric_unary(v, input, |v| -v),
446            Self::Abs(v) => numeric_unary(v, input, f32::abs),
447            Self::Min(a, b) => numeric_binary(a, b, input, f32::min),
448            Self::Max(a, b) => numeric_binary(a, b, input, f32::max),
449            Self::Clamp { value, min, max } => {
450                let value = value.eval(input);
451                let min = min.eval(input).as_scalar_like().unwrap_or(0.0);
452                let max = max.eval(input).as_scalar_like().unwrap_or(min);
453                map_numeric(value, |v| v.clamp(min, max))
454            }
455            Self::Lerp { from, to, t } => {
456                let t = t.eval(input).as_scalar_like().unwrap_or(0.0);
457                from.eval(input).interpolate(&to.eval(input), t)
458            }
459            Self::MapRange {
460                value,
461                from_start,
462                from_end,
463                to_start,
464                to_end,
465                clamp,
466            } => {
467                let raw = value.eval(input).as_scalar_like().unwrap_or(0.0);
468                let denom = from_end - from_start;
469                let mut t = if denom.abs() <= f32::EPSILON {
470                    0.0
471                } else {
472                    (raw - from_start) / denom
473                };
474                if *clamp {
475                    t = t.clamp(0.0, 1.0);
476                }
477                MotionValue::Scalar(lerp(*to_start, *to_end, t))
478            }
479        }
480    }
481}
482
483#[derive(Clone, Debug)]
484/// Inputs used when evaluating a [`MotionExpr`].
485///
486/// This is primarily shell/runtime API. App code usually declares expressions
487/// and lets Fission evaluate them.
488pub struct MotionEvalInput<'a> {
489    /// Runtime state used for interaction predicates and current values.
490    pub runtime: &'a crate::RuntimeState,
491    /// Optional layout snapshot used for layout-aware expressions.
492    pub layout: Option<&'a LayoutSnapshot>,
493    /// Widget currently being evaluated.
494    pub self_id: WidgetId,
495    /// Layout rect of `self_id`, if known.
496    pub self_rect: Option<fission_layout::LayoutRect>,
497    /// Latest pointer position in the widget's local coordinate space.
498    pub pointer_local: Option<LayoutPoint>,
499}
500
501impl<'a> MotionEvalInput<'a> {
502    fn predicate(&self, predicate: &MotionPredicate) -> bool {
503        match predicate {
504            MotionPredicate::Hovered(id) => self.runtime.interaction.is_hovered(*id),
505            MotionPredicate::Pressed(id) => self.runtime.interaction.is_pressed(*id),
506            MotionPredicate::Focused(id) => self.runtime.interaction.is_focused(*id),
507            MotionPredicate::Disabled(_) => false,
508        }
509    }
510}
511
512#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
513/// Defines where a motion track starts from when a new target is synchronized.
514pub enum MotionStartValue {
515    /// Start from the current runtime value, or the property's default value.
516    Current,
517    /// Start from a concrete expression evaluated at synchronization time.
518    Explicit(MotionExpr),
519}
520
521#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
522/// Easing curve applied to tween progress.
523///
524/// ```rust,ignore
525/// let transition = fission::motion::MotionTransition::tween(
526///     180,
527///     fission::motion::MotionEasing::EaseOut,
528/// );
529/// ```
530pub enum MotionEasing {
531    /// No easing; progress is linear.
532    Linear,
533    /// Slow start, fast end.
534    EaseIn,
535    /// Fast start, slow end.
536    EaseOut,
537    /// Slow start and end.
538    EaseInOut,
539    /// Cubic Bezier curve represented by `(x1, y1, x2, y2)`.
540    CubicBezier(f32, f32, f32, f32),
541}
542
543impl Default for MotionEasing {
544    fn default() -> Self {
545        Self::EaseInOut
546    }
547}
548
549impl MotionEasing {
550    /// Applies the easing curve to normalized progress.
551    pub fn apply(&self, t: f32) -> f32 {
552        match self {
553            Self::Linear => t,
554            Self::EaseIn => t * t,
555            Self::EaseOut => 1.0 - (1.0 - t) * (1.0 - t),
556            Self::EaseInOut => {
557                if t < 0.5 {
558                    2.0 * t * t
559                } else {
560                    1.0 - (-2.0 * t + 2.0).powi(2) / 2.0
561                }
562            }
563            Self::CubicBezier(_x1, y1, _x2, y2) => {
564                let t2 = t * t;
565                let t3 = t2 * t;
566                3.0 * (1.0 - t) * (1.0 - t) * t * y1 + 3.0 * (1.0 - t) * t2 * y2 + t3
567            }
568        }
569    }
570}
571
572#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
573/// Timing model for a [`MotionTrack`].
574///
575/// Use [`MotionTransition::tween`] for fixed-duration motion and
576/// [`MotionTransition::spring`] for spring-like motion. Transitions are data,
577/// so shells and tests can step them deterministically.
578pub enum MotionTransition {
579    /// Applies the target value immediately.
580    Instant,
581    /// Fixed-duration transition.
582    Tween {
583        /// Duration in milliseconds.
584        duration_ms: u64,
585        /// Delay before the transition starts, in milliseconds.
586        delay_ms: u64,
587        /// Easing curve used for tween progress.
588        easing: MotionEasing,
589        /// Whether the tween repeats indefinitely.
590        repeat: bool,
591        /// Optional frame interval hint for repeated low-priority motion.
592        frame_interval_ms: Option<u64>,
593    },
594    /// Spring-like transition.
595    Spring {
596        /// Spring stiffness.
597        stiffness: f32,
598        /// Spring damping.
599        damping: f32,
600        /// Spring mass.
601        mass: f32,
602        /// Completion epsilon.
603        epsilon: f32,
604        /// Delay before the spring starts, in milliseconds.
605        delay_ms: u64,
606    },
607}
608
609/// Concise alias for state-style transitions used by interactive widgets.
610pub type Transition = MotionTransition;
611
612impl Default for MotionTransition {
613    fn default() -> Self {
614        Self::Tween {
615            duration_ms: 160,
616            delay_ms: 0,
617            easing: MotionEasing::EaseInOut,
618            repeat: false,
619            frame_interval_ms: None,
620        }
621    }
622}
623
624impl MotionTransition {
625    /// Creates a tween with the common ease-out curve.
626    pub fn ease_out(duration_ms: u64) -> Self {
627        Self::tween(duration_ms, MotionEasing::EaseOut)
628    }
629    /// Creates a fixed-duration tween transition.
630    pub fn tween(duration_ms: u64, easing: MotionEasing) -> Self {
631        Self::Tween {
632            duration_ms,
633            delay_ms: 0,
634            easing,
635            repeat: false,
636            frame_interval_ms: None,
637        }
638    }
639
640    /// Creates a spring-like transition.
641    pub fn spring(stiffness: f32, damping: f32) -> Self {
642        Self::Spring {
643            stiffness,
644            damping,
645            mass: 1.0,
646            epsilon: 0.001,
647            delay_ms: 0,
648        }
649    }
650
651    /// Sets a start delay in milliseconds.
652    pub fn delay_ms(mut self, delay_ms: u64) -> Self {
653        match &mut self {
654            Self::Instant => {}
655            Self::Tween {
656                delay_ms: delay, ..
657            }
658            | Self::Spring {
659                delay_ms: delay, ..
660            } => {
661                *delay = delay_ms;
662            }
663        }
664        self
665    }
666
667    /// Enables or disables repeating tween playback.
668    pub fn repeat(mut self, repeat: bool) -> Self {
669        if let Self::Tween {
670            repeat: current, ..
671        } = &mut self
672        {
673            *current = repeat;
674        }
675        self
676    }
677
678    /// Sets an optional frame interval hint for repeated tween playback.
679    pub fn frame_interval_ms(mut self, frame_interval_ms: Option<u64>) -> Self {
680        if let Self::Tween {
681            frame_interval_ms: current,
682            ..
683        } = &mut self
684        {
685            *current = frame_interval_ms;
686        }
687        self
688    }
689
690    fn duration_ms(&self) -> u64 {
691        match self {
692            Self::Instant => 0,
693            Self::Tween { duration_ms, .. } => *duration_ms,
694            Self::Spring { .. } => 260,
695        }
696    }
697
698    fn delay_value_ms(&self) -> u64 {
699        match self {
700            Self::Instant => 0,
701            Self::Tween { delay_ms, .. } | Self::Spring { delay_ms, .. } => *delay_ms,
702        }
703    }
704
705    fn repeat_enabled(&self) -> bool {
706        matches!(self, Self::Tween { repeat: true, .. })
707    }
708
709    fn easing(&self) -> MotionEasing {
710        match self {
711            Self::Instant | Self::Spring { .. } => MotionEasing::EaseOut,
712            Self::Tween { easing, .. } => easing.clone(),
713        }
714    }
715
716    fn frame_interval_value_ms(&self) -> Option<u64> {
717        match self {
718            Self::Tween {
719                frame_interval_ms, ..
720            } => frame_interval_ms.filter(|ms| *ms > 0),
721            _ => None,
722        }
723    }
724}
725
726#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
727/// A single declarative animation from a start value to a target expression.
728///
729/// Tracks are grouped by [`Motion`] or [`Presence`] and synchronized into the
730/// runtime. Later tracks targeting the same property and phase win when widget
731/// presets are composed.
732///
733/// ```rust,ignore
734/// use fission::motion::{
735///     px, MotionPropertyId, MotionStartValue, MotionTrack, MotionTransition, MotionEasing,
736/// };
737///
738/// let slide = MotionTrack::composite(
739///     MotionPropertyId::TranslateY,
740///     MotionStartValue::Explicit(px(16.0)),
741///     px(0.0),
742/// )
743/// .transition(MotionTransition::tween(160, MotionEasing::EaseOut));
744/// ```
745pub struct MotionTrack {
746    /// Property affected by this track.
747    pub property: MotionPropertyId,
748    /// Rendering phase affected by this track.
749    pub phase: MotionPhase,
750    /// Source value for a newly synchronized target.
751    pub from: MotionStartValue,
752    /// Target expression evaluated by the runtime.
753    pub to: MotionExpr,
754    /// Timing model for this track.
755    pub transition: MotionTransition,
756}
757
758impl MotionTrack {
759    /// Creates a compositor-phase track for transform or opacity properties.
760    pub fn composite(property: MotionPropertyId, from: MotionStartValue, to: MotionExpr) -> Self {
761        Self {
762            property,
763            phase: MotionPhase::Composite,
764            from,
765            to,
766            transition: MotionTransition::default(),
767        }
768    }
769
770    /// Creates a layout-phase track for dimensions or spacing.
771    pub fn layout(property: MotionPropertyId, from: MotionStartValue, to: MotionExpr) -> Self {
772        Self {
773            property,
774            phase: MotionPhase::Layout,
775            from,
776            to,
777            transition: MotionTransition::default(),
778        }
779    }
780
781    /// Creates a paint-phase track for colors, strokes, or corner radii.
782    pub fn paint(property: MotionPropertyId, from: MotionStartValue, to: MotionExpr) -> Self {
783        Self {
784            property,
785            phase: MotionPhase::Paint,
786            from,
787            to,
788            transition: MotionTransition::default(),
789        }
790    }
791
792    /// Replaces the track's timing model.
793    pub fn transition(mut self, transition: MotionTransition) -> Self {
794        self.transition = transition;
795        self
796    }
797}
798
799#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
800/// Runtime lifecycle phase for a [`Presence`] widget.
801pub enum PresencePhase {
802    /// The child is not visible and is not kept mounted.
803    Hidden,
804    /// The child is mounted and running enter tracks.
805    Entering,
806    /// The child is mounted and fully visible.
807    Present,
808    /// The child is mounted and running exit tracks.
809    Exiting,
810}
811
812impl Default for PresencePhase {
813    fn default() -> Self {
814        Self::Hidden
815    }
816}
817
818#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
819/// Placement of a ripple layer relative to the wrapped child.
820pub enum RipplePlacement {
821    /// Draw ripples behind the child content.
822    BehindChild,
823    /// Draw ripples above the child content.
824    AboveChild,
825}
826
827#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
828/// Pointer-origin ripple effect configuration.
829///
830/// Use [`ripple_effect`] for the default and chain builder methods to tune it.
831///
832/// ```rust,ignore
833/// let ripple = fission::motion::ripple_effect()
834///     .scale(12.0)
835///     .duration(500)
836///     .ease(fission::motion::MotionEasing::EaseOut);
837/// ```
838pub struct RippleFx {
839    /// Ripple color before opacity is applied.
840    pub color: Color,
841    /// Peak ripple opacity.
842    pub opacity: f32,
843    /// Final ripple scale relative to the starting circle.
844    pub scale: f32,
845    /// Timing used for each spawned ripple.
846    pub transition: MotionTransition,
847    /// Maximum number of ripples kept for one layer.
848    pub max_instances: usize,
849    /// Whether ripples draw above or behind the child.
850    pub placement: RipplePlacement,
851}
852
853impl Default for RippleFx {
854    fn default() -> Self {
855        Self {
856            color: Color {
857                r: 255,
858                g: 255,
859                b: 255,
860                a: 64,
861            },
862            opacity: 0.35,
863            scale: 10.0,
864            transition: MotionTransition::tween(600, MotionEasing::EaseOut),
865            max_instances: 8,
866            placement: RipplePlacement::BehindChild,
867        }
868    }
869}
870
871impl RippleFx {
872    /// Sets the final ripple scale.
873    pub fn scale(mut self, scale: f32) -> Self {
874        self.scale = scale;
875        self
876    }
877
878    /// Sets the ripple duration in milliseconds when the transition is a tween.
879    pub fn duration(mut self, duration_ms: u64) -> Self {
880        if let MotionTransition::Tween {
881            duration_ms: current,
882            ..
883        } = &mut self.transition
884        {
885            *current = duration_ms;
886        }
887        self
888    }
889
890    /// Sets the ripple easing when the transition is a tween.
891    pub fn ease(mut self, easing: MotionEasing) -> Self {
892        if let MotionTransition::Tween {
893            easing: current, ..
894        } = &mut self.transition
895        {
896            *current = easing;
897        }
898        self
899    }
900}
901
902#[derive(Clone, Debug, Serialize, Deserialize)]
903/// Runtime declaration emitted by motion widgets during build.
904///
905/// Shells consume declarations after a build and synchronize them into
906/// [`MotionStateMap`]. Application code normally constructs [`Motion`],
907/// [`Presence`], or [`RippleLayer`] instead of creating declarations directly.
908pub struct MotionDeclaration {
909    /// Stable identity for the tracked widget or motion slot.
910    pub id: WidgetId,
911    /// Declaration payload.
912    pub kind: MotionDeclarationKind,
913}
914
915#[derive(Clone, Debug, Serialize, Deserialize)]
916/// Payload for a [`MotionDeclaration`].
917pub enum MotionDeclarationKind {
918    /// Standard set of tracks applied to the declaration id.
919    Tracks {
920        /// Tracks to synchronize.
921        tracks: Vec<MotionTrack>,
922    },
923    /// Presence lifecycle declaration for enter and exit motion.
924    Presence {
925        /// Whether the child should be visible in this build.
926        visible: bool,
927        /// Whether the child remains mounted even after exit completes.
928        keep_rendered: bool,
929        /// Tracks used when becoming visible.
930        enter: Vec<MotionTrack>,
931        /// Tracks used when becoming hidden.
932        exit: Vec<MotionTrack>,
933        /// Whether exiting content should be treated as inert by shells.
934        inert_while_exiting: bool,
935    },
936    /// Pointer-origin ripple layer declaration.
937    RippleLayer(RippleFx),
938}
939
940#[derive(Clone, Debug, Serialize, Deserialize)]
941/// Native motion wrapper for app-owned or framework-owned widgets.
942///
943/// `Motion` applies tracks to its child and emits one [`MotionDeclaration`].
944/// It is the low-level escape hatch under all widget-owned motion presets.
945///
946/// ```rust,ignore
947/// use fission::motion::{fade, Motion};
948/// use fission::{Text, WidgetId};
949///
950/// let child = Text::new("Hello").into();
951/// let widget = Motion {
952///     id: WidgetId::explicit("hello_fade"),
953///     tracks: fade(),
954///     child,
955///     ..Default::default()
956/// };
957/// ```
958pub struct Motion {
959    /// Stable identity for the motion wrapper.
960    pub id: WidgetId,
961    /// Tracks applied to the child.
962    pub tracks: Vec<MotionTrack>,
963    /// Wrapped child widget.
964    pub child: Widget,
965    /// Whether the wrapper clips visual overflow.
966    pub clip_to_bounds: bool,
967    /// Whether shells should treat this as a repaint/composite boundary.
968    pub repaint_boundary: bool,
969}
970
971impl Default for Motion {
972    fn default() -> Self {
973        Self {
974            id: WidgetId::explicit("motion"),
975            tracks: Vec::new(),
976            child: Spacer::default().into(),
977            clip_to_bounds: false,
978            repaint_boundary: true,
979        }
980    }
981}
982
983#[derive(Clone, Debug, Serialize, Deserialize)]
984/// Presence wrapper that keeps a child mounted while enter/exit tracks run.
985///
986/// Use this when a widget must animate out after its `visible` state becomes
987/// false. If both enter and exit tracks are empty, presence changes are applied
988/// immediately.
989///
990/// ```rust,ignore
991/// use fission::motion::{fade, Presence};
992/// use fission::{Text, WidgetId};
993///
994/// let widget = Presence {
995///     id: WidgetId::explicit("details_panel"),
996///     visible: state.show_details,
997///     enter: fade(),
998///     exit: fission::motion::reverse_tracks_for_exit(&fade()),
999///     child: Text::new("Details").into(),
1000///     ..Default::default()
1001/// };
1002/// ```
1003pub struct Presence {
1004    /// Stable identity for the presence slot.
1005    pub id: WidgetId,
1006    /// Whether the child is logically visible in the current build.
1007    pub visible: bool,
1008    /// Whether to keep rendering the child after exit completes.
1009    pub keep_rendered: bool,
1010    /// Tracks used when entering.
1011    pub enter: Vec<MotionTrack>,
1012    /// Tracks used when exiting.
1013    pub exit: Vec<MotionTrack>,
1014    /// Child controlled by this presence wrapper.
1015    pub child: Widget,
1016    /// Whether to clip visual overflow during presence motion.
1017    pub clip_to_bounds: bool,
1018    /// Whether shells should treat this as a repaint/composite boundary.
1019    pub repaint_boundary: bool,
1020    /// Whether shells should suppress interaction while the child exits.
1021    pub inert_while_exiting: bool,
1022}
1023
1024impl Default for Presence {
1025    fn default() -> Self {
1026        Self {
1027            id: WidgetId::explicit("presence"),
1028            visible: true,
1029            keep_rendered: false,
1030            enter: Vec::new(),
1031            exit: Vec::new(),
1032            child: Spacer::default().into(),
1033            clip_to_bounds: false,
1034            repaint_boundary: true,
1035            inert_while_exiting: true,
1036        }
1037    }
1038}
1039
1040#[derive(Clone, Debug, Serialize, Deserialize)]
1041/// Wrapper that enables deterministic pointer-origin ripple effects.
1042pub struct RippleLayer {
1043    /// Stable identity for the ripple layer.
1044    pub id: WidgetId,
1045    /// Ripple configuration.
1046    pub effect: RippleFx,
1047    /// Wrapped child widget.
1048    pub child: Widget,
1049}
1050
1051impl Default for RippleLayer {
1052    fn default() -> Self {
1053        Self {
1054            id: WidgetId::explicit("ripple_layer"),
1055            effect: RippleFx::default(),
1056            child: Spacer::default().into(),
1057        }
1058    }
1059}
1060
1061impl From<Motion> for Widget {
1062    fn from(component: Motion) -> Self {
1063        crate::build::try_register_motion(MotionDeclaration {
1064            id: component.id,
1065            kind: MotionDeclarationKind::Tracks {
1066                tracks: component.tracks.clone(),
1067            },
1068        });
1069        let style = composite_style_for_tracks(component.id, component.child, &component.tracks)
1070            .clip_to_bounds(component.clip_to_bounds)
1071            .repaint_boundary(component.repaint_boundary);
1072        style.into()
1073    }
1074}
1075
1076impl From<Presence> for Widget {
1077    fn from(component: Presence) -> Self {
1078        crate::build::try_register_motion(MotionDeclaration {
1079            id: component.id,
1080            kind: MotionDeclarationKind::Presence {
1081                visible: component.visible,
1082                keep_rendered: component.keep_rendered,
1083                enter: component.enter.clone(),
1084                exit: component.exit.clone(),
1085                inert_while_exiting: component.inert_while_exiting,
1086            },
1087        });
1088
1089        let phase = crate::build::try_current_runtime_state()
1090            .and_then(|runtime| runtime.motion.presence.get(&component.id).copied())
1091            .unwrap_or(if component.visible {
1092                PresencePhase::Present
1093            } else {
1094                PresencePhase::Hidden
1095            });
1096        let should_render = component.visible
1097            || component.keep_rendered
1098            || matches!(
1099                phase,
1100                PresencePhase::Entering | PresencePhase::Present | PresencePhase::Exiting
1101            );
1102        if !should_render {
1103            return Spacer::default().into();
1104        }
1105
1106        let tracks = if component.visible {
1107            &component.enter
1108        } else {
1109            &component.exit
1110        };
1111        composite_style_for_tracks(component.id, component.child, tracks)
1112            .clip_to_bounds(component.clip_to_bounds)
1113            .repaint_boundary(component.repaint_boundary)
1114            .into()
1115    }
1116}
1117
1118impl From<RippleLayer> for Widget {
1119    fn from(component: RippleLayer) -> Self {
1120        crate::build::try_register_motion(MotionDeclaration {
1121            id: component.id,
1122            kind: MotionDeclarationKind::RippleLayer(component.effect),
1123        });
1124        Composite {
1125            id: Some(component.id),
1126            child: component.child,
1127            ..Default::default()
1128        }
1129        .into()
1130    }
1131}
1132
1133fn composite_style_for_tracks(id: WidgetId, child: Widget, tracks: &[MotionTrack]) -> Composite {
1134    let mut composite = Composite {
1135        id: Some(id),
1136        child,
1137        ..Default::default()
1138    };
1139    for track in tracks {
1140        if track.phase != MotionPhase::Composite {
1141            continue;
1142        }
1143        match track.property {
1144            MotionPropertyId::Opacity => {
1145                composite.style.opacity = Some(CompositeScalar::new(1.0).motion(id));
1146            }
1147            MotionPropertyId::TranslateX => {
1148                composite.style.translate_x = Some(CompositeScalar::new(0.0).motion(id));
1149            }
1150            MotionPropertyId::TranslateY => {
1151                composite.style.translate_y = Some(CompositeScalar::new(0.0).motion(id));
1152            }
1153            MotionPropertyId::Scale => {
1154                composite.style.scale = Some(CompositeScalar::new(1.0).motion(id));
1155            }
1156            MotionPropertyId::Rotation => {
1157                composite.style.rotation = Some(CompositeScalar::new(0.0).motion(id));
1158            }
1159            _ => {}
1160        }
1161    }
1162    composite
1163}
1164
1165#[derive(Clone, Debug, Default)]
1166/// Runtime storage for active motion values.
1167///
1168/// Shells own this through [`crate::RuntimeState`]. Tests and custom renderers
1169/// may inspect it to read deterministic motion progress.
1170pub struct MotionStateMap {
1171    /// Last known value for each `(widget, property)` pair.
1172    pub values: HashMap<(WidgetId, MotionPropertyId), MotionValue>,
1173    /// Active transitions keyed by `(widget, property)`.
1174    pub active: HashMap<(WidgetId, MotionPropertyId), ActiveMotion>,
1175    /// Current presence phase for each presence id.
1176    pub presence: HashMap<WidgetId, PresencePhase>,
1177    /// Active ripple instances for each ripple layer.
1178    pub ripples: HashMap<WidgetId, Vec<SpawnedRipple>>,
1179}
1180
1181impl MotionStateMap {
1182    /// Returns a scalar-like value for a widget/property pair.
1183    ///
1184    /// If no value has been produced yet, the property's default value is used.
1185    pub fn scalar_value(&self, widget_id: WidgetId, property: MotionPropertyId) -> f32 {
1186        self.values
1187            .get(&(widget_id, property.clone()))
1188            .and_then(MotionValue::as_scalar_like)
1189            .unwrap_or_else(|| property.default_scalar_value())
1190    }
1191}
1192
1193#[derive(Clone, Debug)]
1194/// Runtime transition currently being advanced by the clock.
1195pub struct ActiveMotion {
1196    /// Target widget or motion slot.
1197    pub target: WidgetId,
1198    /// Property being animated.
1199    pub property: MotionPropertyId,
1200    /// Concrete start value.
1201    pub start_value: MotionValue,
1202    /// Concrete target value.
1203    pub end_value: MotionValue,
1204    /// Runtime start time in milliseconds.
1205    pub start_time: u64,
1206    /// Runtime duration in milliseconds.
1207    pub duration: u64,
1208    /// Whether this motion repeats.
1209    pub repeat: bool,
1210    /// Optional repeated-frame interval hint.
1211    pub frame_interval_ms: Option<u64>,
1212    /// Easing used to map progress.
1213    pub easing: MotionEasing,
1214}
1215
1216#[derive(Clone, Debug)]
1217/// A ripple instance spawned by pointer input.
1218pub struct SpawnedRipple {
1219    /// Stable identity for this ripple instance.
1220    pub id: WidgetId,
1221    /// Ripple layer that owns this instance.
1222    pub parent: WidgetId,
1223    /// Monotonic sequence number within the parent layer.
1224    pub sequence: u64,
1225    /// Ripple origin x-position in local pixels.
1226    pub origin_x: f32,
1227    /// Ripple origin y-position in local pixels.
1228    pub origin_y: f32,
1229    /// Creation time in milliseconds.
1230    pub birth_ms: u64,
1231    /// Duration in milliseconds.
1232    pub duration_ms: u64,
1233}
1234
1235#[derive(Default)]
1236/// Result of synchronizing declarations into [`MotionStateMap`].
1237pub struct MotionSyncResult {
1238    /// Widget/property keys that changed during synchronization.
1239    pub changed: Vec<(WidgetId, MotionPropertyId)>,
1240}
1241
1242/// Synchronizes build-time motion declarations into runtime state.
1243///
1244/// This is shell/runtime API. Application code should use widgets and motion
1245/// presets rather than calling it directly.
1246pub fn sync_motion_declarations(
1247    state: &mut MotionStateMap,
1248    declarations: &[MotionDeclaration],
1249    runtime: &crate::RuntimeState,
1250    layout: Option<&LayoutSnapshot>,
1251    now: CurrentTime,
1252) -> MotionSyncResult {
1253    let mut result = MotionSyncResult::default();
1254    let mut requested = HashSet::new();
1255
1256    for declaration in declarations {
1257        match &declaration.kind {
1258            MotionDeclarationKind::Tracks { tracks } => {
1259                sync_tracks(
1260                    state,
1261                    declaration.id,
1262                    tracks,
1263                    runtime,
1264                    layout,
1265                    now,
1266                    &mut requested,
1267                    &mut result,
1268                );
1269            }
1270            MotionDeclarationKind::Presence {
1271                visible,
1272                keep_rendered: _,
1273                enter,
1274                exit,
1275                inert_while_exiting: _,
1276            } => {
1277                let phase = state
1278                    .presence
1279                    .get(&declaration.id)
1280                    .copied()
1281                    .unwrap_or(PresencePhase::Hidden);
1282                let next_phase = match (phase, *visible) {
1283                    (PresencePhase::Hidden, true) => PresencePhase::Entering,
1284                    (PresencePhase::Exiting, true) => PresencePhase::Entering,
1285                    (PresencePhase::Entering, true) => PresencePhase::Entering,
1286                    (PresencePhase::Present, true) => PresencePhase::Present,
1287                    (PresencePhase::Hidden, false) => PresencePhase::Hidden,
1288                    (PresencePhase::Entering, false)
1289                    | (PresencePhase::Present, false)
1290                    | (PresencePhase::Exiting, false) => PresencePhase::Exiting,
1291                };
1292                state.presence.insert(declaration.id, next_phase);
1293                let tracks = if *visible { enter } else { exit };
1294                if tracks.is_empty() {
1295                    match next_phase {
1296                        PresencePhase::Entering => {
1297                            state
1298                                .presence
1299                                .insert(declaration.id, PresencePhase::Present);
1300                        }
1301                        PresencePhase::Exiting => {
1302                            state.presence.insert(declaration.id, PresencePhase::Hidden);
1303                        }
1304                        PresencePhase::Hidden | PresencePhase::Present => {}
1305                    }
1306                    continue;
1307                }
1308                if !*visible && phase == PresencePhase::Hidden {
1309                    continue;
1310                }
1311                sync_tracks(
1312                    state,
1313                    declaration.id,
1314                    tracks,
1315                    runtime,
1316                    layout,
1317                    now,
1318                    &mut requested,
1319                    &mut result,
1320                );
1321            }
1322            MotionDeclarationKind::RippleLayer(_) => {}
1323        }
1324    }
1325
1326    state.active.retain(|key, _| requested.contains(key));
1327    state.values.retain(|key, _| requested.contains(key));
1328    result
1329}
1330
1331/// Advances active motion to `current_time` and returns changed properties.
1332///
1333/// This is shell/runtime API used by render loops and deterministic tests.
1334pub fn tick_motion(
1335    state: &mut MotionStateMap,
1336    current_time: CurrentTime,
1337) -> Vec<(WidgetId, MotionPropertyId)> {
1338    let mut changed = Vec::new();
1339    let mut finished = Vec::new();
1340    let mut finished_presence = Vec::new();
1341
1342    for ((target, property), motion) in state.active.iter_mut() {
1343        let elapsed = current_time.saturating_sub(motion.start_time);
1344        let mut progress = if motion.duration == 0 {
1345            1.0
1346        } else {
1347            elapsed as f32 / motion.duration as f32
1348        };
1349
1350        if motion.repeat && progress >= 1.0 {
1351            progress %= 1.0;
1352        } else {
1353            progress = progress.clamp(0.0, 1.0);
1354        }
1355
1356        if !motion.repeat && (elapsed >= motion.duration || motion.duration == 0) {
1357            finished.push((*target, property.clone()));
1358        }
1359
1360        let eased = motion.easing.apply(progress);
1361        let value = motion.start_value.interpolate(&motion.end_value, eased);
1362        if state.values.get(&(*target, property.clone())) != Some(&value) {
1363            state.values.insert((*target, property.clone()), value);
1364            changed.push((*target, property.clone()));
1365        }
1366    }
1367
1368    for key in finished {
1369        state.active.remove(&key);
1370        if state
1371            .presence
1372            .get(&key.0)
1373            .is_some_and(|phase| *phase == PresencePhase::Entering)
1374        {
1375            finished_presence.push((key.0, PresencePhase::Present));
1376        } else if state
1377            .presence
1378            .get(&key.0)
1379            .is_some_and(|phase| *phase == PresencePhase::Exiting)
1380        {
1381            finished_presence.push((key.0, PresencePhase::Hidden));
1382        }
1383    }
1384
1385    for (id, phase) in finished_presence {
1386        state.presence.insert(id, phase);
1387    }
1388
1389    changed
1390}
1391
1392fn sync_tracks(
1393    state: &mut MotionStateMap,
1394    id: WidgetId,
1395    tracks: &[MotionTrack],
1396    runtime: &crate::RuntimeState,
1397    layout: Option<&LayoutSnapshot>,
1398    now: CurrentTime,
1399    requested: &mut HashSet<(WidgetId, MotionPropertyId)>,
1400    result: &mut MotionSyncResult,
1401) {
1402    let self_rect = layout.and_then(|layout| layout.get_node_rect(id));
1403    let input = MotionEvalInput {
1404        runtime,
1405        layout,
1406        self_id: id,
1407        self_rect,
1408        pointer_local: None,
1409    };
1410    for track in tracks {
1411        let key = (id, track.property.clone());
1412        requested.insert(key.clone());
1413        let target_value = track.to.eval(&input);
1414        if let Some(active) = state.active.get(&key) {
1415            if active.end_value == target_value
1416                && active.duration == track.transition.duration_ms()
1417                && active.repeat == track.transition.repeat_enabled()
1418                && active.frame_interval_ms == track.transition.frame_interval_value_ms()
1419                && active.easing == track.transition.easing()
1420            {
1421                continue;
1422            }
1423        }
1424
1425        let current_value = state
1426            .values
1427            .get(&key)
1428            .cloned()
1429            .unwrap_or_else(|| track.property.default_value());
1430        if !track.transition.repeat_enabled()
1431            && state.values.contains_key(&key)
1432            && current_value == target_value
1433        {
1434            continue;
1435        }
1436
1437        let start_value = match &track.from {
1438            MotionStartValue::Explicit(expr) => expr.eval(&input),
1439            MotionStartValue::Current => current_value,
1440        };
1441
1442        if !track.transition.repeat_enabled() && start_value == target_value {
1443            let changed = state.values.get(&key) != Some(&target_value);
1444            state.values.insert(key.clone(), target_value);
1445            state.active.remove(&key);
1446            if changed {
1447                result.changed.push(key);
1448            }
1449            continue;
1450        }
1451
1452        state.values.insert(key.clone(), start_value.clone());
1453        state.active.insert(
1454            key.clone(),
1455            ActiveMotion {
1456                target: id,
1457                property: track.property.clone(),
1458                start_value,
1459                end_value: target_value,
1460                start_time: now + track.transition.delay_value_ms(),
1461                duration: track.transition.duration_ms(),
1462                repeat: track.transition.repeat_enabled(),
1463                frame_interval_ms: track.transition.frame_interval_value_ms(),
1464                easing: track.transition.easing(),
1465            },
1466        );
1467        result.changed.push(key);
1468    }
1469}
1470
1471/// Creates a unitless scalar expression.
1472pub fn scalar(value: f32) -> MotionExpr {
1473    MotionExpr::Value(MotionValue::Scalar(value))
1474}
1475
1476/// Creates a logical pixel expression.
1477pub fn px(value: f32) -> MotionExpr {
1478    MotionExpr::Value(MotionValue::Px(value))
1479}
1480
1481/// Creates a degree expression.
1482pub fn deg(value: f32) -> MotionExpr {
1483    MotionExpr::Value(MotionValue::Deg(value))
1484}
1485
1486/// Creates a color expression.
1487pub fn color(value: Color) -> MotionExpr {
1488    MotionExpr::Value(MotionValue::Color(value))
1489}
1490
1491/// Creates a discrete solid or gradient fill expression.
1492pub fn fill(value: Fill) -> MotionExpr {
1493    MotionExpr::Value(MotionValue::Fill(value))
1494}
1495
1496/// Creates an ordered discrete box-shadow expression.
1497pub fn shadows(value: Vec<BoxShadow>) -> MotionExpr {
1498    MotionExpr::Value(MotionValue::Shadows(value))
1499}
1500
1501/// Creates an opacity fade-in track.
1502pub fn fade() -> Vec<MotionTrack> {
1503    vec![MotionTrack::composite(
1504        MotionPropertyId::Opacity,
1505        MotionStartValue::Explicit(scalar(0.0)),
1506        scalar(1.0),
1507    )]
1508}
1509
1510/// Creates a horizontal slide-in track from `offset` pixels to zero.
1511pub fn slide_x(offset: f32) -> Vec<MotionTrack> {
1512    vec![MotionTrack::composite(
1513        MotionPropertyId::TranslateX,
1514        MotionStartValue::Explicit(px(offset)),
1515        px(0.0),
1516    )]
1517}
1518
1519/// Creates a vertical slide-in track from `offset` pixels to zero.
1520pub fn slide_y(offset: f32) -> Vec<MotionTrack> {
1521    vec![MotionTrack::composite(
1522        MotionPropertyId::TranslateY,
1523        MotionStartValue::Explicit(px(offset)),
1524        px(0.0),
1525    )]
1526}
1527
1528/// Creates a width collapse/expand track from zero to intrinsic width.
1529pub fn collapse_x() -> Vec<MotionTrack> {
1530    vec![MotionTrack {
1531        property: MotionPropertyId::Width,
1532        phase: MotionPhase::Layout,
1533        from: MotionStartValue::Explicit(px(0.0)),
1534        to: MotionExpr::IntrinsicWidth,
1535        transition: MotionTransition::default(),
1536    }]
1537}
1538
1539/// Creates a height collapse/expand track from zero to intrinsic height.
1540pub fn collapse_y() -> Vec<MotionTrack> {
1541    vec![MotionTrack {
1542        property: MotionPropertyId::Height,
1543        phase: MotionPhase::Layout,
1544        from: MotionStartValue::Explicit(px(0.0)),
1545        to: MotionExpr::IntrinsicHeight,
1546        transition: MotionTransition::default(),
1547    }]
1548}
1549
1550/// Creates tracks that follow another widget's x-position and width.
1551///
1552/// This is useful for tab indicators, segmented controls, and selected pills.
1553pub fn follow_x_and_width(target: WidgetId) -> Vec<MotionTrack> {
1554    vec![
1555        MotionTrack::composite(
1556            MotionPropertyId::TranslateX,
1557            MotionStartValue::Current,
1558            MotionExpr::LayoutX(target),
1559        ),
1560        MotionTrack {
1561            property: MotionPropertyId::Width,
1562            phase: MotionPhase::Layout,
1563            from: MotionStartValue::Current,
1564            to: MotionExpr::LayoutWidth(target),
1565            transition: MotionTransition::default(),
1566        },
1567    ]
1568}
1569
1570/// Creates a hover/press scale feedback track for a widget id.
1571pub fn hover_press(id: WidgetId) -> Vec<MotionTrack> {
1572    vec![MotionTrack::composite(
1573        MotionPropertyId::Scale,
1574        MotionStartValue::Current,
1575        MotionExpr::If {
1576            predicate: MotionPredicate::Pressed(id),
1577            then_expr: Box::new(scalar(0.97)),
1578            else_expr: Box::new(MotionExpr::If {
1579                predicate: MotionPredicate::Hovered(id),
1580                then_expr: Box::new(scalar(1.02)),
1581                else_expr: Box::new(scalar(1.0)),
1582            }),
1583        },
1584    )
1585    .transition(MotionTransition::spring(420.0, 30.0))]
1586}
1587
1588/// Returns the default ripple effect configuration.
1589pub fn ripple_effect() -> RippleFx {
1590    RippleFx::default()
1591}
1592
1593/// Convenience wrapper for presence motion.
1594///
1595/// The provided `tracks` are used for enter motion and reversed for exit motion.
1596pub fn presence(
1597    id: impl IntoMotionId,
1598    visible: bool,
1599    tracks: Vec<MotionTrack>,
1600    child: impl Into<Widget>,
1601) -> Widget {
1602    Presence {
1603        id: id.into_motion_id(),
1604        visible,
1605        enter: tracks.clone(),
1606        exit: reverse_tracks_for_exit(&tracks),
1607        child: child.into(),
1608        ..Default::default()
1609    }
1610    .into()
1611}
1612
1613/// Convenience wrapper for applying tracks to a child immediately.
1614pub fn appear(id: impl IntoMotionId, tracks: Vec<MotionTrack>, child: impl Into<Widget>) -> Widget {
1615    Motion {
1616        id: id.into_motion_id(),
1617        tracks,
1618        child: child.into(),
1619        ..Default::default()
1620    }
1621    .into()
1622}
1623
1624/// Convenience alias for layout-oriented motion.
1625pub fn layout(id: impl IntoMotionId, tracks: Vec<MotionTrack>, child: impl Into<Widget>) -> Widget {
1626    appear(id, tracks, child)
1627}
1628
1629/// Convenience alias for interaction-driven motion.
1630pub fn interactive(
1631    id: impl IntoMotionId,
1632    tracks: Vec<MotionTrack>,
1633    child: impl Into<Widget>,
1634) -> Widget {
1635    appear(id, tracks, child)
1636}
1637
1638/// Convenience wrapper for adding a ripple layer to a child.
1639pub fn ripple(id: impl IntoMotionId, effect: RippleFx, child: impl Into<Widget>) -> Widget {
1640    RippleLayer {
1641        id: id.into_motion_id(),
1642        effect,
1643        child: child.into(),
1644    }
1645    .into()
1646}
1647
1648/// Produces exit tracks by reversing explicit enter start values.
1649///
1650/// A track with `from: Explicit(x)` exits from the current value back to `x`.
1651/// A track with `from: Current` exits to the property's default value.
1652pub fn reverse_tracks_for_exit(tracks: &[MotionTrack]) -> Vec<MotionTrack> {
1653    tracks
1654        .iter()
1655        .map(|track| MotionTrack {
1656            property: track.property.clone(),
1657            phase: track.phase,
1658            from: MotionStartValue::Current,
1659            to: match &track.from {
1660                MotionStartValue::Explicit(expr) => expr.clone(),
1661                MotionStartValue::Current => track.property.default_value().into(),
1662            },
1663            transition: track.transition.clone(),
1664        })
1665        .collect()
1666}
1667
1668/// Removes duplicate tracks so the last track for each property/phase wins.
1669///
1670/// Widget-owned motion presets use this to implement deterministic ordered
1671/// composition.
1672pub fn dedupe_tracks_later_wins(tracks: Vec<MotionTrack>) -> Vec<MotionTrack> {
1673    let mut seen = HashSet::new();
1674    let mut out = Vec::with_capacity(tracks.len());
1675    for track in tracks.into_iter().rev() {
1676        if seen.insert((track.property.clone(), track.phase)) {
1677            out.push(track);
1678        }
1679    }
1680    out.reverse();
1681    out
1682}
1683
1684impl From<MotionValue> for MotionExpr {
1685    fn from(value: MotionValue) -> Self {
1686        Self::Value(value)
1687    }
1688}
1689
1690#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1691/// Generic surface motion preset used by custom widgets and framework internals.
1692///
1693/// Built-in widgets expose widget-specific motion enums instead. Use
1694/// `SurfaceMotion` when authoring your own widget that needs a simple enter/exit
1695/// surface animation without defining a full public enum.
1696pub enum SurfaceMotion {
1697    /// Curated default: fade plus scale.
1698    Default,
1699    /// Fade opacity in.
1700    Fade,
1701    /// Scale from slightly smaller to normal size.
1702    Scale,
1703    /// Slide horizontally from the given pixel offset.
1704    SlideX(f32),
1705    /// Slide vertically from the given pixel offset.
1706    SlideY(f32),
1707    /// Compound fade plus scale preset.
1708    Pop,
1709    /// Ordered composition of surface motion atoms.
1710    Composition(Vec<SurfaceMotion>),
1711    /// Caller-provided native tracks.
1712    Custom {
1713        /// Enter tracks.
1714        enter: Vec<MotionTrack>,
1715        /// Exit tracks.
1716        exit: Vec<MotionTrack>,
1717        /// Whether the child stays rendered after exit completes.
1718        keep_rendered: bool,
1719    },
1720}
1721
1722impl SurfaceMotion {
1723    /// Flattens and normalizes an ordered composition.
1724    pub fn compose(items: impl IntoIterator<Item = Self>) -> Self {
1725        let mut out = Vec::new();
1726        for item in items {
1727            item.flatten_into(&mut out);
1728        }
1729        match out.len() {
1730            0 => Self::Composition(Vec::new()),
1731            1 => out.remove(0),
1732            _ => Self::Composition(out),
1733        }
1734    }
1735
1736    /// Lowers this preset into enter tracks.
1737    pub fn enter_tracks(&self) -> Vec<MotionTrack> {
1738        let mut out = Vec::new();
1739        self.append_enter_tracks(&mut out);
1740        dedupe_tracks_later_wins(out)
1741    }
1742
1743    /// Lowers this preset into exit tracks.
1744    pub fn exit_tracks(&self) -> Vec<MotionTrack> {
1745        match self {
1746            Self::Custom { exit, .. } => exit.clone(),
1747            _ => reverse_tracks_for_exit(&self.enter_tracks()),
1748        }
1749    }
1750
1751    /// Returns whether this preset requests persistent rendering after exit.
1752    pub fn keep_rendered(&self) -> bool {
1753        match self {
1754            Self::Custom { keep_rendered, .. } => *keep_rendered,
1755            Self::Composition(items) => items.iter().any(Self::keep_rendered),
1756            _ => false,
1757        }
1758    }
1759
1760    fn append_enter_tracks(&self, out: &mut Vec<MotionTrack>) {
1761        match self {
1762            Self::Default => {
1763                Self::Fade.append_enter_tracks(out);
1764                Self::Scale.append_enter_tracks(out);
1765            }
1766            Self::Fade => out.extend(fade()),
1767            Self::Scale => out.push(MotionTrack::composite(
1768                MotionPropertyId::Scale,
1769                MotionStartValue::Explicit(scalar(0.96)),
1770                scalar(1.0),
1771            )),
1772            Self::SlideX(offset) => out.extend(slide_x(*offset)),
1773            Self::SlideY(offset) => out.extend(slide_y(*offset)),
1774            Self::Pop => {
1775                Self::Fade.append_enter_tracks(out);
1776                Self::Scale.append_enter_tracks(out);
1777            }
1778            Self::Composition(items) => {
1779                for item in items {
1780                    item.append_enter_tracks(out);
1781                }
1782            }
1783            Self::Custom { enter, .. } => out.extend(enter.clone()),
1784        }
1785    }
1786
1787    fn flatten_into(self, out: &mut Vec<Self>) {
1788        match self {
1789            Self::Composition(items) => {
1790                for item in items {
1791                    item.flatten_into(out);
1792                }
1793            }
1794            item => out.push(item),
1795        }
1796    }
1797}
1798
1799impl Add for SurfaceMotion {
1800    type Output = Self;
1801
1802    fn add(self, rhs: Self) -> Self::Output {
1803        Self::compose([self, rhs])
1804    }
1805}
1806
1807fn numeric_binary(
1808    a: &MotionExpr,
1809    b: &MotionExpr,
1810    input: &MotionEvalInput<'_>,
1811    f: impl FnOnce(f32, f32) -> f32,
1812) -> MotionValue {
1813    let left = a.eval(input);
1814    let right = b.eval(input).as_scalar_like().unwrap_or(0.0);
1815    map_numeric(left, |left| f(left, right))
1816}
1817
1818fn numeric_unary(
1819    value: &MotionExpr,
1820    input: &MotionEvalInput<'_>,
1821    f: impl FnOnce(f32) -> f32,
1822) -> MotionValue {
1823    let value = value.eval(input);
1824    map_numeric(value, f)
1825}
1826
1827fn map_numeric(value: MotionValue, f: impl FnOnce(f32) -> f32) -> MotionValue {
1828    match value {
1829        MotionValue::Scalar(v) => MotionValue::Scalar(f(v)),
1830        MotionValue::Px(v) => MotionValue::Px(f(v)),
1831        MotionValue::Deg(v) => MotionValue::Deg(f(v)),
1832        MotionValue::Bool(_)
1833        | MotionValue::Color(_)
1834        | MotionValue::Fill(_)
1835        | MotionValue::Shadows(_) => value,
1836    }
1837}
1838
1839fn lerp(a: f32, b: f32, t: f32) -> f32 {
1840    a + (b - a) * t
1841}