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 teksilo_core::IntoTeksiChild) -> Self {
79 self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
80 self
81 }
82 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 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 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 }
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 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 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 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 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}