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}