use teksilo_canvas::{Point, Rect, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::build_context::BuildContext;
use teksilo_core::signal::Prop;
use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
use teksilo_core::widget_id::WidgetId;
pub struct Blur {
radius: Prop<f32>,
pending_child: Option<PendingChild>,
child_id: Option<WidgetId>,
}
impl Blur {
pub fn new(radius: impl Into<Prop<f32>>) -> Self {
Self {
radius: radius.into(),
pending_child: None,
child_id: None,
}
}
pub fn child(mut self, widget: impl Widget + 'static) -> Self {
self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
self
}
pub fn child_id(mut self, id: WidgetId) -> Self {
self.pending_child = Some(PendingChild::Id(id));
self
}
}
impl std::fmt::Debug for Blur {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Blur").finish()
}
}
impl Widget for Blur {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
if let Some(pending) = self.pending_child.take() {
self.child_id = Some(match pending {
PendingChild::Id(id) => id,
PendingChild::Deferred(w) => ctx.add_boxed(w),
});
}
let Some(child_id) = self.child_id else {
return vec![];
};
let id = ctx.self_id();
ctx.set_blur(id, self.radius.clone());
if let Prop::Bound(signal) = &self.radius {
ctx.register_animated_signal(signal);
}
vec![child_id]
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
self.child_id
.and_then(|id| ctx.child_size(id, proposal))
.unwrap_or_else(|| proposal.resolve(0.0, 0.0))
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = Point::new(bounds.x, bounds.y);
child.size = bounds.size();
}
}
fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
}
fn children(&self) -> Vec<WidgetId> {
self.child_id.into_iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::{FixedSize, RectWidget};
use teksilo_core::signal::Signal;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_tokens::Color;
fn collect_blur_radii(frame: &teksilo_canvas::RenderFrame) -> Vec<f32> {
frame
.draw_order
.iter()
.filter_map(|c| match c {
teksilo_canvas::DrawCommand::BeginBlurredSubtree { radius, .. } => Some(*radius),
_ => None,
})
.collect()
}
#[test]
fn static_radius_emits_begin_end_pair() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(Blur::new(8.0_f32).child(RectWidget::new().background(Color::RED)));
tree.layout(SizeProposal::exact(100.0, 50.0));
let frame = tree.render();
let radii = collect_blur_radii(&frame);
assert_eq!(radii.len(), 1);
assert!((radii[0] - 8.0).abs() < 1e-6);
let ends = frame
.draw_order
.iter()
.filter(|c| matches!(c, teksilo_canvas::DrawCommand::EndBlurredSubtree))
.count();
assert_eq!(ends, 1);
}
#[test]
fn subperceptual_radius_skipped() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(Blur::new(0.0_f32).child(RectWidget::new().background(Color::RED)));
tree.layout(SizeProposal::exact(100.0, 50.0));
let frame = tree.render();
assert!(collect_blur_radii(&frame).is_empty());
}
#[test]
fn dynamic_radius_signal_drives_emitted_value() {
let radius = Signal::new(4.0_f32);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(Blur::new(radius.clone()).child(RectWidget::new().background(Color::RED)));
tree.layout(SizeProposal::exact(100.0, 50.0));
let frame = tree.render();
assert_eq!(collect_blur_radii(&frame), vec![4.0]);
radius.set(20.0);
tree.layout(SizeProposal::exact(100.0, 50.0));
let frame = tree.render();
assert_eq!(collect_blur_radii(&frame), vec![20.0]);
}
#[test]
fn layout_size_unchanged_by_blur() {
let radius = Signal::new(0.0_f32);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(
Blur::new(radius.clone()).child(
FixedSize::new()
.width(120.0)
.height(40.0)
.child(RectWidget::new()),
),
);
tree.layout(SizeProposal::exact(300.0, 200.0));
let off_bounds = tree.bounds(id);
radius.set(20.0);
tree.layout(SizeProposal::exact(300.0, 200.0));
let on_bounds = tree.bounds(id);
assert_eq!(
off_bounds.size(),
on_bounds.size(),
"Blur must not change its own size with radius"
);
}
#[test]
fn user_provided_animated_signal_is_registered_with_scheduler() {
use std::time::Duration;
let radius = Signal::new_animated(12.0_f32);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(
Blur::new(radius.clone()).child(
FixedSize::new()
.width(80.0)
.height(40.0)
.child(RectWidget::new()),
),
);
tree.layout(SizeProposal::exact(200.0, 100.0));
radius.animate_to(
0.0,
Duration::from_millis(100),
teksilo_tokens::Easing::Linear,
);
tree.layout(SizeProposal::exact(200.0, 100.0));
assert!(
tree.has_active_animations(),
"user-provided animated signal must reach the scheduler"
);
}
#[test]
fn begin_carries_widget_bounds() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(
Blur::new(8.0_f32).child(
FixedSize::new()
.width(80.0)
.height(40.0)
.child(RectWidget::new()),
),
);
tree.layout(SizeProposal::exact(200.0, 100.0));
let bounds = tree.bounds(id);
let frame = tree.render();
let begin = frame
.draw_order
.iter()
.find_map(|c| match c {
teksilo_canvas::DrawCommand::BeginBlurredSubtree { bounds, radius } => {
Some((*bounds, *radius))
}
_ => None,
})
.expect("Begin emitted");
assert_eq!(begin.0, bounds);
assert!((begin.1 - 8.0).abs() < 1e-6);
}
}