Skip to main content

teksilo_widgets/animations/
unroll.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `Unroll` — the horizontal sibling of [`Collapse`](super::collapse::Collapse).
5//!
6//! Animates a child's *width* between zero and natural while the child
7//! keeps its full natural layout — the framework's clip pass crops the
8//! overflow, so the visible reveal tracks progress linearly across the
9//! whole duration and the child never reflows mid-animation. This is the
10//! same "lay out full, clip the shrinking axis" trick the docking
11//! `Splitter` uses for its side expand/collapse (`ClipPane`).
12//!
13//! Two drivers:
14//!
15//! - [`Unroll::new(expanded)`](Unroll::new) — self-animated, like
16//!   `Collapse`. Flips between 0 and natural width over
17//!   `MotionTokens::duration_collapse` whenever `expanded` toggles.
18//! - [`Unroll::from_progress(progress)`](Unroll::from_progress) — driven
19//!   by an external animated `Signal<f32>` ∈ [0, 1]. Use when something
20//!   *else* owns the tween — e.g. an overlay whose deferred dismissal
21//!   rolls the width back into its anchor before going dormant.
22//!
23//! The reveal edge is chosen with [`reveal_from`](Unroll::reveal_from):
24//! [`UnrollFrom::Leading`] (default) keeps the leading edge pinned and
25//! grows trailing-ward — the "slide out from a button on the left"
26//! shape; [`UnrollFrom::Trailing`] mirrors it.
27//!
28//! Honors `prefers-reduced-motion`: the self-animated driver snaps to
29//! its end value instead of tweening (the external driver's owner is
30//! responsible for its own reduced-motion policy).
31//!
32//! ```rust
33//! # use teksilo_widgets::animations::{Unroll, UnrollFrom};
34//! # use teksilo_widgets::primitives::TextWidget;
35//! # use teksilo_core::signal::Signal;
36//! # use teksilo_i18n::lit;
37//! let expanded = Signal::new(false);
38//! let _w = Unroll::new(expanded)
39//!     .reveal_from(UnrollFrom::Leading)
40//!     .child(TextWidget::new(lit!("Reveal me")));
41//! ```
42
43use std::cell::Cell;
44
45use teksilo_canvas::{Point, Rect, Size, SizeProposal};
46use teksilo_core::accessibility::AccessNodeBuilder;
47use teksilo_core::binding::BindingLevel;
48use teksilo_core::build_context::BuildContext;
49use teksilo_core::signal::Signal;
50use teksilo_core::widget::{LayoutContext, LayoutResponse, PendingChild, Widget, WidgetPlacement};
51use teksilo_core::widget_id::WidgetId;
52
53/// Below this progress value the wrapper's reported width snaps to zero
54/// so a fully-rolled-up `Unroll` claims no horizontal space (siblings
55/// in a row must not be pushed aside by an invisible-but-natural-width
56/// wrapper). Picked where the width is already sub-pixel anyway.
57const ROLLED_UP_PROGRESS_EPSILON: f32 = 0.005;
58
59/// Which edge stays anchored as the child unrolls.
60#[derive(Copy, Clone, Debug, Eq, PartialEq)]
61pub enum UnrollFrom {
62    /// Pin the leading edge; reveal trailing-ward (default).
63    Leading,
64    /// Pin the trailing edge; reveal leading-ward.
65    Trailing,
66}
67
68enum Driver {
69    /// Self-animated from a `bool`.
70    Expanded(Signal<bool>),
71    /// Externally driven 0..1 progress.
72    Progress(Signal<f32>),
73}
74
75/// Wraps a child widget and reveals or hides it along the horizontal
76/// axis by animating the wrapper's reported width between zero and the
77/// child's natural width. See the module docs for the two available drivers.
78pub struct Unroll {
79    driver: Driver,
80    pending_child: Option<PendingChild>,
81    child_id: Option<WidgetId>,
82    /// The live progress signal — created from the `bool` for the
83    /// self-animated driver, or the supplied signal for the external
84    /// one. Filled in on `build()`.
85    progress: Option<Signal<f32>>,
86    from: UnrollFrom,
87    /// Last natural size from `layout_response`; `place_children` reads
88    /// it to lay the child out at full width (then clip the overflow).
89    natural_size: Cell<Size>,
90}
91
92impl Unroll {
93    /// Self-animated wrapper bound to `expanded`. Initially rolled up
94    /// iff `expanded.get()` is `false` at the first `build()`.
95    pub fn new(expanded: Signal<bool>) -> Self {
96        Self::with_driver(Driver::Expanded(expanded))
97    }
98
99    /// Externally-driven wrapper. `progress` (an animated 0..1 signal)
100    /// is read every layout; the caller owns the tween. Use when an
101    /// overlay or other coordinator drives the reveal lifecycle.
102    pub fn from_progress(progress: Signal<f32>) -> Self {
103        Self::with_driver(Driver::Progress(progress))
104    }
105
106    fn with_driver(driver: Driver) -> Self {
107        Self {
108            driver,
109            pending_child: None,
110            child_id: None,
111            progress: None,
112            from: UnrollFrom::Leading,
113            natural_size: Cell::new(Size::ZERO),
114        }
115    }
116
117    /// Inline child widget (deferred insertion).
118    pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
119        self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
120        self
121    }
122    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
123    ///
124    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
125    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
126    /// adds no arena node, so nothing is laid out, painted, or published to the
127    /// accessibility tree, and a stack applies no spacing around it.
128    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
129        match widget {
130            Some(w) => self.child(w),
131            None => self,
132        }
133    }
134
135    /// Set the edge that stays anchored as the child unrolls. Defaults
136    /// to [`UnrollFrom::Leading`].
137    pub fn reveal_from(mut self, from: UnrollFrom) -> Self {
138        self.from = from;
139        self
140    }
141
142    /// Return the live progress signal (0.0 = rolled up, 1.0 = fully
143    /// unrolled). Returns `None` before the first `build()`. Useful for
144    /// tests and external coordinators that need to observe or gate on
145    /// the current animated progress.
146    pub fn progress_signal(&self) -> Option<Signal<f32>> {
147        self.progress.clone()
148    }
149}
150
151impl std::fmt::Debug for Unroll {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        f.debug_struct("Unroll").field("from", &self.from).finish()
154    }
155}
156
157impl Widget for Unroll {
158    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
159        if let Some(pending) = self.pending_child.take() {
160            self.child_id = Some(match pending {
161                PendingChild::Id(id) => id,
162                PendingChild::Deferred(w) => ctx.add_boxed(w),
163            });
164        }
165        let Some(child_id) = self.child_id else {
166            return vec![];
167        };
168
169        let self_id = ctx.self_id();
170        match &self.driver {
171            Driver::Expanded(expanded) => {
172                let expanded = expanded.clone();
173                let initial = if expanded.get() { 1.0 } else { 0.0 };
174                let progress = ctx.animated_signal(initial);
175                self.progress = Some(progress.clone());
176                // Every tick re-runs `layout_response`, which reads
177                // `progress` and updates the reported width.
178                progress.bind_to(self_id, ctx.binding_registry(), BindingLevel::Relayout);
179
180                let anim = ctx.animate().collapse().standard();
181                let progress_for_effect = progress;
182                ctx.effect(&expanded, move |&expanded| {
183                    let target = if expanded { 1.0 } else { 0.0 };
184                    anim.to_or_snap(&progress_for_effect, target);
185                });
186            }
187            Driver::Progress(sig) => {
188                self.progress = Some(sig.clone());
189                sig.bind_to(self_id, ctx.binding_registry(), BindingLevel::Relayout);
190            }
191        }
192
193        vec![child_id]
194    }
195
196    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
197        let Some(child_id) = self.child_id else {
198            return proposal.resolve(0.0, 0.0).into();
199        };
200        // Measure against the *unmodified* proposal — never propose a
201        // clipped width, which would let text rewrap to the in-flight
202        // animated value and re-enter a layout feedback loop.
203        let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
204        self.natural_size.set(natural);
205
206        let progress = self
207            .progress
208            .as_ref()
209            .map(|s| s.get().clamp(0.0, 1.0))
210            .unwrap_or(1.0);
211
212        let width = if progress < ROLLED_UP_PROGRESS_EPSILON {
213            0.0
214        } else {
215            natural.width * progress
216        };
217        Size::new(width, natural.height).into()
218    }
219
220    fn place_children(
221        &self,
222        bounds: Rect,
223        _proposal: SizeProposal,
224        children: &mut [WidgetPlacement],
225        _ctx: &LayoutContext,
226    ) {
227        // Lay the child out at full natural width and let `clips_children`
228        // crop the overflow against the (smaller) animated bounds. The
229        // anchored edge stays put; the other edge is revealed/hidden.
230        let natural = self.natural_size.get();
231        let x = match self.from {
232            UnrollFrom::Leading => bounds.x,
233            UnrollFrom::Trailing => bounds.right() - natural.width,
234        };
235        for child in children.iter_mut() {
236            child.origin = Point::new(x, bounds.y);
237            child.size = Size::new(natural.width, natural.height);
238        }
239    }
240
241    fn clips_children(&self) -> bool {
242        true
243    }
244
245    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
246        // Pure layout/animation wrapper — a11y-transparent, like
247        // `Collapse`. The control that toggles the state and the
248        // child's own subtree own the semantics.
249    }
250
251    fn children(&self) -> Vec<WidgetId> {
252        self.child_id.into_iter().collect()
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use std::time::Duration;
259
260    use super::*;
261    use crate::primitives::TextWidget;
262    use teksilo_core::widget_tree::WidgetTree;
263    use teksilo_i18n::lit;
264
265    fn tree() -> WidgetTree {
266        WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
267    }
268
269    #[test]
270    fn starts_rolled_up_when_signal_is_false() {
271        let expanded = Signal::new(false);
272        let mut t = tree();
273        let id = t.add(Unroll::new(expanded).child(TextWidget::new(lit!("hidden"))));
274        t.layout(SizeProposal::unspecified());
275        assert!(
276            t.bounds(id).width < 1.0,
277            "rolled-up width should be ~0, got {}",
278            t.bounds(id).width
279        );
280    }
281
282    #[test]
283    fn starts_unrolled_when_signal_is_true() {
284        let expanded = Signal::new(true);
285        let mut t = tree();
286        let id = t.add(Unroll::new(expanded).child(TextWidget::new(lit!("visible content"))));
287        t.layout(SizeProposal::unspecified());
288        assert!(
289            t.bounds(id).width > 1.0,
290            "unrolled width should be > 0, got {}",
291            t.bounds(id).width
292        );
293    }
294
295    #[test]
296    fn width_grows_proportionally_during_tween() {
297        let expanded = Signal::new(false);
298        let mut t = tree();
299        let id = t.add(Unroll::new(expanded.clone()).child(TextWidget::new(lit!("some content"))));
300        t.layout(SizeProposal::unspecified());
301        let rolled = t.bounds(id).width;
302
303        expanded.set(true);
304        t.tick_animations(Duration::from_millis(300));
305        t.layout(SizeProposal::unspecified());
306        let after = t.bounds(id).width;
307        assert!(
308            after > rolled,
309            "after expanding, width ({after}) should exceed rolled-up ({rolled})"
310        );
311    }
312
313    #[test]
314    fn external_progress_drives_width() {
315        // Half-progress → roughly half the natural width.
316        let progress = Signal::new_animated(1.0);
317        let mut t = tree();
318        let child = t.add(TextWidget::new(lit!("0123456789")));
319        let id = t.add(Unroll::from_progress(progress.clone()).child(child));
320        t.layout(SizeProposal::unspecified());
321        let full = t.bounds(id).width;
322        assert!(full > 0.0);
323
324        progress.set(0.5);
325        t.layout(SizeProposal::unspecified());
326        let half = t.bounds(id).width;
327        assert!(
328            (half - full * 0.5).abs() < full * 0.1,
329            "half progress width ({half}) should be ~half of full ({full})"
330        );
331    }
332
333    #[test]
334    fn trailing_anchor_pins_trailing_edge() {
335        let progress = Signal::new_animated(0.5);
336        let mut t = tree();
337        let child = t.add(TextWidget::new(lit!("0123456789")));
338        let id = t.add(
339            Unroll::from_progress(progress)
340                .reveal_from(UnrollFrom::Trailing)
341                .child(child),
342        );
343        t.layout(SizeProposal::unspecified());
344        // The child is laid out at full natural width anchored so its
345        // trailing edge aligns with the wrapper's trailing edge — i.e.
346        // its origin sits left of the wrapper origin.
347        let wrapper = t.bounds(id);
348        let inner = t.bounds(child);
349        assert!(
350            inner.x < wrapper.x + 0.5,
351            "trailing-anchored child origin ({}) should be at/left of wrapper origin ({})",
352            inner.x,
353            wrapper.x
354        );
355    }
356}