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 Widget + 'static) -> Self {
79        self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
80        self
81    }
82
83    /// Pre-registered child by `WidgetId`.
84    pub fn child_id(mut self, id: WidgetId) -> Self {
85        self.pending_child = Some(PendingChild::Id(id));
86        self
87    }
88}
89
90impl std::fmt::Debug for Blur {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.debug_struct("Blur").finish()
93    }
94}
95
96impl Widget for Blur {
97    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
98        if let Some(pending) = self.pending_child.take() {
99            self.child_id = Some(match pending {
100                PendingChild::Id(id) => id,
101                PendingChild::Deferred(w) => ctx.add_boxed(w),
102            });
103        }
104        let Some(child_id) = self.child_id else {
105            return vec![];
106        };
107
108        let id = ctx.self_id();
109        ctx.set_blur(id, self.radius.clone());
110
111        // Register the radius source with the animation scheduler if
112        // it's a Signal (a no-op for `Signal::new(_)`, only relevant
113        // for `Signal::new_animated(_)`). Without this, the typical
114        // "create animated signal outside build, animate it from a
115        // handler" pattern is a silent no-op — `set_blur` registers
116        // the prop with the binding registry for repaint-on-change,
117        // but the scheduler is a separate registry. Same trap Rotate
118        // had to fix.
119        if let Prop::Bound(signal) = &self.radius {
120            ctx.register_animated_signal(signal);
121        }
122
123        vec![child_id]
124    }
125
126    fn layout_response(
127        &self,
128        proposal: SizeProposal,
129        ctx: &LayoutContext,
130    ) -> teksilo_core::widget::LayoutResponse {
131        // Layout-transparent: report the child's natural size at all
132        // blur radii.
133        self.child_id
134            .and_then(|id| ctx.child_size(id, proposal))
135            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
136            .into()
137    }
138
139    fn place_children(
140        &self,
141        bounds: Rect,
142        _proposal: SizeProposal,
143        children: &mut [WidgetPlacement],
144        _ctx: &LayoutContext,
145    ) {
146        for child in children.iter_mut() {
147            child.origin = Point::new(bounds.x, bounds.y);
148            child.size = bounds.size();
149        }
150    }
151
152    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
153        // Blur is a visual-modulation wrapper. The wrapped subtree owns
154        // its own a11y semantics; this wrapper is intentionally
155        // a11y-transparent. Note: a blurred-out widget is still reported
156        // by AT — callers who want to actually hide content from
157        // assistive tech should pair `Blur` with `visible_when`.
158    }
159
160    fn children(&self) -> Vec<WidgetId> {
161        self.child_id.into_iter().collect()
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::primitives::{FixedSize, RectWidget};
169    use teksilo_core::signal::Signal;
170    use teksilo_core::widget_tree::WidgetTree;
171    use teksilo_tokens::Color;
172
173    fn collect_blur_radii(frame: &teksilo_canvas::RenderFrame) -> Vec<f32> {
174        frame
175            .draw_order
176            .iter()
177            .filter_map(|c| match c {
178                teksilo_canvas::DrawCommand::BeginBlurredSubtree { radius, .. } => Some(*radius),
179                _ => None,
180            })
181            .collect()
182    }
183
184    #[test]
185    fn static_radius_emits_begin_end_pair() {
186        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
187        tree.add(Blur::new(8.0_f32).child(RectWidget::new().background(Color::RED)));
188        tree.layout(SizeProposal::exact(100.0, 50.0));
189        let frame = tree.render();
190
191        let radii = collect_blur_radii(&frame);
192        assert_eq!(radii.len(), 1);
193        assert!((radii[0] - 8.0).abs() < 1e-6);
194        let ends = frame
195            .draw_order
196            .iter()
197            .filter(|c| matches!(c, teksilo_canvas::DrawCommand::EndBlurredSubtree))
198            .count();
199        assert_eq!(ends, 1);
200    }
201
202    #[test]
203    fn subperceptual_radius_skipped() {
204        // Below the 0.5 threshold: walker emits no Begin/End pair so
205        // animated 0→target patterns have zero cost when fully off.
206        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
207        tree.add(Blur::new(0.0_f32).child(RectWidget::new().background(Color::RED)));
208        tree.layout(SizeProposal::exact(100.0, 50.0));
209        let frame = tree.render();
210        assert!(collect_blur_radii(&frame).is_empty());
211    }
212
213    #[test]
214    fn dynamic_radius_signal_drives_emitted_value() {
215        let radius = Signal::new(4.0_f32);
216        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
217        tree.add(Blur::new(radius.clone()).child(RectWidget::new().background(Color::RED)));
218        tree.layout(SizeProposal::exact(100.0, 50.0));
219        let frame = tree.render();
220        assert_eq!(collect_blur_radii(&frame), vec![4.0]);
221
222        radius.set(20.0);
223        tree.layout(SizeProposal::exact(100.0, 50.0));
224        let frame = tree.render();
225        assert_eq!(collect_blur_radii(&frame), vec![20.0]);
226    }
227
228    #[test]
229    fn layout_size_unchanged_by_blur() {
230        let radius = Signal::new(0.0_f32);
231        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
232        let id = tree.add(
233            Blur::new(radius.clone()).child(
234                FixedSize::new()
235                    .width(120.0)
236                    .height(40.0)
237                    .child(RectWidget::new()),
238            ),
239        );
240        tree.layout(SizeProposal::exact(300.0, 200.0));
241        let off_bounds = tree.bounds(id);
242
243        radius.set(20.0);
244        tree.layout(SizeProposal::exact(300.0, 200.0));
245        let on_bounds = tree.bounds(id);
246
247        assert_eq!(
248            off_bounds.size(),
249            on_bounds.size(),
250            "Blur must not change its own size with radius"
251        );
252    }
253
254    #[test]
255    fn user_provided_animated_signal_is_registered_with_scheduler() {
256        // Regression: Blur accepts a user-provided Signal<f32> via its
257        // Prop<f32> argument. If the user creates the signal with
258        // `Signal::new_animated(12.0)` (the natural pattern for a
259        // signal built outside any BuildContext — e.g. in the
260        // animations-kit example), `animate_to` queues a request that
261        // the scheduler only picks up if the signal is registered with
262        // the tree. Blur's build() must auto-register so user
263        // `animate_to` calls actually drive the radius — without this,
264        // the click-to-reveal pattern in the docs is a silent no-op
265        // (the radius signal stays at its initial value, so the blur
266        // never lifts).
267        use std::time::Duration;
268        let radius = Signal::new_animated(12.0_f32);
269        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
270        tree.add(
271            Blur::new(radius.clone()).child(
272                FixedSize::new()
273                    .width(80.0)
274                    .height(40.0)
275                    .child(RectWidget::new()),
276            ),
277        );
278        tree.layout(SizeProposal::exact(200.0, 100.0));
279
280        radius.animate_to(
281            0.0,
282            Duration::from_millis(100),
283            teksilo_tokens::Easing::Linear,
284        );
285        // Drain the pending request onto the scheduler.
286        tree.layout(SizeProposal::exact(200.0, 100.0));
287        assert!(
288            tree.has_active_animations(),
289            "user-provided animated signal must reach the scheduler"
290        );
291    }
292
293    #[test]
294    fn begin_carries_widget_bounds() {
295        // The Begin command's `bounds` field must match the wrapper
296        // widget's actual placed bounds — that's what the renderer uses
297        // to size the intermediate texture and to position the
298        // composite blit.
299        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
300        let id = tree.add(
301            Blur::new(8.0_f32).child(
302                FixedSize::new()
303                    .width(80.0)
304                    .height(40.0)
305                    .child(RectWidget::new()),
306            ),
307        );
308        tree.layout(SizeProposal::exact(200.0, 100.0));
309        let bounds = tree.bounds(id);
310        let frame = tree.render();
311
312        let begin = frame
313            .draw_order
314            .iter()
315            .find_map(|c| match c {
316                teksilo_canvas::DrawCommand::BeginBlurredSubtree { bounds, radius } => {
317                    Some((*bounds, *radius))
318                }
319                _ => None,
320            })
321            .expect("Begin emitted");
322        assert_eq!(begin.0, bounds);
323        assert!((begin.1 - 8.0).abs() < 1e-6);
324    }
325}