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