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 teksilo_core::IntoTeksiChild) -> Self {
76        self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
77        self
78    }
79    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
80    ///
81    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
82    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
83    /// adds no arena node, so nothing is laid out, painted, or published to the
84    /// accessibility tree, and a stack applies no spacing around it.
85    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
86        match widget {
87            Some(w) => self.child(w),
88            None => self,
89        }
90    }
91}
92
93impl std::fmt::Debug for Collapse {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        f.debug_struct("Collapse").finish()
96    }
97}
98
99impl Widget for Collapse {
100    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
101        // Resolve the child if it was provided inline.
102        if let Some(pending) = self.pending_child.take() {
103            self.child_id = Some(match pending {
104                PendingChild::Id(id) => id,
105                PendingChild::Deferred(w) => ctx.add_boxed(w),
106            });
107        }
108        let Some(child_id) = self.child_id else {
109            return vec![];
110        };
111
112        let initial = if self.expanded.get() { 1.0 } else { 0.0 };
113        let progress = ctx.animated_signal(initial);
114        self.progress = Some(progress.clone());
115
116        // Bind progress to *self* at relayout level: every animation
117        // tick re-runs `size_that_fits`, which reads `progress.get()`
118        // and updates the wrapper's reported height accordingly.
119        let id = ctx.self_id();
120        let registry = ctx.binding_registry();
121        progress.bind_to(id, registry, BindingLevel::Relayout);
122
123        // Drive the progress tween whenever `expanded` flips. The
124        // observer survives across rebuilds via `effect_handles`.
125        let collapse_anim = ctx.animate().collapse().standard();
126        let progress_for_effect = progress;
127        ctx.effect(&self.expanded, move |&expanded| {
128            let target = if expanded { 1.0 } else { 0.0 };
129            collapse_anim.to_or_snap(&progress_for_effect, target);
130        });
131
132        vec![child_id]
133    }
134
135    fn layout_response(
136        &self,
137        proposal: SizeProposal,
138        ctx: &LayoutContext,
139    ) -> teksilo_core::widget::LayoutResponse {
140        let Some(child_id) = self.child_id else {
141            return (proposal.resolve(0.0, 0.0)).into();
142        };
143        // Ask the child for its size against the *unmodified* proposal.
144        // We never propose a clipped height — that would let text
145        // wrap or images letterbox to the in-flight animated value
146        // and re-enter a layout feedback loop.
147        let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
148        self.natural_size.set(natural);
149
150        let progress = self
151            .progress
152            .as_ref()
153            .map(|s| s.get().clamp(0.0, 1.0))
154            .unwrap_or(1.0);
155
156        // Width snaps to 0 only when fully collapsed — during the
157        // tween the wrapper keeps its natural width so the child's
158        // text / icons / etc. continue to render at the proper
159        // measure (just clipped vertically by the framework).
160        let width = if progress < COLLAPSED_PROGRESS_EPSILON {
161            0.0
162        } else {
163            natural.width
164        };
165        Size::new(width, natural.height * progress).into()
166    }
167
168    fn place_children(
169        &self,
170        bounds: Rect,
171        _proposal: SizeProposal,
172        children: &mut [WidgetPlacement],
173        _ctx: &LayoutContext,
174    ) {
175        // Lay the child out at its FULL natural size and let the
176        // framework's clip pass crop the bottom overflow against
177        // `Collapse`'s reduced bounds. This is what makes the visible
178        // shrink track the animated progress linearly across the full
179        // duration: the child's internal layout doesn't reflow each
180        // frame, only the clip rect changes.
181        let natural = self.natural_size.get();
182        for child in children.iter_mut() {
183            child.origin = Point::new(bounds.x, bounds.y);
184            child.size = natural;
185        }
186    }
187
188    fn clips_children(&self) -> bool {
189        // Required: the child is sized to its natural dimensions but
190        // the wrapper's bounds are smaller during the tween, so the
191        // overflow must be clipped. Without this the child would
192        // render past the wrapper's reported size and overlap
193        // siblings below.
194        true
195    }
196
197    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
198        // Collapse is a layout/animation wrapper. The control that
199        // *toggles* the expanded state owns the a11y semantics
200        // (Role::Button + set_expanded); the content is announced by
201        // its own subtree when expanded. This widget itself is
202        // intentionally a11y-transparent.
203    }
204
205    fn children(&self) -> Vec<WidgetId> {
206        self.child_id.into_iter().collect()
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use std::time::Duration;
213
214    use super::*;
215    use crate::primitives::TextWidget;
216    use teksilo_core::widget_tree::WidgetTree;
217    use teksilo_i18n::lit;
218
219    #[test]
220    fn starts_collapsed_when_signal_is_false() {
221        let expanded = Signal::new(false);
222        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
223        let id = tree
224            .add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("hidden content"))));
225        tree.layout(SizeProposal {
226            width: Some(300.0),
227            height: None,
228        });
229        assert!(
230            tree.bounds(id).height < 1.0,
231            "collapsed bounds should be ~0, got {}",
232            tree.bounds(id).height
233        );
234    }
235
236    #[test]
237    fn starts_expanded_when_signal_is_true() {
238        let expanded = Signal::new(true);
239        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
240        let id = tree
241            .add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("visible content"))));
242        tree.layout(SizeProposal {
243            width: Some(300.0),
244            height: None,
245        });
246        assert!(
247            tree.bounds(id).height > 1.0,
248            "expanded bounds should be > 0, got {}",
249            tree.bounds(id).height
250        );
251    }
252
253    #[test]
254    fn flipping_signal_drives_animation() {
255        let expanded = Signal::new(false);
256        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
257        let id = tree.add(
258            Collapse::new(expanded.clone())
259                .child(TextWidget::new(lit!("content with some natural height"))),
260        );
261        tree.layout(SizeProposal {
262            width: Some(300.0),
263            height: None,
264        });
265        let collapsed = tree.bounds(id).height;
266
267        expanded.set(true);
268
269        tree.tick_animations(Duration::from_millis(300));
270        tree.layout(SizeProposal {
271            width: Some(300.0),
272            height: None,
273        });
274        let after = tree.bounds(id).height;
275
276        assert!(
277            after > collapsed,
278            "after expanding, height ({}) should exceed collapsed height ({})",
279            after,
280            collapsed
281        );
282    }
283
284    #[test]
285    fn collapse_height_shrinks_proportionally() {
286        // Start expanded; flip to collapsed; verify the wrapper
287        // height shrinks *proportionally* across the tween — the
288        // intermediate 50%-progress sample must be roughly half the
289        // initial height, NOT pinned at the natural height for 95% of
290        // the animation (the bug where max_h tweens against a 10000
291        // sentinel).
292        let expanded = Signal::new(true);
293        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
294        let root =
295            tree.add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("content"))));
296        tree.layout(SizeProposal {
297            width: Some(300.0),
298            height: None,
299        });
300        let initial_h = tree.bounds(root).height;
301        assert!(initial_h > 0.0);
302
303        expanded.set(false);
304
305        // Tick to ~halfway through the 200ms collapse and check the
306        // height is meaningfully below the initial — not snapped to 0
307        // and not still pinned at natural.
308        tree.tick_animations(Duration::from_millis(100));
309        tree.layout(SizeProposal {
310            width: Some(300.0),
311            height: None,
312        });
313        let mid_h = tree.bounds(root).height;
314        assert!(
315            mid_h < initial_h * 0.95,
316            "halfway through collapse, height ({}) should be visibly less than initial ({})",
317            mid_h,
318            initial_h
319        );
320        assert!(
321            mid_h > initial_h * 0.05,
322            "halfway through collapse, height ({}) should not yet be near zero ({})",
323            mid_h,
324            initial_h
325        );
326    }
327
328    #[test]
329    fn collapse_height_monotonically_decreases() {
330        let expanded = Signal::new(true);
331        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
332        let root =
333            tree.add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("content"))));
334        tree.layout(SizeProposal {
335            width: Some(300.0),
336            height: None,
337        });
338        let initial_h = tree.bounds(root).height;
339
340        expanded.set(false);
341
342        let mut prev = f32::INFINITY;
343        for step in 0..5 {
344            tree.tick_animations(Duration::from_millis(50));
345            tree.layout(SizeProposal {
346                width: Some(300.0),
347                height: None,
348            });
349            let h = tree.bounds(root).height;
350            assert!(
351                h <= prev + 0.01,
352                "height must never grow during collapse: step {} got {} after {}",
353                step,
354                h,
355                prev,
356            );
357            assert!(
358                h <= initial_h + 0.01,
359                "step {} height {} must not exceed initial expanded height {}",
360                step,
361                h,
362                initial_h,
363            );
364            prev = h;
365        }
366    }
367
368    #[test]
369    fn animation_is_active_mid_tween() {
370        let expanded = Signal::new(false);
371        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
372        tree.add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("content"))));
373        tree.layout(SizeProposal {
374            width: Some(300.0),
375            height: None,
376        });
377
378        expanded.set(true);
379        tree.tick_animations(Duration::from_millis(50));
380        assert!(
381            tree.has_active_animations(),
382            "tween should be in flight 50 ms in"
383        );
384    }
385}