use std::cell::Cell;
use std::time::Duration;
use teksilo_canvas::{Point, Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::binding::BindingLevel;
use teksilo_core::build_context::BuildContext;
use teksilo_core::signal::Signal;
use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::Easing;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SmoothSizeAxes {
Width,
Height,
Both,
}
const SIZE_CHANGE_EPSILON: f32 = 0.5;
pub struct SmoothSize {
axes: SmoothSizeAxes,
duration: Option<Duration>,
easing: Option<Easing>,
pending_child: Option<PendingChild>,
child_id: Option<WidgetId>,
width_anim: Option<Signal<f32>>,
height_anim: Option<Signal<f32>>,
last_target: Cell<Size>,
natural_size: Cell<Size>,
reduced_motion: bool,
needs_initial_snap: Cell<bool>,
}
impl SmoothSize {
pub fn new() -> Self {
Self {
axes: SmoothSizeAxes::Both,
duration: None,
easing: None,
pending_child: None,
child_id: None,
width_anim: None,
height_anim: None,
last_target: Cell::new(Size::ZERO),
natural_size: Cell::new(Size::ZERO),
reduced_motion: false,
needs_initial_snap: Cell::new(true),
}
}
pub fn axes(mut self, axes: SmoothSizeAxes) -> Self {
self.axes = axes;
self
}
pub fn duration(mut self, duration: Duration) -> Self {
self.duration = Some(duration);
self
}
pub fn easing(mut self, easing: Easing) -> Self {
self.easing = Some(easing);
self
}
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
}
fn animates_width(&self) -> bool {
matches!(self.axes, SmoothSizeAxes::Width | SmoothSizeAxes::Both)
}
fn animates_height(&self) -> bool {
matches!(self.axes, SmoothSizeAxes::Height | SmoothSizeAxes::Both)
}
}
impl Default for SmoothSize {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for SmoothSize {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SmoothSize")
.field("axes", &self.axes)
.field("duration", &self.duration)
.finish()
}
}
impl Widget for SmoothSize {
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 w_sig = ctx.animated_signal(0.0);
let h_sig = ctx.animated_signal(0.0);
let id = ctx.self_id();
let registry = ctx.binding_registry();
w_sig.bind_to(id, registry, BindingLevel::Relayout);
h_sig.bind_to(id, registry, BindingLevel::Relayout);
self.width_anim = Some(w_sig);
self.height_anim = Some(h_sig);
self.reduced_motion = ctx.prefers_reduced_motion();
vec![child_id]
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
let Some(child_id) = self.child_id else {
return (proposal.resolve(0.0, 0.0)).into();
};
let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
self.natural_size.set(natural);
let (Some(w_sig), Some(h_sig)) = (self.width_anim.as_ref(), self.height_anim.as_ref())
else {
return (natural).into();
};
let last = self.last_target.get();
let width_target_changed = (natural.width - last.width).abs() > SIZE_CHANGE_EPSILON;
let height_target_changed = (natural.height - last.height).abs() > SIZE_CHANGE_EPSILON;
if width_target_changed || height_target_changed {
self.last_target.set(natural);
let snap = self.reduced_motion || self.needs_initial_snap.get();
self.needs_initial_snap.set(false);
if snap {
w_sig.set(natural.width);
h_sig.set(natural.height);
} else {
let duration = self.duration.unwrap_or(ctx.theme.motion.duration_normal);
let easing = self.easing.unwrap_or(ctx.theme.motion.easing_standard);
if self.animates_width() && width_target_changed {
w_sig.animate_to(natural.width, duration, easing);
} else if !self.animates_width() {
w_sig.set(natural.width);
}
if self.animates_height() && height_target_changed {
h_sig.animate_to(natural.height, duration, easing);
} else if !self.animates_height() {
h_sig.set(natural.height);
}
}
}
Size::new(w_sig.get().max(0.0), h_sig.get().max(0.0)).into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
let natural = self.natural_size.get();
for child in children.iter_mut() {
child.origin = Point::new(bounds.x, bounds.y);
child.size = natural;
}
}
fn clips_children(&self) -> bool {
true
}
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, TextWidget};
use teksilo_core::widget_tree::WidgetTree;
use teksilo_i18n::lit;
#[test]
fn first_measurement_snaps_to_natural_no_grow_in_animation() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(
SmoothSize::new()
.duration(Duration::from_millis(500))
.child(FixedSize::new().width(180.0).height(70.0)),
);
tree.layout(SizeProposal {
width: None,
height: None,
});
let b = tree.bounds(id);
assert!(
(b.width - 180.0).abs() < 0.5 && (b.height - 70.0).abs() < 0.5,
"first-frame size must equal natural; got ({}, {})",
b.width,
b.height
);
assert!(
!tree.has_active_animations(),
"first-frame snap must not register an animation"
);
}
#[test]
fn subsequent_change_animates() {
let width_signal = Signal::new(100.0_f32);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(
SmoothSize::new()
.duration(Duration::from_millis(200))
.child(FixedSize::new().width(width_signal.clone()).height(50.0)),
);
tree.layout(SizeProposal {
width: None,
height: None,
});
let initial = tree.bounds(id);
assert!((initial.width - 100.0).abs() < 0.5);
width_signal.set(250.0);
tree.layout(SizeProposal {
width: None,
height: None,
});
tree.layout(SizeProposal {
width: None,
height: None,
});
let mid = tree.bounds(id);
assert!(
tree.has_active_animations(),
"size change must kick off a tween (got bounds {:?})",
mid
);
assert!(
mid.width < 240.0,
"mid-tween width should still be near the start, got {}",
mid.width
);
tree.tick_animations(Duration::from_millis(250));
tree.layout(SizeProposal {
width: None,
height: None,
});
let final_b = tree.bounds(id);
assert!(
(final_b.width - 250.0).abs() < 1.0,
"after tween, width should reach 250; got {}",
final_b.width
);
}
#[test]
fn reduced_motion_snaps_to_natural() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.set_accessibility_preferences(false, true, 1.0);
let id = tree.add(SmoothSize::new().child(FixedSize::new().width(150.0).height(60.0)));
tree.layout(SizeProposal {
width: None,
height: None,
});
tree.layout(SizeProposal {
width: None,
height: None,
});
let b = tree.bounds(id);
assert!(
(b.width - 150.0).abs() < 0.5 && (b.height - 60.0).abs() < 0.5,
"expected (150, 60), got ({}, {})",
b.width,
b.height
);
assert!(
!tree.has_active_animations(),
"reduced-motion path must not register animations"
);
}
#[test]
fn empty_smooth_size_is_safe() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(SmoothSize::new());
tree.layout(SizeProposal::exact(100.0, 50.0));
let _ = tree.render();
}
#[test]
fn axes_width_only_pins_height() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(
SmoothSize::new()
.axes(SmoothSizeAxes::Width)
.duration(Duration::from_millis(100))
.child(TextWidget::new(lit!("hi"))),
);
tree.layout(SizeProposal {
width: None,
height: None,
});
tree.tick_animations(Duration::from_millis(150));
tree.layout(SizeProposal {
width: None,
height: None,
});
let b = tree.bounds(id);
assert!(b.height > 0.0);
}
}