Skip to main content

teksilo_widgets/animations/
blur.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `Blur` — a wrapper widget that applies a Gaussian-equivalent blur
5//! to its child subtree, driven by a `Prop<f32>` radius (in logical
6//! pixels).
7//!
8//! Built on [`BuildContext::set_blur`], a per-node paint scope parallel
9//! to `set_opacity` and `set_transform`. The framework's render walker
10//! emits `BeginBlurredSubtree { bounds, radius }` before this widget's
11//! paint and `EndBlurredSubtree` afterwards; the renderer redirects
12//! drawing into an intermediate texture, runs a dual-Kawase blur chain
13//! at the requested radius, and composites the blurred result back into
14//! the parent pass.
15//!
16//! Sub-perceptual radii (< 0.5 px) skip the Begin/End pair entirely so
17//! animated `0 → target_radius` enable patterns have zero per-frame
18//! cost when fully off.
19//!
20//! ```ignore
21//! // Static frosted-glass backdrop:
22//! ctx.add(Blur::new(15.0).child(modal_backdrop));
23//!
24//! // Click-to-reveal sensitive content:
25//! let visible = ctx.signal(false);
26//! let radius = visible.map(|&v| if v { 0.0 } else { 12.0 });
27//! ctx.add(Blur::new(radius).child(secret_text));
28//!
29//! // Animated frosted-glass on modal show:
30//! let radius = ctx.animated_signal(0.0_f32);
31//! ctx.animate().normal().standard().to_or_snap(&radius, 15.0);
32//! ctx.add(Blur::new(radius).child(content));
33//! ```
34//!
35//! ## Layout semantics
36//!
37//! `Blur` does not change layout. The wrapped child reports its full
38//! natural size at all blur radii; only the visual paint output is
39//! affected.
40//!
41//! ## Performance
42//!
43//! Blur is the most expensive paint scope in the framework — every
44//! enabled blur scope drives N+M+1 small render passes per frame
45//! (N downsamples, M upsamples, +1 composite). Don't put it on
46//! widgets that animate every frame at full radius. For "fade-blur on
47//! reveal" patterns, animate the radius up to a static value and leave
48//! it there. See `docs/animation.md` §5.8.
49
50use teksilo_canvas::{Point, Rect, SizeProposal};
51use teksilo_core::accessibility::AccessNodeBuilder;
52use teksilo_core::build_context::BuildContext;
53use teksilo_core::signal::Prop;
54use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
55use teksilo_core::widget_id::WidgetId;
56
57/// Wraps a child and applies a Gaussian-equivalent blur to the entire
58/// subtree, driven by an external `Prop<f32>` radius (logical pixels).
59pub struct Blur {
60    radius: Prop<f32>,
61    pending_child: Option<PendingChild>,
62    child_id: Option<WidgetId>,
63}
64
65impl Blur {
66    /// Build a blur wrapper bound to `radius` (in logical pixels).
67    /// Accepts any `Prop<f32>` source — `f32`, `Signal<f32>`, or
68    /// `Prop<f32>`. Sub-perceptual radii (< 0.5) are a no-op.
69    pub fn new(radius: impl Into<Prop<f32>>) -> Self {
70        Self {
71            radius: radius.into(),
72            pending_child: None,
73            child_id: None,
74        }
75    }
76
77    /// Inline child widget (deferred insertion).
78    pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
79        self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
80        self
81    }
82    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
83    ///
84    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
85    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
86    /// adds no arena node, so nothing is laid out, painted, or published to the
87    /// accessibility tree, and a stack applies no spacing around it.
88    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
89        match widget {
90            Some(w) => self.child(w),
91            None => self,
92        }
93    }
94}
95
96impl std::fmt::Debug for Blur {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        f.debug_struct("Blur").finish()
99    }
100}
101
102impl Widget for Blur {
103    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
104        if let Some(pending) = self.pending_child.take() {
105            self.child_id = Some(match pending {
106                PendingChild::Id(id) => id,
107                PendingChild::Deferred(w) => ctx.add_boxed(w),
108            });
109        }
110        let Some(child_id) = self.child_id else {
111            return vec![];
112        };
113
114        let id = ctx.self_id();
115        ctx.set_blur(id, self.radius.clone());
116
117        // Register the radius source with the animation scheduler if
118        // it's a Signal (a no-op for `Signal::new(_)`, only relevant
119        // for `Signal::new_animated(_)`). Without this, the typical
120        // "create animated signal outside build, animate it from a
121        // handler" pattern is a silent no-op — `set_blur` registers
122        // the prop with the binding registry for repaint-on-change,
123        // but the scheduler is a separate registry. Same trap Rotate
124        // had to fix.
125        if let Prop::Bound(signal) = &self.radius {
126            ctx.register_animated_signal(signal);
127        }
128
129        vec![child_id]
130    }
131
132    fn layout_response(
133        &self,
134        proposal: SizeProposal,
135        ctx: &LayoutContext,
136    ) -> teksilo_core::widget::LayoutResponse {
137        // Layout-transparent: report the child's natural size at all
138        // blur radii.
139        self.child_id
140            .and_then(|id| ctx.child_size(id, proposal))
141            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
142            .into()
143    }
144
145    fn place_children(
146        &self,
147        bounds: Rect,
148        _proposal: SizeProposal,
149        children: &mut [WidgetPlacement],
150        _ctx: &LayoutContext,
151    ) {
152        for child in children.iter_mut() {
153            child.origin = Point::new(bounds.x, bounds.y);
154            child.size = bounds.size();
155        }
156    }
157
158    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
159        // Blur is a visual-modulation wrapper. The wrapped subtree owns
160        // its own a11y semantics; this wrapper is intentionally
161        // a11y-transparent. Note: a blurred-out widget is still reported
162        // by AT — callers who want to actually hide content from
163        // assistive tech should pair `Blur` with `visible_when`.
164    }
165
166    fn children(&self) -> Vec<WidgetId> {
167        self.child_id.into_iter().collect()
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::primitives::{FixedSize, RectWidget};
175    use teksilo_core::signal::Signal;
176    use teksilo_core::widget_tree::WidgetTree;
177    use teksilo_tokens::Color;
178
179    fn collect_blur_radii(frame: &teksilo_canvas::RenderFrame) -> Vec<f32> {
180        frame
181            .draw_order
182            .iter()
183            .filter_map(|c| match c {
184                teksilo_canvas::DrawCommand::BeginBlurredSubtree { radius, .. } => Some(*radius),
185                _ => None,
186            })
187            .collect()
188    }
189
190    #[test]
191    fn static_radius_emits_begin_end_pair() {
192        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
193        tree.add(Blur::new(8.0_f32).child(RectWidget::new().background(Color::RED)));
194        tree.layout(SizeProposal::exact(100.0, 50.0));
195        let frame = tree.render();
196
197        let radii = collect_blur_radii(&frame);
198        assert_eq!(radii.len(), 1);
199        assert!((radii[0] - 8.0).abs() < 1e-6);
200        let ends = frame
201            .draw_order
202            .iter()
203            .filter(|c| matches!(c, teksilo_canvas::DrawCommand::EndBlurredSubtree))
204            .count();
205        assert_eq!(ends, 1);
206    }
207
208    #[test]
209    fn subperceptual_radius_skipped() {
210        // Below the 0.5 threshold: walker emits no Begin/End pair so
211        // animated 0→target patterns have zero cost when fully off.
212        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
213        tree.add(Blur::new(0.0_f32).child(RectWidget::new().background(Color::RED)));
214        tree.layout(SizeProposal::exact(100.0, 50.0));
215        let frame = tree.render();
216        assert!(collect_blur_radii(&frame).is_empty());
217    }
218
219    #[test]
220    fn dynamic_radius_signal_drives_emitted_value() {
221        let radius = Signal::new(4.0_f32);
222        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
223        tree.add(Blur::new(radius.clone()).child(RectWidget::new().background(Color::RED)));
224        tree.layout(SizeProposal::exact(100.0, 50.0));
225        let frame = tree.render();
226        assert_eq!(collect_blur_radii(&frame), vec![4.0]);
227
228        radius.set(20.0);
229        tree.layout(SizeProposal::exact(100.0, 50.0));
230        let frame = tree.render();
231        assert_eq!(collect_blur_radii(&frame), vec![20.0]);
232    }
233
234    #[test]
235    fn layout_size_unchanged_by_blur() {
236        let radius = Signal::new(0.0_f32);
237        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
238        let id = tree.add(
239            Blur::new(radius.clone()).child(
240                FixedSize::new()
241                    .width(120.0)
242                    .height(40.0)
243                    .child(RectWidget::new()),
244            ),
245        );
246        tree.layout(SizeProposal::exact(300.0, 200.0));
247        let off_bounds = tree.bounds(id);
248
249        radius.set(20.0);
250        tree.layout(SizeProposal::exact(300.0, 200.0));
251        let on_bounds = tree.bounds(id);
252
253        assert_eq!(
254            off_bounds.size(),
255            on_bounds.size(),
256            "Blur must not change its own size with radius"
257        );
258    }
259
260    #[test]
261    fn user_provided_animated_signal_is_registered_with_scheduler() {
262        // Regression: Blur accepts a user-provided Signal<f32> via its
263        // Prop<f32> argument. If the user creates the signal with
264        // `Signal::new_animated(12.0)` (the natural pattern for a
265        // signal built outside any BuildContext — e.g. in the
266        // animations-kit example), `animate_to` queues a request that
267        // the scheduler only picks up if the signal is registered with
268        // the tree. Blur's build() must auto-register so user
269        // `animate_to` calls actually drive the radius — without this,
270        // the click-to-reveal pattern in the docs is a silent no-op
271        // (the radius signal stays at its initial value, so the blur
272        // never lifts).
273        use std::time::Duration;
274        let radius = Signal::new_animated(12.0_f32);
275        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
276        tree.add(
277            Blur::new(radius.clone()).child(
278                FixedSize::new()
279                    .width(80.0)
280                    .height(40.0)
281                    .child(RectWidget::new()),
282            ),
283        );
284        tree.layout(SizeProposal::exact(200.0, 100.0));
285
286        radius.animate_to(
287            0.0,
288            Duration::from_millis(100),
289            teksilo_tokens::Easing::Linear,
290        );
291        // Drain the pending request onto the scheduler.
292        tree.layout(SizeProposal::exact(200.0, 100.0));
293        assert!(
294            tree.has_active_animations(),
295            "user-provided animated signal must reach the scheduler"
296        );
297    }
298
299    #[test]
300    fn begin_carries_widget_bounds() {
301        // The Begin command's `bounds` field must match the wrapper
302        // widget's actual placed bounds — that's what the renderer uses
303        // to size the intermediate texture and to position the
304        // composite blit.
305        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
306        let id = tree.add(
307            Blur::new(8.0_f32).child(
308                FixedSize::new()
309                    .width(80.0)
310                    .height(40.0)
311                    .child(RectWidget::new()),
312            ),
313        );
314        tree.layout(SizeProposal::exact(200.0, 100.0));
315        let bounds = tree.bounds(id);
316        let frame = tree.render();
317
318        let begin = frame
319            .draw_order
320            .iter()
321            .find_map(|c| match c {
322                teksilo_canvas::DrawCommand::BeginBlurredSubtree { bounds, radius } => {
323                    Some((*bounds, *radius))
324                }
325                _ => None,
326            })
327            .expect("Begin emitted");
328        assert_eq!(begin.0, bounds);
329        assert!((begin.1 - 8.0).abs() < 1e-6);
330    }
331}