Skip to main content

teksilo_widgets/animations/
collapse.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `Collapse` — a wrapper widget that animates its child between
5//! hidden and natural size when an external `Signal<bool>` toggles.
6//!
7//! Drives a `progress: Signal<f32>` ∈ [0, 1] (0 = collapsed,
8//! 1 = expanded) and reports its own size as `(natural_w, natural_h *
9//! progress)` while the child lays out at full natural size — the
10//! framework's clip pass crops the overflow. This keeps the animation
11//! visible across the *whole* duration, instead of compressing the
12//! visible portion into the final few milliseconds (which is what
13//! happened when an animated `MaxSize::max_height` slid against a
14//! 10000-px sentinel that vastly overshot the child's natural height).
15//!
16//! ```ignore
17//! let expanded = ctx.signal(false);
18//! ctx.add(Collapse::new(expanded.clone()).child(advanced_settings));
19//! // ...elsewhere:
20//! expanded.set(true);  // animates open over `motion.duration_collapse`
21//! ```
22//!
23//! Honors `prefers-reduced-motion`: under reduced motion, progress
24//! snaps to its end value instead of tweening.
25
26use std::cell::Cell;
27
28use teksilo_canvas::{Point, Rect, Size, SizeProposal};
29use teksilo_core::accessibility::AccessNodeBuilder;
30use teksilo_core::binding::BindingLevel;
31use teksilo_core::build_context::BuildContext;
32use teksilo_core::signal::Signal;
33use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
34use teksilo_core::widget_id::WidgetId;
35
36/// Below this progress value, the wrapper's reported width snaps to
37/// zero so a fully-collapsed Collapse doesn't claim any horizontal
38/// space (siblings in a tooltip footer must not be pushed off the row
39/// by an invisible-but-natural-width wrapper). Picked at the level
40/// where the wrapper's height is already sub-pixel anyway, so the
41/// width snap is invisible.
42const COLLAPSED_PROGRESS_EPSILON: f32 = 0.005;
43
44/// Wraps a child and animates it between hidden (progress=0) and
45/// natural size (progress=1), driven by an external `Signal<bool>`.
46pub struct Collapse {
47    expanded: Signal<bool>,
48    pending_child: Option<PendingChild>,
49    child_id: Option<WidgetId>,
50    /// Cached so external integrations (and tests) can read the
51    /// current animated 0..1 progress. Filled in on `build()`.
52    progress: Option<Signal<f32>>,
53    /// Last natural size computed by `size_that_fits`. `place_children`
54    /// reads it so the child is laid out at full natural dimensions
55    /// (the framework clips the overflow against `Collapse`'s smaller
56    /// reported bounds).
57    natural_size: Cell<Size>,
58}
59
60impl Collapse {
61    /// Build a collapse wrapper bound to `expanded`. Initially
62    /// collapsed iff `expanded.get()` is `false` at the first
63    /// `build()`.
64    pub fn new(expanded: Signal<bool>) -> Self {
65        Self {
66            expanded,
67            pending_child: None,
68            child_id: None,
69            progress: None,
70            natural_size: Cell::new(Size::ZERO),
71        }
72    }
73
74    /// Inline child widget (deferred insertion).
75    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
76        self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
77        self
78    }
79
80    /// Pre-registered child by `WidgetId`.
81    pub fn child_id(mut self, id: WidgetId) -> Self {
82        self.pending_child = Some(PendingChild::Id(id));
83        self
84    }
85}
86
87impl std::fmt::Debug for Collapse {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_struct("Collapse").finish()
90    }
91}
92
93impl Widget for Collapse {
94    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
95        // Resolve the child if it was provided inline.
96        if let Some(pending) = self.pending_child.take() {
97            self.child_id = Some(match pending {
98                PendingChild::Id(id) => id,
99                PendingChild::Deferred(w) => ctx.add_boxed(w),
100            });
101        }
102        let Some(child_id) = self.child_id else {
103            return vec![];
104        };
105
106        let initial = if self.expanded.get() { 1.0 } else { 0.0 };
107        let progress = ctx.animated_signal(initial);
108        self.progress = Some(progress.clone());
109
110        // Bind progress to *self* at relayout level: every animation
111        // tick re-runs `size_that_fits`, which reads `progress.get()`
112        // and updates the wrapper's reported height accordingly.
113        let id = ctx.self_id();
114        let registry = ctx.binding_registry();
115        progress.bind_to(id, registry, BindingLevel::Relayout);
116
117        // Drive the progress tween whenever `expanded` flips. The
118        // observer survives across rebuilds via `effect_handles`.
119        let collapse_anim = ctx.animate().collapse().standard();
120        let progress_for_effect = progress;
121        ctx.effect(&self.expanded, move |&expanded| {
122            let target = if expanded { 1.0 } else { 0.0 };
123            collapse_anim.to_or_snap(&progress_for_effect, target);
124        });
125
126        vec![child_id]
127    }
128
129    fn layout_response(
130        &self,
131        proposal: SizeProposal,
132        ctx: &LayoutContext,
133    ) -> teksilo_core::widget::LayoutResponse {
134        let Some(child_id) = self.child_id else {
135            return (proposal.resolve(0.0, 0.0)).into();
136        };
137        // Ask the child for its size against the *unmodified* proposal.
138        // We never propose a clipped height — that would let text
139        // wrap or images letterbox to the in-flight animated value
140        // and re-enter a layout feedback loop.
141        let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
142        self.natural_size.set(natural);
143
144        let progress = self
145            .progress
146            .as_ref()
147            .map(|s| s.get().clamp(0.0, 1.0))
148            .unwrap_or(1.0);
149
150        // Width snaps to 0 only when fully collapsed — during the
151        // tween the wrapper keeps its natural width so the child's
152        // text / icons / etc. continue to render at the proper
153        // measure (just clipped vertically by the framework).
154        let width = if progress < COLLAPSED_PROGRESS_EPSILON {
155            0.0
156        } else {
157            natural.width
158        };
159        Size::new(width, natural.height * progress).into()
160    }
161
162    fn place_children(
163        &self,
164        bounds: Rect,
165        _proposal: SizeProposal,
166        children: &mut [WidgetPlacement],
167        _ctx: &LayoutContext,
168    ) {
169        // Lay the child out at its FULL natural size and let the
170        // framework's clip pass crop the bottom overflow against
171        // `Collapse`'s reduced bounds. This is what makes the visible
172        // shrink track the animated progress linearly across the full
173        // duration: the child's internal layout doesn't reflow each
174        // frame, only the clip rect changes.
175        let natural = self.natural_size.get();
176        for child in children.iter_mut() {
177            child.origin = Point::new(bounds.x, bounds.y);
178            child.size = natural;
179        }
180    }
181
182    fn clips_children(&self) -> bool {
183        // Required: the child is sized to its natural dimensions but
184        // the wrapper's bounds are smaller during the tween, so the
185        // overflow must be clipped. Without this the child would
186        // render past the wrapper's reported size and overlap
187        // siblings below.
188        true
189    }
190
191    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
192        // Collapse is a layout/animation wrapper. The control that
193        // *toggles* the expanded state owns the a11y semantics
194        // (Role::Button + set_expanded); the content is announced by
195        // its own subtree when expanded. This widget itself is
196        // intentionally a11y-transparent.
197    }
198
199    fn children(&self) -> Vec<WidgetId> {
200        self.child_id.into_iter().collect()
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use std::time::Duration;
207
208    use super::*;
209    use crate::primitives::TextWidget;
210    use teksilo_core::widget_tree::WidgetTree;
211    use teksilo_i18n::lit;
212
213    #[test]
214    fn starts_collapsed_when_signal_is_false() {
215        let expanded = Signal::new(false);
216        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
217        let id = tree
218            .add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("hidden content"))));
219        tree.layout(SizeProposal {
220            width: Some(300.0),
221            height: None,
222        });
223        assert!(
224            tree.bounds(id).height < 1.0,
225            "collapsed bounds should be ~0, got {}",
226            tree.bounds(id).height
227        );
228    }
229
230    #[test]
231    fn starts_expanded_when_signal_is_true() {
232        let expanded = Signal::new(true);
233        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
234        let id = tree
235            .add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("visible content"))));
236        tree.layout(SizeProposal {
237            width: Some(300.0),
238            height: None,
239        });
240        assert!(
241            tree.bounds(id).height > 1.0,
242            "expanded bounds should be > 0, got {}",
243            tree.bounds(id).height
244        );
245    }
246
247    #[test]
248    fn flipping_signal_drives_animation() {
249        let expanded = Signal::new(false);
250        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
251        let id = tree.add(
252            Collapse::new(expanded.clone())
253                .child(TextWidget::new(lit!("content with some natural height"))),
254        );
255        tree.layout(SizeProposal {
256            width: Some(300.0),
257            height: None,
258        });
259        let collapsed = tree.bounds(id).height;
260
261        expanded.set(true);
262
263        tree.tick_animations(Duration::from_millis(300));
264        tree.layout(SizeProposal {
265            width: Some(300.0),
266            height: None,
267        });
268        let after = tree.bounds(id).height;
269
270        assert!(
271            after > collapsed,
272            "after expanding, height ({}) should exceed collapsed height ({})",
273            after,
274            collapsed
275        );
276    }
277
278    #[test]
279    fn collapse_height_shrinks_proportionally() {
280        // Start expanded; flip to collapsed; verify the wrapper
281        // height shrinks *proportionally* across the tween — the
282        // intermediate 50%-progress sample must be roughly half the
283        // initial height, NOT pinned at the natural height for 95% of
284        // the animation (the bug where max_h tweens against a 10000
285        // sentinel).
286        let expanded = Signal::new(true);
287        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
288        let root =
289            tree.add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("content"))));
290        tree.layout(SizeProposal {
291            width: Some(300.0),
292            height: None,
293        });
294        let initial_h = tree.bounds(root).height;
295        assert!(initial_h > 0.0);
296
297        expanded.set(false);
298
299        // Tick to ~halfway through the 200ms collapse and check the
300        // height is meaningfully below the initial — not snapped to 0
301        // and not still pinned at natural.
302        tree.tick_animations(Duration::from_millis(100));
303        tree.layout(SizeProposal {
304            width: Some(300.0),
305            height: None,
306        });
307        let mid_h = tree.bounds(root).height;
308        assert!(
309            mid_h < initial_h * 0.95,
310            "halfway through collapse, height ({}) should be visibly less than initial ({})",
311            mid_h,
312            initial_h
313        );
314        assert!(
315            mid_h > initial_h * 0.05,
316            "halfway through collapse, height ({}) should not yet be near zero ({})",
317            mid_h,
318            initial_h
319        );
320    }
321
322    #[test]
323    fn collapse_height_monotonically_decreases() {
324        let expanded = Signal::new(true);
325        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
326        let root =
327            tree.add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("content"))));
328        tree.layout(SizeProposal {
329            width: Some(300.0),
330            height: None,
331        });
332        let initial_h = tree.bounds(root).height;
333
334        expanded.set(false);
335
336        let mut prev = f32::INFINITY;
337        for step in 0..5 {
338            tree.tick_animations(Duration::from_millis(50));
339            tree.layout(SizeProposal {
340                width: Some(300.0),
341                height: None,
342            });
343            let h = tree.bounds(root).height;
344            assert!(
345                h <= prev + 0.01,
346                "height must never grow during collapse: step {} got {} after {}",
347                step,
348                h,
349                prev,
350            );
351            assert!(
352                h <= initial_h + 0.01,
353                "step {} height {} must not exceed initial expanded height {}",
354                step,
355                h,
356                initial_h,
357            );
358            prev = h;
359        }
360    }
361
362    #[test]
363    fn animation_is_active_mid_tween() {
364        let expanded = Signal::new(false);
365        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
366        tree.add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("content"))));
367        tree.layout(SizeProposal {
368            width: Some(300.0),
369            height: None,
370        });
371
372        expanded.set(true);
373        tree.tick_animations(Duration::from_millis(50));
374        assert!(
375            tree.has_active_animations(),
376            "tween should be in flight 50 ms in"
377        );
378    }
379}