Skip to main content

tui_lipan/widgets/animated/
mod.rs

1mod layout;
2mod node;
3mod reconcile;
4
5pub(crate) use self::layout::measure_animated;
6pub use self::node::AnimatedNode;
7pub(crate) use self::reconcile::reconcile_animated;
8
9use std::hash::Hash;
10
11use crate::animation::{ExitAnimation, TransitionConfig};
12use crate::callback::Callback;
13use crate::core::element::{Element, ElementKind};
14use crate::layout::hash::LayoutHash;
15use crate::style::{Color, LayoutConstraints, Length};
16use crate::widgets::Spacer;
17
18/// Animate child opacity, revealed height, colors, and optional visual position changes.
19#[derive(Clone)]
20pub struct Animated {
21    pub(crate) child: Box<Element>,
22    pub(crate) opacity: f32,
23    pub(crate) opacity_fg_only: bool,
24    pub(crate) opacity_target: Option<Color>,
25    pub(crate) fg: Option<Color>,
26    pub(crate) bg: Option<Color>,
27    pub(crate) transition: TransitionConfig,
28    pub(crate) height: Option<Length>,
29    pub(crate) layout_height: Option<Length>,
30    pub(crate) position_transition: bool,
31    pub(crate) auto_exit: Option<ExitAnimation>,
32    pub(crate) on_opacity_transition_end: Option<Callback<()>>,
33    pub(crate) on_height_transition_end: Option<Callback<()>>,
34    pub(crate) on_position_transition_end: Option<Callback<()>>,
35}
36
37impl Default for Animated {
38    fn default() -> Self {
39        Self {
40            child: Box::new(Spacer::new().into()),
41            opacity: 1.0,
42            opacity_fg_only: false,
43            opacity_target: None,
44            auto_exit: None,
45            fg: None,
46            bg: None,
47            transition: TransitionConfig::default(),
48            height: None,
49            layout_height: None,
50            position_transition: false,
51            on_opacity_transition_end: None,
52            on_height_transition_end: None,
53            on_position_transition_end: None,
54        }
55    }
56}
57
58impl Animated {
59    /// Create an animated wrapper around `child`.
60    pub fn new(child: impl Into<Element>) -> Self {
61        Self {
62            child: Box::new(child.into()),
63            ..Self::default()
64        }
65    }
66
67    /// Set wrapped child content.
68    pub fn child(mut self, child: impl Into<Element>) -> Self {
69        self.child = Box::new(child.into());
70        self
71    }
72
73    /// Set target opacity (`0.0` transparent, `1.0` fully visible).
74    pub fn opacity(mut self, opacity: f32) -> Self {
75        self.opacity = opacity.clamp(0.0, 1.0);
76        self
77    }
78
79    /// When true, [`Animated::opacity`] only scales foreground alpha; cell backgrounds are unchanged.
80    pub fn opacity_fg_only(mut self, fg_only: bool) -> Self {
81        self.opacity_fg_only = fg_only;
82        self
83    }
84
85    /// When set, the opacity post-pass blends toward this color instead of the terminal backdrop.
86    ///
87    /// Only [`Animated::opacity`] is animated; changing this target mid-transition snaps immediately.
88    /// Composes with [`Animated::fg`] / [`Animated::bg`] (they set the base colors that the wash runs on)
89    /// and with [`Animated::opacity_fg_only`] (restricts the wash to foreground cells).
90    pub fn opacity_target(mut self, color: Color) -> Self {
91        self.opacity_target = Some(color);
92        self
93    }
94
95    /// Set target animated foreground color.
96    pub fn fg(mut self, color: Color) -> Self {
97        self.fg = Some(color);
98        self
99    }
100
101    /// Set target animated background color.
102    pub fn bg(mut self, color: Color) -> Self {
103        self.bg = Some(color);
104        self
105    }
106
107    /// Configure transition timing for this wrapper.
108    pub fn transition(mut self, transition: TransitionConfig) -> Self {
109        self.transition = transition;
110        self
111    }
112
113    /// Configure transition duration in milliseconds.
114    pub fn duration(mut self, ms: u64) -> Self {
115        self.transition.duration = std::time::Duration::from_millis(ms);
116        self
117    }
118
119    /// Configure transition easing.
120    pub fn easing(mut self, easing: crate::animation::Easing) -> Self {
121        self.transition.easing = easing;
122        self
123    }
124
125    /// Set optional animated height target.
126    ///
127    /// - `None`: wrapper height follows parent allocation.
128    /// - `Some(Length::Auto)`: uses measured child natural height.
129    /// - `Some(Length::Px(_))`: uses explicit pixel target.
130    pub fn height(mut self, height: Length) -> Self {
131        self.height = Some(height);
132        self
133    }
134
135    /// Override the height used for stack measurement and gap math while [`Animated::height`] still
136    /// drives the animated target.
137    ///
138    /// Use while collapsing so parents keep reserving natural height until
139    /// [`Animated::on_height_transition_end`] fires, then clear (`None`) so layout matches the final
140    /// target.
141    pub fn layout_height(mut self, height: Option<Length>) -> Self {
142        self.layout_height = height;
143        self
144    }
145
146    /// Enable or disable visual position transitions for this wrapper.
147    ///
148    /// When enabled on an existing keyed `Animated` node, layout rect changes animate visually from
149    /// the previous origin to the new final origin while hit-testing and layout use the final rect
150    /// immediately. Initial mount does not animate.
151    pub fn position_transition(mut self, enabled: bool) -> Self {
152        self.position_transition = enabled;
153        self
154    }
155
156    /// Called once when a height transition reaches its target (including zero-duration jumps).
157    pub fn on_height_transition_end(mut self, cb: Callback<()>) -> Self {
158        self.on_height_transition_end = Some(cb);
159        self
160    }
161
162    /// Called once when an opacity transition reaches its target (including zero-duration jumps).
163    pub fn on_opacity_transition_end(mut self, cb: Callback<()>) -> Self {
164        self.on_opacity_transition_end = Some(cb);
165        self
166    }
167
168    /// Called once when a position transition reaches its final layout origin.
169    ///
170    /// This also fires for zero-duration position transitions that snap immediately.
171    pub fn on_position_transition_end(mut self, cb: Callback<()>) -> Self {
172        self.on_position_transition_end = Some(cb);
173        self
174    }
175
176    /// Fade and collapse helper for mount/unmount transitions.
177    ///
178    /// Sets opacity, animated height, and duration in one call to drive the
179    /// standard "appear / disappear" animation. Pair with
180    /// [`Animated::on_exit_complete`] to be notified when the disappearance
181    /// finishes so the parent can actually drop the element from state.
182    ///
183    /// - `visible == true`: opacity `1.0`, height `Length::Auto`.
184    /// - `visible == false`: opacity `0.0`, height `Length::Px(0)`.
185    ///
186    /// Both directions use `duration_ms` and the wrapper's currently configured
187    /// easing (defaults to `EaseOutQuad`; override with [`Animated::easing`]).
188    ///
189    /// ```ignore
190    /// // state.visible: bool, state.removed: bool
191    /// if !state.removed {
192    ///     Animated::new(child)
193    ///         .exit(state.visible, 200)
194    ///         .on_exit_complete(ctx.link().callback(|_| Msg::Removed))
195    /// }
196    /// ```
197    pub fn exit(mut self, visible: bool, duration_ms: u64) -> Self {
198        self.opacity = if visible { 1.0 } else { 0.0 };
199        self.height = Some(if visible { Length::Auto } else { Length::Px(0) });
200        self.transition.duration = std::time::Duration::from_millis(duration_ms);
201        self
202    }
203
204    /// Play an exit animation automatically when this element is removed.
205    ///
206    /// [`Animated::exit`] requires the parent to keep the element in its own state until
207    /// [`Animated::on_exit_complete`] fires, because the reconciler frees any node that is not
208    /// re-described during `view()`. `auto_exit` moves that bookkeeping into the framework: the
209    /// element can simply stop being described, and its container retains the already-rendered
210    /// subtree, animates it out, and drops it.
211    ///
212    /// Takes anything that converts into an [`ExitAnimation`]. A bare duration is the common case
213    /// and means "fade out over this many milliseconds":
214    ///
215    /// ```ignore
216    /// // No `removed` flag, no on_exit_complete plumbing: dropping it from the list is enough.
217    /// VStack::new().children(state.rows.iter().map(|row| {
218    ///     Animated::new(row_view(row)).auto_exit(200).key(row.id)
219    /// }))
220    ///
221    /// // Or say what leaving should look like.
222    /// Animated::new(toast)
223    ///     .auto_exit(ExitAnimation::slide(180, 0, -1).with_collapse(true))
224    ///     .key(id)
225    /// ```
226    ///
227    /// # Requirements
228    ///
229    /// The element must carry a [`Key`](crate::Key) and sit directly in a `VStack`, `HStack`,
230    /// `ZStack`, or `Canvas`. Keys are how the container recognizes that a specific child left
231    /// rather than that the list merely reordered. Debug builds log when either is missing.
232    ///
233    /// # What the container decides
234    ///
235    /// Everything visual comes from the [`ExitAnimation`]. The one thing it does not control is
236    /// whether height collapses, because that is a layout question the parent owns:
237    ///
238    /// - A **`VStack` or `HStack`** always collapses, whatever the exit says. The collapse is what
239    ///   lets siblings reflow into the vacated space, so it is part of the container's contract.
240    /// - A **`Canvas` or `ZStack`** collapses only if the exit asked for it with
241    ///   [`ExitAnimation::with_collapse`]. Nothing reflows around a positioned child, so there is
242    ///   no space to reclaim and the collapse is a pure effect.
243    ///
244    /// A `Canvas` additionally draws exiting children *beneath* every live one, so a departing
245    /// element can never cover something the application is still describing.
246    ///
247    /// # Lifecycle and disposal
248    ///
249    /// A retained subtree is a **snapshot**, not a living element. The container keeps the node it
250    /// already reconciled; the element itself stopped being described, so on that same frame its
251    /// component state, hooks, command registrations, and scroll state were all disposed by the
252    /// ordinary sweep. Only the resolved node data survives, which is exactly enough to keep
253    /// painting it.
254    ///
255    /// The framework enforces what follows from that, so an exit cannot reach into a dropped
256    /// scope:
257    ///
258    /// - The subtree is **inert**: skipped for hit-testing, focus, and key routing. It cannot be
259    ///   clicked, cannot take focus, and receives no keys.
260    /// - Transition-end callbacks (`on_opacity_transition_end` and friends) do **not** fire during
261    ///   an automatic exit.
262    /// - Nothing re-runs `view()`, so no effect, command, or state read happens on its behalf.
263    ///
264    /// Retention also ends on a deadline derived from the exit duration, so a container that stops
265    /// being rendered mid-exit cannot hold the subtree indefinitely. Re-adding the same key before
266    /// the exit finishes cancels it and hands the live element back.
267    ///
268    /// Use [`Animated::exit`] with [`ExitQueue`](crate::animation::ExitQueue) instead when the app
269    /// needs to own the lifecycle, or when the exit has to change where the element's *children*
270    /// sit: a retained subtree is never re-laid out, so scaling and reflowing are out of reach.
271    /// See [`ExitAnimation`] for that boundary in full.
272    pub fn auto_exit(mut self, exit: impl Into<ExitAnimation>) -> Self {
273        // Deliberately touches neither `height` nor `transition`. Opting into an exit must not
274        // change how the element looks or lays out while it is alive; the exit carries its own
275        // duration and easing, and the collapse reads the node's real rectangle rather than a
276        // resolved `Length`.
277        self.auto_exit = Some(exit.into());
278        self
279    }
280
281    /// Callback fired once when an [`Animated::exit`]-style collapse finishes,
282    /// i.e. when the height transition reaches its final target.
283    ///
284    /// This is an alias for [`Animated::on_height_transition_end`] —
285    /// `exit(false, ..)` settles height last, so this fires when the element
286    /// has fully collapsed and is safe to remove from state.
287    pub fn on_exit_complete(self, cb: Callback<()>) -> Self {
288        self.on_height_transition_end(cb)
289    }
290}
291
292impl From<Animated> for Element {
293    fn from(value: Animated) -> Self {
294        let (min_w, min_h) = measure_animated(&value, None, None);
295        let mut layout = LayoutConstraints::default().min_width(Length::Px(min_w));
296        if value.height.is_none() {
297            layout = layout.min_height(Length::Px(min_h));
298        }
299        Element::new(ElementKind::Animated(value)).with_layout(layout)
300    }
301}
302
303impl LayoutHash for Animated {
304    fn layout_hash(
305        &self,
306        hasher: &mut impl std::hash::Hasher,
307        recurse: &dyn Fn(&Element) -> Option<u64>,
308    ) -> Option<()> {
309        self.opacity.to_bits().hash(hasher);
310        self.opacity_fg_only.hash(hasher);
311        self.opacity_target.hash(hasher);
312        self.transition.duration.hash(hasher);
313        self.transition.easing.hash(hasher);
314        self.height.hash(hasher);
315        self.layout_height.hash(hasher);
316        self.position_transition.hash(hasher);
317        recurse(self.child.as_ref())?.hash(hasher);
318        Some(())
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::widgets::Spacer;
326
327    #[test]
328    fn exit_visible_sets_full_opacity_and_auto_height() {
329        let a = Animated::new(Spacer::new()).exit(true, 200);
330        assert_eq!(a.opacity, 1.0);
331        assert_eq!(a.height, Some(Length::Auto));
332        assert_eq!(a.transition.duration.as_millis(), 200);
333    }
334
335    #[test]
336    fn exit_hidden_sets_zero_opacity_and_zero_height() {
337        let a = Animated::new(Spacer::new()).exit(false, 150);
338        assert_eq!(a.opacity, 0.0);
339        assert_eq!(a.height, Some(Length::Px(0)));
340        assert_eq!(a.transition.duration.as_millis(), 150);
341    }
342
343    #[test]
344    fn on_exit_complete_aliases_height_transition_end() {
345        let cb = Callback::new(|_: ()| {});
346        let a = Animated::new(Spacer::new()).on_exit_complete(cb);
347        assert!(a.on_height_transition_end.is_some());
348    }
349}