Skip to main content

teksilo_widgets/animations/
fade.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `Fade` — a wrapper widget that animates its child between hidden
5//! (opacity 0) and visible (opacity 1) when an external
6//! `Signal<bool>` toggles.
7//!
8//! Drives an `opacity: Signal<f32>` ∈ [0, 1] and applies it to its
9//! own subtree via [`BuildContext::set_opacity`]. The framework's
10//! render walker emits `SetOpacity(value)` before this widget's
11//! paint and `RestoreOpacity` afterwards, so the multiplier composes
12//! correctly with ancestor opacity scopes via the canvas's stacked
13//! opacity model.
14//!
15//! ```ignore
16//! let visible = ctx.signal(false);
17//! ctx.add(Fade::new(visible.clone()).child(tooltip_content));
18//! // ...elsewhere:
19//! visible.set(true);  // fades in over `motion.duration_fast`
20//! ```
21//!
22//! ## Layout semantics
23//!
24//! `Fade` does not change layout. The wrapped child reports its full
25//! natural size at all opacity values, so reserving space for a
26//! to-be-faded-in widget works the same whether the widget is fully
27//! visible or fully hidden.
28//!
29//! For overlays where the dismiss should be *deferred* until the
30//! fade-out completes (tooltip / popover / snackbar / dialog),
31//! prefer `OverlayRequest::with_fade`
32//! instead — that path coordinates the dismiss with the tween so the
33//! overlay survives until the opacity reaches zero.
34//!
35//! ## Reduced motion
36//!
37//! Honours `prefers-reduced-motion`: under reduced motion the
38//! opacity snaps to its end value instead of tweening.
39
40use teksilo_canvas::{Point, Rect, SizeProposal};
41use teksilo_core::accessibility::AccessNodeBuilder;
42use teksilo_core::build_context::BuildContext;
43use teksilo_core::signal::{Prop, Signal};
44use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
45use teksilo_core::widget_id::WidgetId;
46
47/// Wraps a child and animates the entire subtree's opacity between
48/// 0 and 1, driven by an external `Signal<bool>`.
49pub struct Fade {
50    visible: Prop<bool>,
51    pending_child: Option<PendingChild>,
52    child_id: Option<WidgetId>,
53    /// Cached so external integrations (and tests) can read the
54    /// current animated opacity. Filled in on `build()`.
55    opacity: Option<Signal<f32>>,
56}
57
58impl Fade {
59    /// Build a fade wrapper bound to `visible`. Initially hidden iff
60    /// `visible.get()` is `false` at the first `build()`.
61    ///
62    /// Accepts any `Prop<bool>` source — `Signal<bool>`, `Prop<bool>`,
63    /// or a plain `bool` (for static "always visible" / "always
64    /// hidden" cases without a tween).
65    pub fn new(visible: impl Into<Prop<bool>>) -> Self {
66        Self {
67            visible: visible.into(),
68            pending_child: None,
69            child_id: None,
70            opacity: None,
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 Fade {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        f.debug_struct("Fade").finish()
96    }
97}
98
99impl Widget for Fade {
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.visible.get() { 1.0 } else { 0.0 };
113        let opacity = ctx.animated_signal(initial);
114        self.opacity = Some(opacity.clone());
115
116        // Apply the opacity scope to *this* widget so the entire
117        // subtree (the wrapped child) inherits the multiplier.
118        let id = ctx.self_id();
119        ctx.set_opacity(id, opacity.clone());
120
121        // Tween on visibility flips. Static `Prop::Static(_)` doesn't
122        // need an observer — the initial opacity already matches.
123        if let Prop::Bound(visible_signal) = &self.visible {
124            let visible_signal = visible_signal.clone();
125            let fade_anim = ctx.animate().fast().standard();
126            let opacity_for_effect = opacity;
127            ctx.effect(&visible_signal, move |&v| {
128                let target = if v { 1.0 } else { 0.0 };
129                fade_anim.to_or_snap(&opacity_for_effect, target);
130            });
131        }
132
133        vec![child_id]
134    }
135
136    fn layout_response(
137        &self,
138        proposal: SizeProposal,
139        ctx: &LayoutContext,
140    ) -> teksilo_core::widget::LayoutResponse {
141        // Layout-transparent: report the child's natural size at all
142        // opacity values. A faded-out tooltip still occupies its
143        // future visible footprint so `Fade` doesn't drive layout
144        // jitter when used purely as a visual modulator.
145        self.child_id
146            .and_then(|id| ctx.child_size(id, proposal))
147            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
148            .into()
149    }
150
151    fn place_children(
152        &self,
153        bounds: Rect,
154        _proposal: SizeProposal,
155        children: &mut [WidgetPlacement],
156        _ctx: &LayoutContext,
157    ) {
158        for child in children.iter_mut() {
159            child.origin = Point::new(bounds.x, bounds.y);
160            child.size = bounds.size();
161        }
162    }
163
164    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
165        // Fade is a visual-modulation wrapper. The wrapped subtree
166        // owns its own a11y semantics; this wrapper is intentionally
167        // a11y-transparent. Note: a fully-faded-out widget is still
168        // reported by AT — callers who want true visibility-driven
169        // a11y should pair `Fade` with `visible_when` on the same
170        // signal.
171    }
172
173    fn children(&self) -> Vec<WidgetId> {
174        self.child_id.into_iter().collect()
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use std::time::Duration;
181
182    use super::*;
183    use crate::primitives::{RectWidget, TextWidget};
184    use teksilo_core::widget_tree::WidgetTree;
185    use teksilo_i18n::lit;
186    use teksilo_tokens::Color;
187
188    fn count_set_opacity(frame: &teksilo_canvas::RenderFrame) -> Vec<f32> {
189        frame
190            .draw_order
191            .iter()
192            .filter_map(|c| match c {
193                teksilo_canvas::DrawCommand::SetOpacity(v) => Some(*v),
194                _ => None,
195            })
196            .collect()
197    }
198
199    #[test]
200    fn starts_hidden_when_signal_is_false() {
201        let visible = Signal::new(false);
202        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
203        tree.add(Fade::new(visible.clone()).child(RectWidget::new().background(Color::RED)));
204        tree.layout(SizeProposal::exact(100.0, 50.0));
205        let frame = tree.render();
206        // Sub-perceptual opacity skips the subtree entirely — no
207        // SetOpacity, and the red child must not paint.
208        assert!(count_set_opacity(&frame).is_empty());
209        assert!(
210            !frame
211                .shapes
212                .iter()
213                .any(|s| s.color == Color::RED.to_array()),
214            "hidden subtree must not paint"
215        );
216    }
217
218    #[test]
219    fn starts_visible_when_signal_is_true() {
220        let visible = Signal::new(true);
221        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
222        tree.add(Fade::new(visible.clone()).child(RectWidget::new().background(Color::RED)));
223        tree.layout(SizeProposal::exact(100.0, 50.0));
224        let frame = tree.render();
225        // Initially visible: opacity 1.0 emits exactly one SetOpacity
226        // pair (the framework still wraps the subtree even at 1.0 so
227        // descendant opacity scopes compose correctly).
228        let ops = count_set_opacity(&frame);
229        assert_eq!(ops.len(), 1);
230        assert!((ops[0] - 1.0).abs() < 1e-6);
231    }
232
233    #[test]
234    fn flipping_signal_drives_animation() {
235        let visible = Signal::new(false);
236        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
237        tree.add(Fade::new(visible.clone()).child(TextWidget::new(lit!("payload"))));
238        tree.layout(SizeProposal::exact(100.0, 50.0));
239
240        visible.set(true);
241        // Halfway through the 120 ms fast tween: opacity should be
242        // visibly between 0 and 1.
243        tree.tick_animations(Duration::from_millis(60));
244        tree.layout(SizeProposal::exact(100.0, 50.0));
245        let frame = tree.render();
246        let ops = count_set_opacity(&frame);
247        assert_eq!(ops.len(), 1, "exactly one opacity scope should be active");
248        assert!(
249            ops[0] > 0.05 && ops[0] < 0.95,
250            "mid-tween opacity should be between 0 and 1, got {}",
251            ops[0]
252        );
253    }
254
255    #[test]
256    fn animation_completes_at_target() {
257        let visible = Signal::new(false);
258        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
259        tree.add(Fade::new(visible.clone()).child(TextWidget::new(lit!("payload"))));
260        tree.layout(SizeProposal::exact(100.0, 50.0));
261
262        visible.set(true);
263        tree.tick_animations(Duration::from_millis(200));
264        tree.layout(SizeProposal::exact(100.0, 50.0));
265        let frame = tree.render();
266        let ops = count_set_opacity(&frame);
267        assert_eq!(ops.len(), 1);
268        assert!(
269            (ops[0] - 1.0).abs() < 0.01,
270            "post-tween opacity should be 1.0, got {}",
271            ops[0]
272        );
273    }
274
275    #[test]
276    fn fade_does_not_change_layout() {
277        // The wrapped child reports its natural size; the wrapper
278        // bounds match it regardless of opacity.
279        let visible = Signal::new(false);
280        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
281        let id = tree.add(Fade::new(visible.clone()).child(TextWidget::new(lit!("hello"))));
282        tree.layout(SizeProposal {
283            width: Some(300.0),
284            height: None,
285        });
286        let hidden_bounds = tree.bounds(id);
287
288        visible.set(true);
289        tree.tick_animations(Duration::from_millis(200));
290        tree.layout(SizeProposal {
291            width: Some(300.0),
292            height: None,
293        });
294        let visible_bounds = tree.bounds(id);
295
296        assert_eq!(
297            hidden_bounds.size(),
298            visible_bounds.size(),
299            "Fade must not change its own size based on opacity"
300        );
301    }
302
303    #[test]
304    fn static_visible_does_not_register_observer() {
305        // `Fade::new(true)` (literal bool) is `Prop::Static(true)` —
306        // no observer registered, no animation kick-off, just a
307        // static fully-visible scope. Verify the wrapper still works
308        // (renders the child) but that no animation is queued.
309        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
310        tree.add(Fade::new(true).child(RectWidget::new().background(Color::RED)));
311        tree.layout(SizeProposal::exact(100.0, 50.0));
312        let _ = tree.render();
313        assert!(
314            !tree.has_active_animations(),
315            "static Prop must not start a fade animation"
316        );
317    }
318}