Skip to main content

blinc_layout/
motion.rs

1//! Motion container for animations
2//!
3//! A container that applies animations to its children. Supports:
4//! - Enter/exit animations (fade_in, scale_in, slide_in, etc.)
5//! - Staggered animations for lists
6//! - **Continuous animations** driven by `AnimatedValue` or `AnimatedTimeline`
7//!
8//! # Example - Enter/Exit
9//!
10//! ```ignore
11//! use blinc_layout::prelude::*;
12//!
13//! motion()
14//!     .fade_in(300)
15//!     .fade_out(200)
16//!     .child(my_content)
17//! ```
18//!
19//! # Example - Continuous Animation with AnimatedValue
20//!
21//! ```ignore
22//! use blinc_layout::prelude::*;
23//! use blinc_animation::{AnimatedValue, SpringConfig};
24//!
25//! // Create animated value for Y translation
26//! let offset_y = Rc::new(RefCell::new(
27//!     AnimatedValue::new(ctx.animation_handle(), 0.0, SpringConfig::wobbly())
28//! ));
29//!
30//! motion()
31//!     .translate_y(offset_y.clone())  // Bind to AnimatedValue
32//!     .child(my_content)
33//!
34//! // Later, in drag handler:
35//! offset_y.borrow_mut().set_target(100.0);  // Animates smoothly
36//! ```
37//!
38//! # Motion Context
39//!
40//! When building UI trees, motion containers track their stable key in a thread-local
41//! context. This allows child elements (especially Stateful elements) to detect if
42//! they're inside an animating motion and defer visual updates until the animation
43//! completes.
44//!
45//! ```ignore
46//! // Check if currently inside an animating motion:
47//! if is_inside_animating_motion() {
48//!     // Don't apply hover state yet
49//! }
50//! ```
51
52// `clippy::missing_const_for_thread_local` mis-fires on nightly clippy
53// 0.1.96+ when the `thread_local!` initializer is already wrapped in
54// `const { ... }`. The lint should be a no-op in that case but
55// currently re-flags it. Suppressed at the module level so the macro
56// expansion (which is what the lint attaches to) is covered.
57#![allow(clippy::missing_const_for_thread_local)]
58
59use std::cell::RefCell;
60use std::sync::Arc;
61
62use crate::div::{ElementBuilder, ElementTypeId};
63use crate::element::ElementBounds;
64use crate::element::{MotionAnimation, MotionKeyframe, RenderProps};
65use crate::key::InstanceKey;
66
67// =============================================================================
68// Motion Context - Tracks ancestor motion during tree building
69// =============================================================================
70
71thread_local! {
72    /// Stack of motion container stable keys currently being built
73    ///
74    /// When a Motion container's build() is called, it pushes its stable key.
75    /// When build() returns, it pops the key. This allows descendants to know
76    /// they're inside a motion container and check its animation state.
77    static MOTION_CONTEXT_STACK: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
78}
79
80/// Push a motion container's stable key onto the context stack
81///
82/// Call this when entering a Motion's build() method.
83pub fn push_motion_context(key: &str) {
84    MOTION_CONTEXT_STACK.with(|stack| {
85        stack.borrow_mut().push(key.to_string());
86    });
87}
88
89/// Pop the current motion container's key from the context stack
90///
91/// Call this when leaving a Motion's build() method.
92pub fn pop_motion_context() {
93    MOTION_CONTEXT_STACK.with(|stack| {
94        stack.borrow_mut().pop();
95    });
96}
97
98/// Get the stable key of the nearest ancestor motion container
99///
100/// Returns `None` if not inside any motion container.
101pub fn current_motion_key() -> Option<String> {
102    MOTION_CONTEXT_STACK.with(|stack| stack.borrow().last().cloned())
103}
104
105/// Check if currently inside an animating motion container
106///
107/// This queries the motion animation state for the nearest ancestor motion.
108/// If inside a motion that is still animating (Waiting, Entering, or Exiting),
109/// returns true. This is used by Stateful elements to defer visual updates.
110///
111/// # Returns
112///
113/// - `true` if inside a motion container that is currently animating
114/// - `false` if not inside any motion, or if the motion has settled (Visible)
115pub fn is_inside_animating_motion() -> bool {
116    if let Some(key) = current_motion_key() {
117        // Query the motion state via the global query API
118        let state = blinc_core::query_motion(&key);
119        state.is_animating()
120    } else {
121        false
122    }
123}
124
125/// Check if currently inside a motion container (regardless of animation state)
126pub fn is_inside_motion() -> bool {
127    MOTION_CONTEXT_STACK.with(|stack| !stack.borrow().is_empty())
128}
129use crate::tree::{LayoutNodeId, LayoutTree};
130use blinc_animation::{AnimatedValue, AnimationPreset, MultiKeyframeAnimation};
131use blinc_core::Transform;
132use taffy::{Display, FlexDirection, Style};
133
134/// Animation configuration for element lifecycle
135#[derive(Clone)]
136pub struct ElementAnimation {
137    /// The animation to play
138    pub animation: MultiKeyframeAnimation,
139}
140
141impl ElementAnimation {
142    /// Create a new element animation
143    pub fn new(animation: MultiKeyframeAnimation) -> Self {
144        Self { animation }
145    }
146
147    /// Set delay before animation starts
148    pub fn with_delay(mut self, delay_ms: u32) -> Self {
149        self.animation = self.animation.delay(delay_ms);
150        self
151    }
152}
153
154impl From<MultiKeyframeAnimation> for ElementAnimation {
155    fn from(animation: MultiKeyframeAnimation) -> Self {
156        Self::new(animation)
157    }
158}
159
160/// Direction for stagger animations
161#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
162pub enum StaggerDirection {
163    /// Animate first to last
164    #[default]
165    Forward,
166    /// Animate last to first
167    Reverse,
168    /// Animate from center outward
169    FromCenter,
170}
171
172/// Direction for slide animations
173#[derive(Clone, Copy, Debug, PartialEq, Eq)]
174pub enum SlideDirection {
175    Left,
176    Right,
177    Top,
178    Bottom,
179}
180
181/// Configuration for stagger animations
182#[derive(Clone)]
183pub struct StaggerConfig {
184    /// Delay between each child's animation start (ms)
185    pub delay_ms: u32,
186    /// Animation to apply to each child
187    pub animation: ElementAnimation,
188    /// Direction of stagger
189    pub direction: StaggerDirection,
190    /// Optional: limit stagger to first N items
191    pub limit: Option<usize>,
192}
193
194impl StaggerConfig {
195    /// Create a new stagger config with delay between items
196    pub fn new(delay_ms: u32, animation: impl Into<ElementAnimation>) -> Self {
197        Self {
198            delay_ms,
199            animation: animation.into(),
200            direction: StaggerDirection::Forward,
201            limit: None,
202        }
203    }
204
205    /// Stagger from last to first
206    pub fn reverse(mut self) -> Self {
207        self.direction = StaggerDirection::Reverse;
208        self
209    }
210
211    /// Stagger from center outward
212    pub fn from_center(mut self) -> Self {
213        self.direction = StaggerDirection::FromCenter;
214        self
215    }
216
217    /// Limit stagger to first N items
218    pub fn limit(mut self, n: usize) -> Self {
219        self.limit = Some(n);
220        self
221    }
222
223    /// Calculate delay for a specific child index
224    pub fn delay_for_index(&self, index: usize, total: usize) -> u32 {
225        let effective_index = match self.direction {
226            StaggerDirection::Forward => index,
227            StaggerDirection::Reverse => total.saturating_sub(1).saturating_sub(index),
228            StaggerDirection::FromCenter => {
229                let center = total / 2;
230                index.abs_diff(center)
231            }
232        };
233
234        // Apply limit if set
235        let capped_index = if let Some(limit) = self.limit {
236            effective_index.min(limit)
237        } else {
238            effective_index
239        };
240
241        self.delay_ms * capped_index as u32
242    }
243}
244
245/// Shared animated value type for motion bindings (thread-safe)
246pub type SharedAnimatedValue = std::sync::Arc<std::sync::Mutex<AnimatedValue>>;
247
248/// Timeline rotation binding for continuous spinning animations
249///
250/// Used for spinners and other continuously rotating elements that use
251/// timeline-based animation instead of spring physics.
252#[derive(Clone)]
253pub struct TimelineRotation {
254    /// The timeline containing the rotation animation
255    pub timeline: blinc_animation::SharedAnimatedTimeline,
256    /// The entry ID for the rotation value in the timeline
257    pub entry_id: blinc_animation::TimelineEntryId,
258}
259
260/// Motion bindings for continuous animation driven by AnimatedValue
261///
262/// This struct holds references to animated values that are sampled every frame
263/// during rendering, enabling smooth continuous animations.
264#[derive(Clone, Default)]
265pub struct MotionBindings {
266    /// Animated X translation
267    pub translate_x: Option<SharedAnimatedValue>,
268    /// Animated Y translation
269    pub translate_y: Option<SharedAnimatedValue>,
270    /// Animated uniform scale
271    pub scale: Option<SharedAnimatedValue>,
272    /// Animated X scale
273    pub scale_x: Option<SharedAnimatedValue>,
274    /// Animated Y scale
275    pub scale_y: Option<SharedAnimatedValue>,
276    /// Animated rotation (degrees) - spring-based
277    pub rotation: Option<SharedAnimatedValue>,
278    /// Animated rotation (degrees) - timeline-based for continuous spin
279    pub rotation_timeline: Option<TimelineRotation>,
280    /// Animated opacity
281    pub opacity: Option<SharedAnimatedValue>,
282}
283
284impl MotionBindings {
285    /// Check if any bindings are set
286    pub fn is_empty(&self) -> bool {
287        self.translate_x.is_none()
288            && self.translate_y.is_none()
289            && self.scale.is_none()
290            && self.scale_x.is_none()
291            && self.scale_y.is_none()
292            && self.rotation.is_none()
293            && self.rotation_timeline.is_none()
294            && self.opacity.is_none()
295    }
296
297    /// Get the current translation from animated values
298    ///
299    /// Returns a translation transform for the tx/ty bindings.
300    /// Scale and rotation should be queried separately for proper centered application.
301    pub fn get_transform(&self) -> Option<Transform> {
302        let tx = self
303            .translate_x
304            .as_ref()
305            .map(|v| v.lock().unwrap().get())
306            .unwrap_or(0.0);
307        let ty = self
308            .translate_y
309            .as_ref()
310            .map(|v| v.lock().unwrap().get())
311            .unwrap_or(0.0);
312
313        if tx.abs() > 0.001 || ty.abs() > 0.001 {
314            Some(Transform::translate(tx, ty))
315        } else {
316            None
317        }
318    }
319
320    /// Get the current scale values from animated bindings
321    ///
322    /// Returns (scale_x, scale_y) if any scale is bound.
323    /// The renderer should apply this centered around the element.
324    pub fn get_scale(&self) -> Option<(f32, f32)> {
325        let scale = self.scale.as_ref().map(|v| v.lock().unwrap().get());
326        let scale_x = self.scale_x.as_ref().map(|v| v.lock().unwrap().get());
327        let scale_y = self.scale_y.as_ref().map(|v| v.lock().unwrap().get());
328
329        if let Some(s) = scale {
330            Some((s, s))
331        } else if scale_x.is_some() || scale_y.is_some() {
332            Some((scale_x.unwrap_or(1.0), scale_y.unwrap_or(1.0)))
333        } else {
334            None
335        }
336    }
337
338    /// Get the current rotation from animated values (in degrees)
339    ///
340    /// The renderer should apply this centered around the element.
341    /// Checks timeline-based rotation first, then spring-based.
342    pub fn get_rotation(&self) -> Option<f32> {
343        // Timeline rotation takes precedence (for continuous spinning)
344        if let Some(ref tl_rot) = self.rotation_timeline {
345            if let Ok(timeline) = tl_rot.timeline.lock() {
346                return timeline.get(tl_rot.entry_id);
347            }
348        }
349        // Fall back to spring-based rotation
350        self.rotation.as_ref().map(|v| v.lock().unwrap().get())
351    }
352
353    /// Get the current opacity from animated value
354    pub fn get_opacity(&self) -> Option<f32> {
355        self.opacity.as_ref().map(|v| v.lock().unwrap().get())
356    }
357}
358
359/// Motion container for animations
360///
361/// Wraps child elements and applies animations. Supports:
362/// - Entry/exit animations (one-time on mount/unmount)
363/// - Continuous animations driven by `AnimatedValue` bindings
364///
365/// The container itself is transparent but can have layout properties
366/// to control how children are arranged (flex direction, gap, etc.).
367///
368/// # Stable Keys
369///
370/// Motion containers automatically generate a stable key based on their call site
371/// (file, line, column). This allows animations to persist across tree rebuilds,
372/// which is essential for overlays and other dynamically rebuilt content.
373///
374/// For additional uniqueness (e.g., in loops), use `.id()` to append a suffix:
375///
376/// ```ignore
377/// for i in 0..items.len() {
378///     motion()
379///         .id(i)  // Appends index to auto-generated key
380///         .fade_in(300)
381///         .child(item_content)
382/// }
383/// ```
384pub struct Motion {
385    /// Children to animate (single or multiple)
386    children: Vec<Box<dyn ElementBuilder>>,
387    /// Entry animation
388    enter: Option<ElementAnimation>,
389    /// Exit animation
390    exit: Option<ElementAnimation>,
391    /// Stagger configuration for multiple children
392    stagger_config: Option<StaggerConfig>,
393    /// Layout style for the container
394    style: Style,
395
396    /// Stable key for motion tracking across tree rebuilds
397    /// Auto-generated with UUID for uniqueness even in loops/closures
398    key: InstanceKey,
399
400    /// Whether to use stable keying for this motion
401    /// When true (default), animation state persists across tree rebuilds using stable_key
402    /// When false, each new node gets a fresh animation (useful for tabs, lists)
403    use_stable_key: bool,
404
405    /// Whether to replay the animation even if the motion already exists
406    /// When true, the animation restarts from the beginning
407    /// Useful for tab transitions where content changes but key stays stable
408    replay: bool,
409
410    /// Whether to start in suspended state (waiting for explicit start)
411    /// When true, the motion starts with opacity 0 and waits for `query_motion(key).start()`
412    /// to trigger the enter animation. Useful for tab transitions where you want to
413    /// mount content invisibly, then trigger animation manually.
414    suspended: bool,
415
416    /// Callback to invoke when the motion container is laid out and ready
417    /// Used with `suspended` to trigger animation start after content is mounted
418    on_ready_callback: Option<Arc<dyn Fn(ElementBounds) + Send + Sync>>,
419
420    // =========================================================================
421    // Continuous animation bindings (AnimatedValue driven)
422    // =========================================================================
423    /// Animated X translation
424    translate_x: Option<SharedAnimatedValue>,
425    /// Animated Y translation
426    translate_y: Option<SharedAnimatedValue>,
427    /// Animated uniform scale
428    scale: Option<SharedAnimatedValue>,
429    /// Animated X scale
430    scale_x: Option<SharedAnimatedValue>,
431    /// Animated Y scale
432    scale_y: Option<SharedAnimatedValue>,
433    /// Animated rotation (degrees) - spring-based
434    rotation: Option<SharedAnimatedValue>,
435    /// Animated rotation (degrees) - timeline-based for continuous spin
436    rotation_timeline: Option<TimelineRotation>,
437    /// Animated opacity
438    opacity: Option<SharedAnimatedValue>,
439    /// DEPRECATED: Whether the overlay was closing when this motion was constructed
440    ///
441    /// This field is deprecated and always false. Motion exit is now triggered
442    /// explicitly via `MotionHandle.exit()` / `query_motion(key).exit()`.
443    #[deprecated(
444        since = "0.1.0",
445        note = "Use query_motion(key).exit() to explicitly trigger motion exit"
446    )]
447    is_exiting: bool,
448
449    /// Whether this motion container should be transparent to pointer events.
450    /// When true, clicks that don't hit a child will pass through to siblings.
451    /// Useful for overlay wrappers that should let backdrop clicks through.
452    pointer_events_none: bool,
453}
454
455/// Convert a MotionKeyframe to KeyframeProperties for animation system integration
456fn motion_keyframe_to_properties(kf: &MotionKeyframe) -> blinc_animation::KeyframeProperties {
457    let mut props = blinc_animation::KeyframeProperties::default();
458
459    if let Some(opacity) = kf.opacity {
460        props = props.with_opacity(opacity);
461    }
462    if let Some(scale_x) = kf.scale_x {
463        props.scale_x = Some(scale_x);
464    }
465    if let Some(scale_y) = kf.scale_y {
466        props.scale_y = Some(scale_y);
467    }
468    if let Some(tx) = kf.translate_x {
469        props.translate_x = Some(tx);
470    }
471    if let Some(ty) = kf.translate_y {
472        props.translate_y = Some(ty);
473    }
474    if let Some(rotate) = kf.rotate {
475        props.rotate = Some(rotate);
476    }
477
478    props
479}
480
481/// Create a motion container
482///
483/// The motion container automatically generates a stable unique key using UUID,
484/// ensuring uniqueness even when created in loops or closures.
485///
486/// For additional uniqueness in loops, use `.id()`:
487///
488/// ```ignore
489/// for i in 0..items.len() {
490///     motion()
491///         .id(i)  // Appends index to auto-generated key
492///         .fade_in(300)
493///         .child(item_content)
494/// }
495/// ```
496#[track_caller]
497#[allow(deprecated)]
498pub fn motion() -> Motion {
499    Motion {
500        children: Vec::new(),
501        enter: None,
502        exit: None,
503        stagger_config: None,
504        style: Style {
505            display: Display::Flex,
506            flex_direction: FlexDirection::Column,
507            // Default to filling parent container (acts as transparent wrapper)
508            size: taffy::Size {
509                width: taffy::Dimension::Percent(1.0),
510                height: taffy::Dimension::Auto,
511            },
512            flex_grow: 1.0,
513            ..Style::default()
514        },
515        key: InstanceKey::new("motion"),
516        use_stable_key: true, // Default to stable keying for overlays
517        replay: false,
518        suspended: false,
519        on_ready_callback: None,
520        translate_x: None,
521        translate_y: None,
522        scale: None,
523        scale_x: None,
524        scale_y: None,
525        rotation: None,
526        rotation_timeline: None,
527        opacity: None,
528        // Motion exit is now triggered explicitly via MotionHandle.exit()
529        // The is_exiting field is deprecated and always false
530        is_exiting: false,
531        pointer_events_none: false,
532    }
533}
534
535/// Create a motion container with a key derived from a parent key.
536///
537/// Use this inside `on_state` callbacks or other contexts where the motion
538/// is recreated on each rebuild. By deriving from a stable parent key,
539/// the motion's animation state persists across rebuilds.
540///
541/// # Example
542///
543/// ```ignore
544/// // In a component with a stable key
545/// let key = InstanceKey::new("tabs");
546///
547/// Stateful::with_shared_state(state)
548///     .on_state(move |_, container| {
549///         // Derive motion key from parent - stable across rebuilds
550///         let m = motion_derived(&key.derive("content"))
551///             .fade_in(200)
552///             .child(content);
553///         container.merge(div().child(m));
554///     })
555/// ```
556#[allow(deprecated)]
557pub fn motion_derived(parent_key: &str) -> Motion {
558    Motion {
559        children: Vec::new(),
560        enter: None,
561        exit: None,
562        stagger_config: None,
563        style: Style {
564            display: Display::Flex,
565            flex_direction: FlexDirection::Column,
566            size: taffy::Size {
567                width: taffy::Dimension::Percent(1.0),
568                height: taffy::Dimension::Auto,
569            },
570            flex_grow: 1.0,
571            ..Style::default()
572        },
573        key: InstanceKey::explicit(format!("motion:{}", parent_key)),
574        use_stable_key: true,
575        replay: false,
576        suspended: false,
577        on_ready_callback: None,
578        translate_x: None,
579        translate_y: None,
580        scale: None,
581        scale_x: None,
582        scale_y: None,
583        rotation: None,
584        rotation_timeline: None,
585        opacity: None,
586        // Motion exit is now triggered explicitly via MotionHandle.exit()
587        // The is_exiting field is deprecated and always false
588        is_exiting: false,
589        pointer_events_none: false,
590    }
591}
592
593impl Motion {
594    /// Set the child element to animate
595    pub fn child(mut self, child: impl ElementBuilder + 'static) -> Self {
596        // Store single child in children vec so it's returned by children_builders()
597        self.children = vec![Box::new(child)];
598        self
599    }
600
601    /// Add multiple children with stagger animation support
602    pub fn children<I, E>(mut self, children: I) -> Self
603    where
604        I: IntoIterator<Item = E>,
605        E: ElementBuilder + 'static,
606    {
607        self.children = children
608            .into_iter()
609            .map(|c| Box::new(c) as Box<dyn ElementBuilder>)
610            .collect();
611        self
612    }
613
614    /// Set animation to play when element enters the tree
615    pub fn enter_animation(mut self, animation: impl Into<ElementAnimation>) -> Self {
616        self.enter = Some(animation.into());
617        self
618    }
619
620    /// Set animation to play when element exits the tree
621    pub fn exit_animation(mut self, animation: impl Into<ElementAnimation>) -> Self {
622        self.exit = Some(animation.into());
623        self
624    }
625
626    /// Enable stagger animations for multiple children
627    pub fn stagger(mut self, config: StaggerConfig) -> Self {
628        self.stagger_config = Some(config);
629        self
630    }
631
632    /// Set both enter and exit animations from a MotionAnimation config
633    ///
634    /// This is useful when you have a pre-built `MotionAnimation` from CSS
635    /// keyframes or other sources.
636    pub fn animation(self, config: MotionAnimation) -> Self {
637        use blinc_animation::{Easing, KeyframeProperties};
638
639        let mut result = self;
640
641        if let Some(ref enter_from) = config.enter_from {
642            // Build enter animation: start from enter_from, animate to defaults (visible)
643            let from_props = motion_keyframe_to_properties(enter_from);
644            let to_props = KeyframeProperties::default()
645                .with_opacity(1.0)
646                .with_scale(1.0)
647                .with_translate(0.0, 0.0);
648
649            let enter = MultiKeyframeAnimation::new(config.enter_duration_ms)
650                .keyframe(0.0, from_props, Easing::Linear)
651                .keyframe(1.0, to_props, Easing::EaseOut);
652
653            result = result.enter_animation(enter);
654        }
655
656        if let Some(ref exit_to) = config.exit_to {
657            // Build exit animation: start from defaults (visible), animate to exit_to
658            let from_props = KeyframeProperties::default()
659                .with_opacity(1.0)
660                .with_scale(1.0)
661                .with_translate(0.0, 0.0);
662            let to_props = motion_keyframe_to_properties(exit_to);
663
664            let exit = MultiKeyframeAnimation::new(config.exit_duration_ms)
665                .keyframe(0.0, from_props, Easing::Linear)
666                .keyframe(1.0, to_props, Easing::EaseIn);
667
668            result = result.exit_animation(exit);
669        }
670
671        result
672    }
673
674    // ========================================================================
675    // Convenience methods for common animations
676    // ========================================================================
677
678    /// Fade in on enter
679    pub fn fade_in(self, duration_ms: u32) -> Self {
680        self.enter_animation(AnimationPreset::fade_in(duration_ms))
681    }
682
683    /// Fade out on exit
684    pub fn fade_out(self, duration_ms: u32) -> Self {
685        self.exit_animation(AnimationPreset::fade_out(duration_ms))
686    }
687
688    /// Scale in on enter
689    pub fn scale_in(self, duration_ms: u32) -> Self {
690        self.enter_animation(AnimationPreset::scale_in(duration_ms))
691    }
692
693    /// Scale out on exit
694    pub fn scale_out(self, duration_ms: u32) -> Self {
695        self.exit_animation(AnimationPreset::scale_out(duration_ms))
696    }
697
698    /// Bounce in on enter
699    pub fn bounce_in(self, duration_ms: u32) -> Self {
700        self.enter_animation(AnimationPreset::bounce_in(duration_ms))
701    }
702
703    /// Bounce out on exit
704    pub fn bounce_out(self, duration_ms: u32) -> Self {
705        self.exit_animation(AnimationPreset::bounce_out(duration_ms))
706    }
707
708    /// Slide in from direction
709    pub fn slide_in(self, direction: SlideDirection, duration_ms: u32) -> Self {
710        let distance = 50.0;
711        let anim = match direction {
712            SlideDirection::Left => AnimationPreset::slide_in_left(duration_ms, distance),
713            SlideDirection::Right => AnimationPreset::slide_in_right(duration_ms, distance),
714            SlideDirection::Top => AnimationPreset::slide_in_top(duration_ms, distance),
715            SlideDirection::Bottom => AnimationPreset::slide_in_bottom(duration_ms, distance),
716        };
717        self.enter_animation(anim)
718    }
719
720    /// Slide out to direction
721    pub fn slide_out(self, direction: SlideDirection, duration_ms: u32) -> Self {
722        let distance = 50.0;
723        let anim = match direction {
724            SlideDirection::Left => AnimationPreset::slide_out_left(duration_ms, distance),
725            SlideDirection::Right => AnimationPreset::slide_out_right(duration_ms, distance),
726            SlideDirection::Top => AnimationPreset::slide_out_top(duration_ms, distance),
727            SlideDirection::Bottom => AnimationPreset::slide_out_bottom(duration_ms, distance),
728        };
729        self.exit_animation(anim)
730    }
731
732    /// Pop in (scale with overshoot)
733    pub fn pop_in(self, duration_ms: u32) -> Self {
734        self.enter_animation(AnimationPreset::pop_in(duration_ms))
735    }
736
737    // ========================================================================
738    // Stylesheet Integration
739    // ========================================================================
740
741    /// Apply animation from a CSS stylesheet's `@keyframes` definition
742    ///
743    /// This is an alternative to `to_motion_animation()` that works with the
744    /// Motion builder API. It looks up the named keyframes and applies them
745    /// as enter/exit animations.
746    ///
747    /// # Arguments
748    ///
749    /// * `stylesheet` - The parsed CSS stylesheet containing @keyframes
750    /// * `animation_name` - The name of the @keyframes to use
751    /// * `enter_duration_ms` - Duration for enter animation
752    /// * `exit_duration_ms` - Duration for exit animation
753    ///
754    /// # Example
755    ///
756    /// ```ignore
757    /// let css = r#"
758    ///     @keyframes modal-enter {
759    ///         from { opacity: 0; transform: scale(0.95); }
760    ///         to { opacity: 1; transform: scale(1); }
761    ///     }
762    /// "#;
763    /// let stylesheet = Stylesheet::parse_with_errors(css).stylesheet;
764    ///
765    /// motion()
766    ///     .from_stylesheet(&stylesheet, "modal-enter", 300, 200)
767    ///     .child(modal_content)
768    /// ```
769    pub fn from_stylesheet(
770        self,
771        stylesheet: &crate::css_parser::Stylesheet,
772        animation_name: &str,
773        enter_duration_ms: u32,
774        exit_duration_ms: u32,
775    ) -> Self {
776        if let Some(keyframes) = stylesheet.get_keyframes(animation_name) {
777            let motion_anim = keyframes.to_motion_animation(enter_duration_ms, exit_duration_ms);
778            self.animation(motion_anim)
779        } else {
780            tracing::warn!(
781                animation_name = animation_name,
782                "Keyframes not found in stylesheet"
783            );
784            self
785        }
786    }
787
788    /// Apply animation from @keyframes with custom easing
789    ///
790    /// Similar to `from_stylesheet` but uses the `MultiKeyframeAnimation`
791    /// system for more complex multi-step animations with custom easing.
792    ///
793    /// # Example
794    ///
795    /// ```ignore
796    /// let css = r#"
797    ///     @keyframes pulse {
798    ///         0%, 100% { opacity: 1; transform: scale(1); }
799    ///         50% { opacity: 0.8; transform: scale(1.05); }
800    ///     }
801    /// "#;
802    /// let stylesheet = Stylesheet::parse_with_errors(css).stylesheet;
803    ///
804    /// motion()
805    ///     .keyframes_from_stylesheet(&stylesheet, "pulse", 1000, Easing::EaseInOut)
806    ///     .child(button_content)
807    /// ```
808    pub fn keyframes_from_stylesheet(
809        self,
810        stylesheet: &crate::css_parser::Stylesheet,
811        animation_name: &str,
812        duration_ms: u32,
813        easing: blinc_animation::Easing,
814    ) -> Self {
815        if let Some(keyframes) = stylesheet.get_keyframes(animation_name) {
816            let animation = keyframes.to_multi_keyframe_animation(duration_ms, easing);
817            self.enter_animation(animation)
818        } else {
819            tracing::warn!(
820                animation_name = animation_name,
821                "Keyframes not found in stylesheet"
822            );
823            self
824        }
825    }
826
827    // ========================================================================
828    // Continuous Animation Bindings (AnimatedValue driven)
829    // ========================================================================
830
831    /// Bind X translation to an AnimatedValue
832    ///
833    /// The motion element's X position will track this animated value.
834    pub fn translate_x(mut self, value: SharedAnimatedValue) -> Self {
835        self.translate_x = Some(value);
836        self
837    }
838
839    /// Bind Y translation to an AnimatedValue
840    ///
841    /// The motion element's Y position will track this animated value.
842    /// Perfect for pull-to-refresh, swipe gestures, etc.
843    pub fn translate_y(mut self, value: SharedAnimatedValue) -> Self {
844        self.translate_y = Some(value);
845        self
846    }
847
848    /// Bind uniform scale to an AnimatedValue
849    ///
850    /// Scales both X and Y uniformly.
851    pub fn scale(mut self, value: SharedAnimatedValue) -> Self {
852        self.scale = Some(value);
853        self
854    }
855
856    /// Bind X scale to an AnimatedValue
857    pub fn scale_x(mut self, value: SharedAnimatedValue) -> Self {
858        self.scale_x = Some(value);
859        self
860    }
861
862    /// Bind Y scale to an AnimatedValue
863    pub fn scale_y(mut self, value: SharedAnimatedValue) -> Self {
864        self.scale_y = Some(value);
865        self
866    }
867
868    /// Bind rotation to an AnimatedValue (in degrees)
869    pub fn rotate(mut self, value: SharedAnimatedValue) -> Self {
870        self.rotation = Some(value);
871        self
872    }
873
874    /// Bind rotation to a timeline for continuous spinning (in degrees)
875    ///
876    /// Use this for spinners and other continuously rotating elements.
877    /// The timeline should be configured with infinite looping.
878    ///
879    /// # Example
880    ///
881    /// ```ignore
882    /// let timeline = ctx.use_animated_timeline();
883    /// let entry_id = timeline.lock().unwrap().configure(|t| {
884    ///     let id = t.add(0, 1000, 0.0, 360.0);
885    ///     t.set_loop(-1);
886    ///     t.start();
887    ///     id
888    /// });
889    /// motion()
890    ///     .rotate_timeline(timeline, entry_id)
891    ///     .child(spinner_visual)
892    /// ```
893    pub fn rotate_timeline(
894        mut self,
895        timeline: blinc_animation::SharedAnimatedTimeline,
896        entry_id: blinc_animation::TimelineEntryId,
897    ) -> Self {
898        self.rotation_timeline = Some(TimelineRotation { timeline, entry_id });
899        self
900    }
901
902    /// Bind opacity to an AnimatedValue (0.0 to 1.0)
903    pub fn opacity(mut self, value: SharedAnimatedValue) -> Self {
904        self.opacity = Some(value);
905        self
906    }
907
908    /// Check if any continuous animations are bound
909    pub fn has_animated_bindings(&self) -> bool {
910        self.translate_x.is_some()
911            || self.translate_y.is_some()
912            || self.scale.is_some()
913            || self.scale_x.is_some()
914            || self.scale_y.is_some()
915            || self.rotation.is_some()
916            || self.rotation_timeline.is_some()
917            || self.opacity.is_some()
918    }
919
920    /// Get the motion bindings for this element
921    ///
922    /// These bindings are stored in the RenderTree and sampled every frame
923    /// during rendering to apply continuous animations.
924    pub fn get_motion_bindings(&self) -> Option<MotionBindings> {
925        if !self.has_animated_bindings() {
926            return None;
927        }
928
929        Some(MotionBindings {
930            translate_x: self.translate_x.clone(),
931            translate_y: self.translate_y.clone(),
932            scale: self.scale.clone(),
933            scale_x: self.scale_x.clone(),
934            scale_y: self.scale_y.clone(),
935            rotation: self.rotation.clone(),
936            rotation_timeline: self.rotation_timeline.clone(),
937            opacity: self.opacity.clone(),
938        })
939    }
940
941    // ========================================================================
942    // Layout methods - control how children are arranged
943    // ========================================================================
944
945    /// Set the gap between children (in pixels)
946    pub fn gap(mut self, gap: f32) -> Self {
947        self.style.gap = taffy::Size {
948            width: taffy::LengthPercentage::Length(gap),
949            height: taffy::LengthPercentage::Length(gap),
950        };
951        self
952    }
953
954    /// Set flex direction to row
955    pub fn flex_row(mut self) -> Self {
956        self.style.flex_direction = FlexDirection::Row;
957        self
958    }
959
960    /// Set flex direction to column
961    pub fn flex_col(mut self) -> Self {
962        self.style.flex_direction = FlexDirection::Column;
963        self
964    }
965
966    /// Align items to center (cross-axis)
967    pub fn items_center(mut self) -> Self {
968        self.style.align_items = Some(taffy::AlignItems::Center);
969        self
970    }
971
972    /// Align items to start (cross-axis)
973    pub fn items_start(mut self) -> Self {
974        self.style.align_items = Some(taffy::AlignItems::FlexStart);
975        self
976    }
977
978    /// Justify content to center (main-axis)
979    pub fn justify_center(mut self) -> Self {
980        self.style.justify_content = Some(taffy::JustifyContent::Center);
981        self
982    }
983
984    /// Justify content with space between (main-axis)
985    pub fn justify_between(mut self) -> Self {
986        self.style.justify_content = Some(taffy::JustifyContent::SpaceBetween);
987        self
988    }
989
990    /// Set width to 100% of parent
991    pub fn w_full(mut self) -> Self {
992        self.style.size.width = taffy::Dimension::Percent(1.0);
993        self
994    }
995
996    /// Set height to 100% of parent
997    pub fn h_full(mut self) -> Self {
998        self.style.size.height = taffy::Dimension::Percent(1.0);
999        self
1000    }
1001
1002    /// Allow this element to grow to fill available space
1003    pub fn flex_grow(mut self) -> Self {
1004        self.style.flex_grow = 1.0;
1005        self
1006    }
1007
1008    /// Get the enter animation if set
1009    pub fn get_enter_animation(&self) -> Option<&ElementAnimation> {
1010        self.enter.as_ref()
1011    }
1012
1013    /// Get the exit animation if set
1014    pub fn get_exit_animation(&self) -> Option<&ElementAnimation> {
1015        self.exit.as_ref()
1016    }
1017
1018    /// Get the stagger config if set
1019    pub fn get_stagger_config(&self) -> Option<&StaggerConfig> {
1020        self.stagger_config.as_ref()
1021    }
1022
1023    // ========================================================================
1024    // ID for uniqueness in loops/lists
1025    // ========================================================================
1026
1027    /// Append an ID suffix for additional uniqueness
1028    ///
1029    /// Motion containers automatically generate a unique key using UUID.
1030    /// Use `.id()` when you need additional uniqueness, such as in loops or lists.
1031    ///
1032    /// The provided ID is appended to the generated key.
1033    ///
1034    /// # Example
1035    ///
1036    /// ```ignore
1037    /// for (i, item) in items.iter().enumerate() {
1038    ///     motion()
1039    ///         .id(i)  // Creates unique key for each iteration
1040    ///         .fade_in(300)
1041    ///         .child(item_content)
1042    /// }
1043    /// ```
1044    pub fn id(mut self, id: impl std::fmt::Display) -> Self {
1045        // Create a new key that includes the user-provided suffix
1046        let new_key = format!("{}:{}", self.key.get(), id);
1047        self.key = InstanceKey::explicit(new_key);
1048        self
1049    }
1050
1051    /// Get the stable key for this motion container
1052    ///
1053    /// Returns the unique key (UUID-based) with any user-provided ID suffixes appended.
1054    pub fn get_stable_key(&self) -> &str {
1055        self.key.get()
1056    }
1057
1058    /// Make this motion transient (animation replays on each rebuild)
1059    ///
1060    /// By default, motion containers persist their animation state across tree
1061    /// rebuilds using a stable key. This is essential for overlays that rebuild
1062    /// frequently but should maintain animation continuity.
1063    ///
1064    /// For content that changes frequently (like tab panels, list items),
1065    /// use `.transient()` so the enter animation replays each time the
1066    /// content appears.
1067    ///
1068    /// # Example
1069    ///
1070    /// ```ignore
1071    /// // Tab content that animates on every tab switch
1072    /// motion()
1073    ///     .transient()
1074    ///     .fade_in(150)
1075    ///     .child(tab_content)
1076    /// ```
1077    pub fn transient(mut self) -> Self {
1078        self.use_stable_key = false;
1079        self
1080    }
1081
1082    /// Request the animation to replay from the beginning
1083    ///
1084    /// Use this with `motion_derived` when you want the animation to play
1085    /// each time the content changes, while still maintaining a stable key
1086    /// to prevent animation restarts on unrelated rebuilds.
1087    ///
1088    /// # Example
1089    ///
1090    /// ```ignore
1091    /// // Tab content that animates on every tab switch
1092    /// let motion_key = format!("{}:{}", base_key, active_tab);
1093    /// motion_derived(&motion_key)
1094    ///     .replay()  // Animation plays each time active_tab changes
1095    ///     .fade_in(150)
1096    ///     .child(tab_content)
1097    /// ```
1098    pub fn replay(mut self) -> Self {
1099        self.replay = true;
1100        self
1101    }
1102
1103    /// Check if replay is requested
1104    pub fn should_replay(&self) -> bool {
1105        self.replay
1106    }
1107
1108    /// Start in suspended state (waiting for explicit start)
1109    ///
1110    /// When enabled, the motion starts with opacity 0 and waits for
1111    /// `query_motion(key).start()` to trigger the enter animation.
1112    ///
1113    /// This is useful for tab transitions and other cases where you want to:
1114    /// 1. Mount the content invisibly
1115    /// 2. Perform any setup/measurement (via `on_ready`)
1116    /// 3. Then trigger the animation manually
1117    ///
1118    /// # Example
1119    ///
1120    /// ```ignore
1121    /// // In tabs.rs on_state callback:
1122    /// let motion_key = format!("tabs_motion:{}", active_tab);
1123    ///
1124    /// // Create suspended motion
1125    /// let m = motion_derived(&motion_key)
1126    ///     .suspended()
1127    ///     .enter_animation(enter)
1128    ///     .child(content);
1129    ///
1130    /// // Trigger animation after mounting (e.g., in on_ready callback)
1131    /// query_motion(&motion_key).start();
1132    /// ```
1133    pub fn suspended(mut self) -> Self {
1134        self.suspended = true;
1135        self
1136    }
1137
1138    /// Check if suspended mode is enabled
1139    pub fn is_suspended(&self) -> bool {
1140        self.suspended
1141    }
1142
1143    /// Make this motion container transparent to pointer events.
1144    ///
1145    /// When enabled, clicks that don't hit a child element will pass through
1146    /// to siblings (like the backdrop layer). This is essential for overlays
1147    /// like sheets and drawers where the motion wrapper shouldn't block
1148    /// backdrop click-to-dismiss behavior.
1149    ///
1150    /// # Example
1151    ///
1152    /// ```ignore
1153    /// // Sheet content with motion that allows backdrop clicks
1154    /// motion_derived(motion_key)
1155    ///     .pointer_events_none()
1156    ///     .enter_animation(slide_in)
1157    ///     .exit_animation(slide_out)
1158    ///     .child(sheet_panel)
1159    /// ```
1160    pub fn pointer_events_none(mut self) -> Self {
1161        self.pointer_events_none = true;
1162        self
1163    }
1164
1165    /// Register a callback to be invoked when the motion container is ready
1166    ///
1167    /// The callback fires once after the motion is laid out for the first time.
1168    /// This is the ideal place to start suspended animations after content is mounted.
1169    ///
1170    /// # Example
1171    ///
1172    /// ```ignore
1173    /// // Tab content with suspended animation that starts after mount
1174    /// let motion_key = format!("tabs_motion:{}", active_tab);
1175    /// let full_key = format!("motion:{}:child:0", motion_key);
1176    ///
1177    /// motion_derived(&motion_key)
1178    ///     .suspended()
1179    ///     .enter_animation(enter)
1180    ///     .on_ready(move |_bounds| {
1181    ///         // Content is mounted - start the animation
1182    ///         query_motion(&full_key).start();
1183    ///     })
1184    ///     .child(content)
1185    /// ```
1186    pub fn on_ready<F>(mut self, callback: F) -> Self
1187    where
1188        F: Fn(ElementBounds) + Send + Sync + 'static,
1189    {
1190        self.on_ready_callback = Some(Arc::new(callback));
1191        self
1192    }
1193
1194    /// Get the motion animation configuration for a child at given index
1195    ///
1196    /// Takes stagger into account to compute the correct delay for each child.
1197    pub fn motion_animation_for_child(&self, child_index: usize) -> Option<MotionAnimation> {
1198        let total_children = self.children.len();
1199
1200        if total_children == 0 {
1201            return None;
1202        }
1203
1204        // Calculate delay based on stagger config
1205        let delay_ms = if let Some(ref stagger) = self.stagger_config {
1206            stagger.delay_for_index(child_index, total_children)
1207        } else {
1208            0
1209        };
1210
1211        // Get base animation (from stagger config or direct enter/exit)
1212        let enter_anim = if let Some(ref stagger) = self.stagger_config {
1213            Some(&stagger.animation.animation)
1214        } else {
1215            self.enter.as_ref().map(|e| &e.animation)
1216        };
1217
1218        let exit_anim = self.exit.as_ref().map(|e| &e.animation);
1219
1220        // Build MotionAnimation
1221        if let Some(enter) = enter_anim {
1222            let enter_from = enter
1223                .first_keyframe()
1224                .map(|kf| MotionKeyframe::from_keyframe_properties(&kf.properties));
1225
1226            let mut motion = MotionAnimation {
1227                enter_from,
1228                enter_duration_ms: enter.duration_ms(),
1229                enter_delay_ms: delay_ms,
1230                exit_to: None,
1231                exit_duration_ms: 0,
1232            };
1233
1234            if let Some(exit) = exit_anim {
1235                motion.exit_to = exit
1236                    .last_keyframe()
1237                    .map(|kf| MotionKeyframe::from_keyframe_properties(&kf.properties));
1238                motion.exit_duration_ms = exit.duration_ms();
1239            }
1240
1241            Some(motion)
1242        } else if let Some(exit) = exit_anim {
1243            let exit_to = exit
1244                .last_keyframe()
1245                .map(|kf| MotionKeyframe::from_keyframe_properties(&kf.properties));
1246
1247            Some(MotionAnimation {
1248                enter_from: None,
1249                enter_duration_ms: 0,
1250                enter_delay_ms: delay_ms,
1251                exit_to,
1252                exit_duration_ms: exit.duration_ms(),
1253            })
1254        } else {
1255            None
1256        }
1257    }
1258
1259    /// Get the number of children
1260    pub fn child_count(&self) -> usize {
1261        self.children.len()
1262    }
1263}
1264
1265// ElementBuilder impl moved after MotionPresence section to keep it together
1266
1267// =============================================================================
1268// MotionPresence - State Machine for Enter/Exit Lifecycle
1269// =============================================================================
1270
1271/// Custom event types for motion presence state machine
1272pub mod motion_events {
1273    /// Content mounted, start enter animation
1274    pub const MOUNT: u32 = 30001;
1275    /// Enter animation completed
1276    pub const ENTER_COMPLETE: u32 = 30002;
1277    /// Request to exit (start exit animation)
1278    pub const EXIT: u32 = 30003;
1279    /// Exit animation completed
1280    pub const EXIT_COMPLETE: u32 = 30004;
1281    /// Key changed - new content is replacing old
1282    pub const KEY_CHANGED: u32 = 30005;
1283}
1284
1285/// State machine for motion presence lifecycle
1286///
1287/// Tracks whether content is entering, visible, or exiting.
1288/// Used by `MotionPresence` to manage sequenced enter/exit animations.
1289#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
1290pub enum MotionPresenceState {
1291    /// No content mounted
1292    #[default]
1293    Empty,
1294    /// Content mounted, enter animation playing
1295    Entering,
1296    /// Content fully visible
1297    Visible,
1298    /// Exit animation playing, content still rendered
1299    Exiting,
1300}
1301
1302impl MotionPresenceState {
1303    /// Check if content should be rendered
1304    pub fn is_mounted(&self) -> bool {
1305        !matches!(self, MotionPresenceState::Empty)
1306    }
1307
1308    /// Check if currently animating (entering or exiting)
1309    pub fn is_animating(&self) -> bool {
1310        matches!(
1311            self,
1312            MotionPresenceState::Entering | MotionPresenceState::Exiting
1313        )
1314    }
1315
1316    /// Check if exit animation is playing
1317    pub fn is_exiting(&self) -> bool {
1318        matches!(self, MotionPresenceState::Exiting)
1319    }
1320
1321    /// Check if fully visible and not animating
1322    pub fn is_visible(&self) -> bool {
1323        matches!(self, MotionPresenceState::Visible)
1324    }
1325}
1326
1327impl crate::stateful::StateTransitions for MotionPresenceState {
1328    fn on_event(&self, event: u32) -> Option<Self> {
1329        use motion_events::*;
1330        use MotionPresenceState::*;
1331
1332        match (self, event) {
1333            // Empty -> Entering: Content mounted
1334            (Empty, MOUNT) => Some(Entering),
1335
1336            // Entering -> Visible: Enter animation finished
1337            (Entering, ENTER_COMPLETE) => Some(Visible),
1338
1339            // Visible -> Exiting: Start exit animation
1340            (Visible, EXIT) | (Visible, KEY_CHANGED) => Some(Exiting),
1341
1342            // Exiting -> Empty: Exit animation finished, content removed
1343            (Exiting, EXIT_COMPLETE) => Some(Empty),
1344
1345            // If key changes while entering, let it finish then exit
1346            (Entering, KEY_CHANGED) => Some(Entering), // Queue the exit for later
1347
1348            // No transition
1349            _ => None,
1350        }
1351    }
1352}
1353
1354/// Tracked child that is currently exiting
1355#[derive(Clone)]
1356pub struct ExitingChild {
1357    /// Unique key for this child
1358    pub key: String,
1359    /// The motion key for tracking animation state
1360    pub motion_key: String,
1361}
1362
1363/// State tracked for MotionPresence via blinc_store
1364#[derive(Clone, Default)]
1365pub struct MotionPresenceStore {
1366    /// Current child key (if any)
1367    pub current_key: Option<String>,
1368    /// Children that are currently exiting
1369    pub exiting: Vec<ExitingChild>,
1370    /// Current state of the presence state machine
1371    pub state: MotionPresenceState,
1372    /// Whether we're waiting for enter animation to start
1373    pub pending_enter: bool,
1374}
1375
1376impl MotionPresenceStore {
1377    /// Check if a specific key is currently exiting
1378    pub fn is_key_exiting(&self, key: &str) -> bool {
1379        self.exiting.iter().any(|e| e.key == key)
1380    }
1381
1382    /// Remove a key from the exiting list
1383    pub fn remove_exiting(&mut self, key: &str) {
1384        self.exiting.retain(|e| e.key != key);
1385    }
1386
1387    /// Add a child to the exiting list
1388    pub fn add_exiting(&mut self, key: String, motion_key: String) {
1389        // Don't add duplicates
1390        if !self.is_key_exiting(&key) {
1391            self.exiting.push(ExitingChild { key, motion_key });
1392        }
1393    }
1394
1395    /// Check if we have any children exiting
1396    pub fn has_exiting(&self) -> bool {
1397        !self.exiting.is_empty()
1398    }
1399}
1400
1401/// Get the global store for MotionPresence states
1402pub fn motion_presence_store() -> &'static blinc_core::Store<MotionPresenceStore> {
1403    blinc_core::create_store::<MotionPresenceStore>("motion_presence")
1404}
1405
1406/// Query the current presence state for a given store key
1407pub fn query_presence_state(store_key: &str) -> MotionPresenceStore {
1408    motion_presence_store().get(store_key)
1409}
1410
1411/// Update the presence state for a given store key
1412pub fn update_presence_state<F>(store_key: &str, f: F)
1413where
1414    F: FnOnce(&mut MotionPresenceStore),
1415{
1416    motion_presence_store().update(store_key, f);
1417}
1418
1419/// Check and clear any completed exit animations for a store key
1420///
1421/// Returns true if any exits were cleared (meaning we should re-render).
1422pub fn check_and_clear_exiting(store_key: &str) -> bool {
1423    use crate::selector::query_motion;
1424
1425    let state = motion_presence_store().get(store_key);
1426    let mut cleared_any = false;
1427
1428    for child in &state.exiting {
1429        let motion = query_motion(&child.motion_key);
1430        // Motion is done if it's not animating (Visible, Removed, or NotFound)
1431        if !motion.is_animating() {
1432            tracing::debug!(
1433                "MotionPresence '{}': exit complete for '{}' (motion state: {:?})",
1434                store_key,
1435                child.key,
1436                motion.state()
1437            );
1438            cleared_any = true;
1439        }
1440    }
1441
1442    if cleared_any {
1443        motion_presence_store().update(store_key, |s| {
1444            s.exiting.retain(|child| {
1445                let motion = query_motion(&child.motion_key);
1446                motion.is_animating()
1447            });
1448        });
1449    }
1450
1451    cleared_any
1452}
1453
1454/// Start exit animation for a child and add to exiting list
1455pub fn start_exit_for_key(store_key: &str, child_key: &str, motion_key: &str) {
1456    use crate::selector::query_motion;
1457
1458    tracing::debug!(
1459        "MotionPresence '{}': starting exit for '{}' (motion: '{}')",
1460        store_key,
1461        child_key,
1462        motion_key
1463    );
1464
1465    // Trigger the exit animation
1466    query_motion(motion_key).exit();
1467
1468    // Add to exiting list
1469    motion_presence_store().update(store_key, |s| {
1470        s.add_exiting(child_key.to_string(), motion_key.to_string());
1471        s.state = MotionPresenceState::Exiting;
1472    });
1473}
1474
1475/// Check if all exits are complete and trigger enter for new content
1476pub fn check_ready_for_enter(store_key: &str) -> bool {
1477    let state = motion_presence_store().get(store_key);
1478
1479    // If we have pending enter and no more exiting children
1480    if state.pending_enter && state.exiting.is_empty() {
1481        tracing::debug!(
1482            "MotionPresence '{}': all exits complete, ready for enter",
1483            store_key
1484        );
1485
1486        motion_presence_store().update(store_key, |s| {
1487            s.pending_enter = false;
1488            s.state = MotionPresenceState::Entering;
1489        });
1490
1491        return true;
1492    }
1493
1494    false
1495}
1496
1497#[cfg(test)]
1498mod tests_motion_presence {
1499    use super::*;
1500
1501    #[test]
1502    fn test_motion_presence_state_transitions() {
1503        use crate::stateful::StateTransitions;
1504
1505        let state = MotionPresenceState::Empty;
1506        assert_eq!(
1507            state.on_event(motion_events::MOUNT),
1508            Some(MotionPresenceState::Entering)
1509        );
1510
1511        let state = MotionPresenceState::Entering;
1512        assert_eq!(
1513            state.on_event(motion_events::ENTER_COMPLETE),
1514            Some(MotionPresenceState::Visible)
1515        );
1516
1517        let state = MotionPresenceState::Visible;
1518        assert_eq!(
1519            state.on_event(motion_events::EXIT),
1520            Some(MotionPresenceState::Exiting)
1521        );
1522
1523        let state = MotionPresenceState::Exiting;
1524        assert_eq!(
1525            state.on_event(motion_events::EXIT_COMPLETE),
1526            Some(MotionPresenceState::Empty)
1527        );
1528    }
1529
1530    #[test]
1531    fn test_motion_presence_state_helpers() {
1532        assert!(!MotionPresenceState::Empty.is_mounted());
1533        assert!(MotionPresenceState::Entering.is_mounted());
1534        assert!(MotionPresenceState::Visible.is_mounted());
1535        assert!(MotionPresenceState::Exiting.is_mounted());
1536
1537        assert!(MotionPresenceState::Entering.is_animating());
1538        assert!(!MotionPresenceState::Visible.is_animating());
1539        assert!(MotionPresenceState::Exiting.is_animating());
1540
1541        assert!(MotionPresenceState::Exiting.is_exiting());
1542        assert!(!MotionPresenceState::Visible.is_exiting());
1543    }
1544}
1545
1546// ElementBuilder implementation for Motion
1547impl ElementBuilder for Motion {
1548    fn build(&self, tree: &mut LayoutTree) -> LayoutNodeId {
1549        // Push motion context so children know they're inside this motion
1550        // This enables stateful children to defer visual updates during animation
1551        if self.use_stable_key {
1552            push_motion_context(self.key.get());
1553        }
1554
1555        // Create a container node with the configured style
1556        let node = tree.create_node(self.style.clone());
1557
1558        // Build and add all children (stagger delay is computed later via motion_animation_for_child)
1559        for child in &self.children {
1560            let child_node = child.build(tree);
1561            tree.add_child(node, child_node);
1562        }
1563
1564        // Pop motion context when done building children
1565        if self.use_stable_key {
1566            pop_motion_context();
1567        }
1568
1569        node
1570    }
1571
1572    fn render_props(&self) -> RenderProps {
1573        // Motion with animated bindings uses motion_bindings() instead of static props.
1574        // Return default props - the actual transform/opacity will be sampled at render time.
1575        RenderProps {
1576            pointer_events_none: self.pointer_events_none,
1577            ..RenderProps::default()
1578        }
1579    }
1580
1581    fn children_builders(&self) -> &[Box<dyn ElementBuilder>] {
1582        // Return children vec - single child is now stored in children vec as well
1583        &self.children
1584    }
1585
1586    fn element_type_id(&self) -> ElementTypeId {
1587        ElementTypeId::Motion
1588    }
1589
1590    fn motion_animation_for_child(&self, child_index: usize) -> Option<MotionAnimation> {
1591        self.motion_animation_for_child(child_index)
1592    }
1593
1594    fn motion_bindings(&self) -> Option<MotionBindings> {
1595        self.get_motion_bindings()
1596    }
1597
1598    fn motion_stable_id(&self) -> Option<&str> {
1599        // Return stable key only if stable keying is enabled
1600        // When disabled, each node gets fresh animations (node-based tracking)
1601        if self.use_stable_key {
1602            Some(self.key.get())
1603        } else {
1604            None
1605        }
1606    }
1607
1608    fn motion_should_replay(&self) -> bool {
1609        self.replay
1610    }
1611
1612    fn motion_is_suspended(&self) -> bool {
1613        self.suspended
1614    }
1615
1616    #[allow(deprecated)]
1617    fn motion_is_exiting(&self) -> bool {
1618        self.is_exiting
1619    }
1620
1621    fn layout_style(&self) -> Option<&taffy::Style> {
1622        Some(&self.style)
1623    }
1624
1625    fn motion_on_ready_callback(&self) -> Option<Arc<dyn Fn(ElementBounds) + Send + Sync>> {
1626        self.on_ready_callback.clone()
1627    }
1628}
1629
1630#[cfg(test)]
1631mod tests {
1632    use super::*;
1633
1634    #[test]
1635    fn test_stagger_delay_forward() {
1636        let config = StaggerConfig::new(50, AnimationPreset::fade_in(300));
1637
1638        assert_eq!(config.delay_for_index(0, 5), 0);
1639        assert_eq!(config.delay_for_index(1, 5), 50);
1640        assert_eq!(config.delay_for_index(2, 5), 100);
1641        assert_eq!(config.delay_for_index(4, 5), 200);
1642    }
1643
1644    #[test]
1645    fn test_stagger_delay_reverse() {
1646        let config = StaggerConfig::new(50, AnimationPreset::fade_in(300)).reverse();
1647
1648        assert_eq!(config.delay_for_index(0, 5), 200);
1649        assert_eq!(config.delay_for_index(1, 5), 150);
1650        assert_eq!(config.delay_for_index(4, 5), 0);
1651    }
1652
1653    #[test]
1654    fn test_stagger_delay_from_center() {
1655        let config = StaggerConfig::new(50, AnimationPreset::fade_in(300)).from_center();
1656
1657        // For 5 items, center is index 2
1658        // Distances from center: [2, 1, 0, 1, 2]
1659        assert_eq!(config.delay_for_index(0, 5), 100); // 2 steps from center
1660        assert_eq!(config.delay_for_index(1, 5), 50); // 1 step from center
1661        assert_eq!(config.delay_for_index(2, 5), 0); // at center
1662        assert_eq!(config.delay_for_index(3, 5), 50); // 1 step from center
1663        assert_eq!(config.delay_for_index(4, 5), 100); // 2 steps from center
1664    }
1665
1666    #[test]
1667    fn test_stagger_delay_with_limit() {
1668        let config = StaggerConfig::new(50, AnimationPreset::fade_in(300)).limit(3);
1669
1670        assert_eq!(config.delay_for_index(0, 10), 0);
1671        assert_eq!(config.delay_for_index(3, 10), 150); // capped at limit
1672        assert_eq!(config.delay_for_index(5, 10), 150); // still capped
1673        assert_eq!(config.delay_for_index(9, 10), 150); // still capped
1674    }
1675}