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}