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 Widget + 'static) -> Self {
119        self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
120        self
121    }
122
123    /// Pre-registered child by `WidgetId`.
124    pub fn child_id(mut self, id: WidgetId) -> Self {
125        self.pending_child = Some(PendingChild::Id(id));
126        self
127    }
128
129    /// Set the edge that stays anchored as the child unrolls. Defaults
130    /// to [`UnrollFrom::Leading`].
131    pub fn reveal_from(mut self, from: UnrollFrom) -> Self {
132        self.from = from;
133        self
134    }
135
136    /// Return the live progress signal (0.0 = rolled up, 1.0 = fully
137    /// unrolled). Returns `None` before the first `build()`. Useful for
138    /// tests and external coordinators that need to observe or gate on
139    /// the current animated progress.
140    pub fn progress_signal(&self) -> Option<Signal<f32>> {
141        self.progress.clone()
142    }
143}
144
145impl std::fmt::Debug for Unroll {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        f.debug_struct("Unroll").field("from", &self.from).finish()
148    }
149}
150
151impl Widget for Unroll {
152    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
153        if let Some(pending) = self.pending_child.take() {
154            self.child_id = Some(match pending {
155                PendingChild::Id(id) => id,
156                PendingChild::Deferred(w) => ctx.add_boxed(w),
157            });
158        }
159        let Some(child_id) = self.child_id else {
160            return vec![];
161        };
162
163        let self_id = ctx.self_id();
164        match &self.driver {
165            Driver::Expanded(expanded) => {
166                let expanded = expanded.clone();
167                let initial = if expanded.get() { 1.0 } else { 0.0 };
168                let progress = ctx.animated_signal(initial);
169                self.progress = Some(progress.clone());
170                // Every tick re-runs `layout_response`, which reads
171                // `progress` and updates the reported width.
172                progress.bind_to(self_id, ctx.binding_registry(), BindingLevel::Relayout);
173
174                let anim = ctx.animate().collapse().standard();
175                let progress_for_effect = progress;
176                ctx.effect(&expanded, move |&expanded| {
177                    let target = if expanded { 1.0 } else { 0.0 };
178                    anim.to_or_snap(&progress_for_effect, target);
179                });
180            }
181            Driver::Progress(sig) => {
182                self.progress = Some(sig.clone());
183                sig.bind_to(self_id, ctx.binding_registry(), BindingLevel::Relayout);
184            }
185        }
186
187        vec![child_id]
188    }
189
190    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
191        let Some(child_id) = self.child_id else {
192            return proposal.resolve(0.0, 0.0).into();
193        };
194        // Measure against the *unmodified* proposal — never propose a
195        // clipped width, which would let text rewrap to the in-flight
196        // animated value and re-enter a layout feedback loop.
197        let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
198        self.natural_size.set(natural);
199
200        let progress = self
201            .progress
202            .as_ref()
203            .map(|s| s.get().clamp(0.0, 1.0))
204            .unwrap_or(1.0);
205
206        let width = if progress < ROLLED_UP_PROGRESS_EPSILON {
207            0.0
208        } else {
209            natural.width * progress
210        };
211        Size::new(width, natural.height).into()
212    }
213
214    fn place_children(
215        &self,
216        bounds: Rect,
217        _proposal: SizeProposal,
218        children: &mut [WidgetPlacement],
219        _ctx: &LayoutContext,
220    ) {
221        // Lay the child out at full natural width and let `clips_children`
222        // crop the overflow against the (smaller) animated bounds. The
223        // anchored edge stays put; the other edge is revealed/hidden.
224        let natural = self.natural_size.get();
225        let x = match self.from {
226            UnrollFrom::Leading => bounds.x,
227            UnrollFrom::Trailing => bounds.right() - natural.width,
228        };
229        for child in children.iter_mut() {
230            child.origin = Point::new(x, bounds.y);
231            child.size = Size::new(natural.width, natural.height);
232        }
233    }
234
235    fn clips_children(&self) -> bool {
236        true
237    }
238
239    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
240        // Pure layout/animation wrapper — a11y-transparent, like
241        // `Collapse`. The control that toggles the state and the
242        // child's own subtree own the semantics.
243    }
244
245    fn children(&self) -> Vec<WidgetId> {
246        self.child_id.into_iter().collect()
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use std::time::Duration;
253
254    use super::*;
255    use crate::primitives::TextWidget;
256    use teksilo_core::widget_tree::WidgetTree;
257    use teksilo_i18n::lit;
258
259    fn tree() -> WidgetTree {
260        WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
261    }
262
263    #[test]
264    fn starts_rolled_up_when_signal_is_false() {
265        let expanded = Signal::new(false);
266        let mut t = tree();
267        let id = t.add(Unroll::new(expanded).child(TextWidget::new(lit!("hidden"))));
268        t.layout(SizeProposal::unspecified());
269        assert!(
270            t.bounds(id).width < 1.0,
271            "rolled-up width should be ~0, got {}",
272            t.bounds(id).width
273        );
274    }
275
276    #[test]
277    fn starts_unrolled_when_signal_is_true() {
278        let expanded = Signal::new(true);
279        let mut t = tree();
280        let id = t.add(Unroll::new(expanded).child(TextWidget::new(lit!("visible content"))));
281        t.layout(SizeProposal::unspecified());
282        assert!(
283            t.bounds(id).width > 1.0,
284            "unrolled width should be > 0, got {}",
285            t.bounds(id).width
286        );
287    }
288
289    #[test]
290    fn width_grows_proportionally_during_tween() {
291        let expanded = Signal::new(false);
292        let mut t = tree();
293        let id = t.add(Unroll::new(expanded.clone()).child(TextWidget::new(lit!("some content"))));
294        t.layout(SizeProposal::unspecified());
295        let rolled = t.bounds(id).width;
296
297        expanded.set(true);
298        t.tick_animations(Duration::from_millis(300));
299        t.layout(SizeProposal::unspecified());
300        let after = t.bounds(id).width;
301        assert!(
302            after > rolled,
303            "after expanding, width ({after}) should exceed rolled-up ({rolled})"
304        );
305    }
306
307    #[test]
308    fn external_progress_drives_width() {
309        // Half-progress → roughly half the natural width.
310        let progress = Signal::new_animated(1.0);
311        let mut t = tree();
312        let child = t.add(TextWidget::new(lit!("0123456789")));
313        let id = t.add(Unroll::from_progress(progress.clone()).child_id(child));
314        t.layout(SizeProposal::unspecified());
315        let full = t.bounds(id).width;
316        assert!(full > 0.0);
317
318        progress.set(0.5);
319        t.layout(SizeProposal::unspecified());
320        let half = t.bounds(id).width;
321        assert!(
322            (half - full * 0.5).abs() < full * 0.1,
323            "half progress width ({half}) should be ~half of full ({full})"
324        );
325    }
326
327    #[test]
328    fn trailing_anchor_pins_trailing_edge() {
329        let progress = Signal::new_animated(0.5);
330        let mut t = tree();
331        let child = t.add(TextWidget::new(lit!("0123456789")));
332        let id = t.add(
333            Unroll::from_progress(progress)
334                .reveal_from(UnrollFrom::Trailing)
335                .child_id(child),
336        );
337        t.layout(SizeProposal::unspecified());
338        // The child is laid out at full natural width anchored so its
339        // trailing edge aligns with the wrapper's trailing edge — i.e.
340        // its origin sits left of the wrapper origin.
341        let wrapper = t.bounds(id);
342        let inner = t.bounds(child);
343        assert!(
344            inner.x < wrapper.x + 0.5,
345            "trailing-anchored child origin ({}) should be at/left of wrapper origin ({})",
346            inner.x,
347            wrapper.x
348        );
349    }
350}