Skip to main content

teksilo_widgets/animations/
slide.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `Slide` — wraps a child and slides it in or out from a chosen
5//! edge when an external `Signal<bool>` toggles. Common patterns:
6//! drawers, snackbars, side panels, banner notifications.
7//!
8//! ```ignore
9//! let visible = ctx.signal(false);
10//! ctx.add(
11//!     Slide::new(visible.clone())
12//!         .from(SlideEdge::Bottom)
13//!         .child(snackbar_content),
14//! );
15//! // ...elsewhere:
16//! visible.set(true);   // slides in from below
17//! ```
18//!
19//! ## Layout semantics
20//!
21//! `Slide`'s own slot stays in its laid-out position; the child is
22//! *translated* within the slot via `place_children`. The wrapper
23//! clips so a sliding-in child doesn't bleed past the slot edges.
24//! The wrapper reports the child's full natural size at all
25//! progress values — siblings don't reflow as the child slides.
26//!
27//! For a "slide + fade" effect (notification snackbar), wrap the
28//! child in [`Fade`](super::Fade) before passing it to `Slide`:
29//!
30//! ```rust
31//! # use teksilo_widgets::animations::{Slide, Fade, SlideEdge};
32//! # use teksilo_widgets::primitives::TextWidget;
33//! # use teksilo_core::signal::Signal;
34//! # use teksilo_i18n::lit;
35//! # let visible = Signal::new(false);
36//! # let snackbar_content = TextWidget::new(lit!("Changes saved"));
37//! let _w = Slide::new(visible.clone())
38//!     .from(SlideEdge::Bottom)
39//!     .child(Fade::new(visible).child(snackbar_content));
40//! ```
41//!
42//! ## Reduced motion
43//!
44//! Honours `prefers-reduced-motion`: snaps the child instantly into
45//! or out of position instead of tweening.
46
47use std::cell::Cell;
48
49use teksilo_canvas::{Point, Rect, Size, SizeProposal};
50use teksilo_core::accessibility::AccessNodeBuilder;
51use teksilo_core::binding::BindingLevel;
52use teksilo_core::build_context::BuildContext;
53use teksilo_core::signal::{Prop, Signal};
54use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
55use teksilo_core::widget_id::WidgetId;
56
57/// Which edge the child slides in from / out to.
58///
59/// `Leading` and `Trailing` honour layout direction (RTL flips them);
60/// the resolution happens in `place_children` via the layout context.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum SlideEdge {
63    /// Slide from the leading edge (left in LTR, right in RTL). Suits drawers and side panels.
64    Leading,
65    /// Slide from the trailing edge (right in LTR, left in RTL).
66    Trailing,
67    /// Slide from the top edge. Suits drop-down banners or navigation bars.
68    Top,
69    /// Slide from the bottom edge. Suits snackbars and bottom sheets.
70    Bottom,
71}
72
73/// Wraps a child widget and translates it in or out from one edge of
74/// its slot whenever `visible` flips.
75pub struct Slide {
76    visible: Prop<bool>,
77    edge: SlideEdge,
78    pending_child: Option<PendingChild>,
79    child_id: Option<WidgetId>,
80    /// 0 = fully off-edge, 1 = at rest. Animated. Bound to self at
81    /// Relayout level so each tick re-runs place_children.
82    progress: Option<Signal<f32>>,
83    /// Last natural size measured. `place_children` reads it to
84    /// compute the slide distance (= child extent on the slide axis).
85    natural_size: Cell<Size>,
86}
87
88impl Slide {
89    /// Create a slide wrapper bound to `visible`; accepts a static `bool`
90    /// or a reactive `Signal<bool>`. Defaults to [`SlideEdge::Bottom`] —
91    /// override with [`.from(...)`](Self::from).
92    pub fn new(visible: impl Into<Prop<bool>>) -> Self {
93        Self {
94            visible: visible.into(),
95            edge: SlideEdge::Bottom,
96            pending_child: None,
97            child_id: None,
98            progress: None,
99            natural_size: Cell::new(Size::ZERO),
100        }
101    }
102
103    /// Edge the child slides in from (and out to).
104    pub fn from(mut self, edge: SlideEdge) -> Self {
105        self.edge = edge;
106        self
107    }
108
109    /// Inline child widget (deferred insertion).
110    pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
111        self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
112        self
113    }
114    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
115    ///
116    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
117    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
118    /// adds no arena node, so nothing is laid out, painted, or published to the
119    /// accessibility tree, and a stack applies no spacing around it.
120    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
121        match widget {
122            Some(w) => self.child(w),
123            None => self,
124        }
125    }
126}
127
128impl std::fmt::Debug for Slide {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        f.debug_struct("Slide").field("edge", &self.edge).finish()
131    }
132}
133
134impl Widget for Slide {
135    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
136        if let Some(pending) = self.pending_child.take() {
137            self.child_id = Some(match pending {
138                PendingChild::Id(id) => id,
139                PendingChild::Deferred(w) => ctx.add_boxed(w),
140            });
141        }
142        let Some(child_id) = self.child_id else {
143            return vec![];
144        };
145
146        let initial = if self.visible.get() { 1.0 } else { 0.0 };
147        let progress = ctx.animated_signal(initial);
148        self.progress = Some(progress.clone());
149
150        // Bind progress to self at Relayout — every tick re-runs
151        // place_children with the new offset.
152        let id = ctx.self_id();
153        let registry = ctx.binding_registry();
154        progress.bind_to(id, registry, BindingLevel::Relayout);
155
156        // Drive the slide on visibility flips.
157        if let Prop::Bound(visible_signal) = &self.visible {
158            let visible_signal = visible_signal.clone();
159            let slide_anim = ctx.animate().normal().standard();
160            let progress_for_effect = progress;
161            ctx.effect(&visible_signal, move |&v| {
162                let target = if v { 1.0 } else { 0.0 };
163                slide_anim.to_or_snap(&progress_for_effect, target);
164            });
165        }
166
167        vec![child_id]
168    }
169
170    fn layout_response(
171        &self,
172        proposal: SizeProposal,
173        ctx: &LayoutContext,
174    ) -> teksilo_core::widget::LayoutResponse {
175        let Some(child_id) = self.child_id else {
176            return (proposal.resolve(0.0, 0.0)).into();
177        };
178        let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
179        self.natural_size.set(natural);
180        // Layout-stable: slot stays at child's natural size at all
181        // progress values; only the child's *visual* offset moves.
182        natural.into()
183    }
184
185    fn place_children(
186        &self,
187        bounds: Rect,
188        _proposal: SizeProposal,
189        children: &mut [WidgetPlacement],
190        ctx: &LayoutContext,
191    ) {
192        let progress = self
193            .progress
194            .as_ref()
195            .map(|s| s.get().clamp(0.0, 1.0))
196            .unwrap_or(1.0);
197        let natural = self.natural_size.get();
198        // Distance to translate when fully hidden — the child's full
199        // extent on the slide axis (so its trailing pixel just leaves
200        // the bounds at progress=0).
201        let off_amount = 1.0 - progress;
202        // Resolve Leading/Trailing against the layout direction so the
203        // slide direction tracks RTL correctly.
204        let resolved = match (self.edge, ctx.is_rtl()) {
205            (SlideEdge::Leading, false) | (SlideEdge::Trailing, true) => SlideEdge::Leading,
206            (SlideEdge::Trailing, false) | (SlideEdge::Leading, true) => SlideEdge::Trailing,
207            (other, _) => other,
208        };
209        let (dx, dy) = match resolved {
210            SlideEdge::Leading => (-natural.width * off_amount, 0.0),
211            SlideEdge::Trailing => (natural.width * off_amount, 0.0),
212            SlideEdge::Top => (0.0, -natural.height * off_amount),
213            SlideEdge::Bottom => (0.0, natural.height * off_amount),
214        };
215        for child in children.iter_mut() {
216            child.origin = Point::new(bounds.x + dx, bounds.y + dy);
217            child.size = natural;
218        }
219    }
220
221    fn clips_children(&self) -> bool {
222        // Required: a sliding-in child renders past the wrapper's
223        // own bounds and would overlap siblings without clipping.
224        true
225    }
226
227    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
228        // Visual-modulation wrapper. The child owns its own a11y.
229    }
230
231    fn children(&self) -> Vec<WidgetId> {
232        self.child_id.into_iter().collect()
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use std::time::Duration;
239
240    use super::*;
241    use crate::primitives::TextWidget;
242    use teksilo_core::widget_tree::WidgetTree;
243    use teksilo_i18n::lit;
244
245    #[test]
246    fn starts_visible_when_signal_is_true() {
247        let visible = Signal::new(true);
248        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
249        let id = tree.add(Slide::new(visible.clone()).child(TextWidget::new(lit!("hello"))));
250        tree.layout(SizeProposal {
251            width: Some(300.0),
252            height: None,
253        });
254        let bounds = tree.bounds(id);
255        assert!(bounds.width > 0.0 && bounds.height > 0.0);
256    }
257
258    #[test]
259    fn flipping_signal_drives_slide_progress() {
260        let visible = Signal::new(false);
261        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
262        tree.add(
263            Slide::new(visible.clone())
264                .from(SlideEdge::Bottom)
265                .child(TextWidget::new(lit!("snackbar message"))),
266        );
267        tree.layout(SizeProposal {
268            width: Some(300.0),
269            height: None,
270        });
271
272        visible.set(true);
273        // Mid-tween: animation should be in flight.
274        tree.tick_animations(Duration::from_millis(50));
275        assert!(
276            tree.has_active_animations(),
277            "slide-in should be animating mid-tween"
278        );
279    }
280
281    #[test]
282    fn slide_does_not_change_layout_size() {
283        let visible = Signal::new(false);
284        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
285        let id = tree.add(
286            Slide::new(visible.clone())
287                .from(SlideEdge::Leading)
288                .child(TextWidget::new(lit!("content"))),
289        );
290        tree.layout(SizeProposal {
291            width: Some(300.0),
292            height: None,
293        });
294        let hidden_bounds = tree.bounds(id);
295
296        visible.set(true);
297        tree.tick_animations(Duration::from_millis(300));
298        tree.layout(SizeProposal {
299            width: Some(300.0),
300            height: None,
301        });
302        let visible_bounds = tree.bounds(id);
303
304        assert_eq!(
305            hidden_bounds.size(),
306            visible_bounds.size(),
307            "Slide must not change its own size based on progress"
308        );
309    }
310
311    #[test]
312    fn rtl_swaps_leading_and_trailing() {
313        // In LTR, SlideEdge::Leading hides off the LEFT (negative x).
314        // In RTL, Leading should hide off the RIGHT (positive x).
315        // We don't have a direct way to read child placement offsets
316        // from the public API in tests, so we exercise the RTL path
317        // and confirm the wrapper still lays out and animates without
318        // panicking. The math itself is covered by the static enum
319        // mapping in `place_children`.
320        use teksilo_core::environment::LayoutDirection;
321        let visible = Signal::new(true);
322        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
323        tree.set_layout_direction(LayoutDirection::RightToLeft);
324        let id = tree.add(
325            Slide::new(visible.clone())
326                .from(SlideEdge::Leading)
327                .child(TextWidget::new(lit!("rtl content"))),
328        );
329        tree.layout(SizeProposal {
330            width: Some(300.0),
331            height: None,
332        });
333        let bounds = tree.bounds(id);
334        assert!(bounds.width > 0.0 && bounds.height > 0.0);
335
336        visible.set(false);
337        tree.tick_animations(Duration::from_millis(300));
338        tree.layout(SizeProposal {
339            width: Some(300.0),
340            height: None,
341        });
342        // After fully sliding out under RTL, the wrapper's slot stays
343        // at natural size — Slide is layout-stable.
344        let after = tree.bounds(id);
345        assert_eq!(bounds.size(), after.size());
346    }
347
348    #[test]
349    fn reduced_motion_snaps_progress() {
350        let visible = Signal::new(false);
351        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
352        tree.set_accessibility_preferences(false, true, 1.0);
353        tree.add(Slide::new(visible.clone()).child(TextWidget::new(lit!("snap"))));
354        tree.layout(SizeProposal {
355            width: Some(300.0),
356            height: None,
357        });
358
359        visible.set(true);
360        // Reduced-motion path uses to_or_snap — value lands instantly,
361        // no animation registered.
362        assert!(
363            !tree.has_active_animations(),
364            "reduced-motion path must not register animations"
365        );
366    }
367}