teksilo_widgets/animations/
blur.rs1use 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
57pub struct Blur {
60 radius: Prop<f32>,
61 pending_child: Option<PendingChild>,
62 child_id: Option<WidgetId>,
63}
64
65impl Blur {
66 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 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 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 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 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 }
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 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 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 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 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}