use std::cell::Cell;
use std::rc::Rc;
use std::time::Duration;
use teksilo_canvas::{Point, Rect, Size, SizeProposal, Transform2D};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::binding::BindingLevel;
use teksilo_core::build_context::BuildContext;
use teksilo_core::signal::{Prop, Signal};
use teksilo_core::widget::{LayoutContext, LayoutResponse, PendingChild, Widget, WidgetPlacement};
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::Easing;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScaleOrigin {
Center,
TopLeading,
TopTrailing,
BottomLeading,
BottomTrailing,
}
impl ScaleOrigin {
pub(crate) fn pivot_world(self, bounds: Rect, is_rtl: bool) -> Point {
let (x_anchor, y_anchor) = match self {
Self::Center => (Anchor::Mid, Anchor::Mid),
Self::TopLeading => (Anchor::Leading, Anchor::Start),
Self::TopTrailing => (Anchor::Trailing, Anchor::Start),
Self::BottomLeading => (Anchor::Leading, Anchor::End),
Self::BottomTrailing => (Anchor::Trailing, Anchor::End),
};
let x = match (x_anchor, is_rtl) {
(Anchor::Mid, _) => bounds.x + bounds.width * 0.5,
(Anchor::Leading, false) | (Anchor::Trailing, true) => bounds.x,
(Anchor::Trailing, false) | (Anchor::Leading, true) => bounds.x + bounds.width,
(Anchor::Start, _) | (Anchor::End, _) => unreachable!(),
};
let y = match y_anchor {
Anchor::Start => bounds.y,
Anchor::Mid => bounds.y + bounds.height * 0.5,
Anchor::End => bounds.y + bounds.height,
Anchor::Leading | Anchor::Trailing => unreachable!(),
};
Point::new(x, y)
}
}
#[derive(Clone, Copy)]
enum Anchor {
Leading,
Trailing,
Start,
Mid,
End,
}
fn centered_scale(pivot: Point, scale: f32) -> Transform2D {
Transform2D {
m: [
scale,
0.0,
0.0,
scale,
pivot.x * (1.0 - scale),
pivot.y * (1.0 - scale),
],
}
}
pub struct Scale {
visible: Prop<bool>,
reflow: bool,
origin: ScaleOrigin,
duration: Option<Duration>,
easing: Option<Easing>,
pending_child: Option<PendingChild>,
child_id: Option<WidgetId>,
progress: Option<Signal<f32>>,
transform_signal: Option<Signal<Transform2D>>,
natural_size: Cell<Size>,
last_bounds: Rc<Cell<Rect>>,
last_is_rtl: Rc<Cell<bool>>,
}
impl Scale {
pub fn new(visible: impl Into<Prop<bool>>) -> Self {
Self {
visible: visible.into(),
reflow: false,
origin: ScaleOrigin::Center,
duration: None,
easing: None,
pending_child: None,
child_id: None,
progress: None,
transform_signal: None,
natural_size: Cell::new(Size::ZERO),
last_bounds: Rc::new(Cell::new(Rect::ZERO)),
last_is_rtl: Rc::new(Cell::new(false)),
}
}
pub fn reflow(mut self, reflow: bool) -> Self {
self.reflow = reflow;
self
}
pub fn origin(mut self, origin: ScaleOrigin) -> Self {
self.origin = origin;
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
}
}
impl std::fmt::Debug for Scale {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Scale")
.field("reflow", &self.reflow)
.field("origin", &self.origin)
.finish()
}
}
impl Widget for Scale {
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 initial = if self.visible.get() { 1.0 } else { 0.0 };
let progress = ctx.animated_signal(initial);
let transform_signal = ctx.signal(Transform2D::IDENTITY);
let id = ctx.self_id();
ctx.set_transform(id, transform_signal.clone());
if self.reflow {
let registry = ctx.binding_registry();
progress.bind_to(id, registry, BindingLevel::Relayout);
}
let last_bounds = self.last_bounds.clone();
let last_is_rtl = self.last_is_rtl.clone();
let origin = self.origin;
let transform_for_observer = transform_signal.clone();
ctx.effect(&progress, move |&p| {
let p = p.clamp(0.0, 1.0);
let bounds = last_bounds.get();
let pivot = origin.pivot_world(bounds, last_is_rtl.get());
transform_for_observer.set(centered_scale(pivot, p));
});
self.progress = Some(progress.clone());
self.transform_signal = Some(transform_signal);
if let Prop::Bound(visible_signal) = &self.visible {
let visible_signal = visible_signal.clone();
let scale_anim = if let Some(d) = self.duration {
ctx.animate().duration(d)
} else {
ctx.animate().normal()
};
let scale_anim = if let Some(e) = self.easing {
scale_anim.easing(e)
} else {
scale_anim.standard()
};
let progress_for_effect = progress;
ctx.effect(&visible_signal, move |&v| {
let target = if v { 1.0 } else { 0.0 };
scale_anim.to_or_snap(&progress_for_effect, target);
});
}
vec![child_id]
}
fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> 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);
if self.reflow {
let p = self
.progress
.as_ref()
.map(|s| s.get().clamp(0.0, 1.0))
.unwrap_or(1.0);
Size::new(natural.width * p, natural.height * p).into()
} else {
natural.into()
}
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
ctx: &LayoutContext,
) {
self.last_bounds.set(bounds);
self.last_is_rtl.set(ctx.is_rtl());
if let (Some(progress), Some(t_sig)) = (&self.progress, &self.transform_signal) {
let p = progress.get().clamp(0.0, 1.0);
let pivot = self.origin.pivot_world(bounds, ctx.is_rtl());
t_sig.set(centered_scale(pivot, p));
}
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 std::time::Duration;
use super::*;
use crate::primitives::TextWidget;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_i18n::lit;
#[test]
fn starts_visible_when_signal_true_emits_identity_skip() {
let visible = Signal::new(true);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(Scale::new(visible).child(TextWidget::new(lit!("hello"))));
tree.layout(SizeProposal {
width: Some(200.0),
height: None,
});
let frame = tree.render();
let push_count = frame
.draw_order
.iter()
.filter(|c| matches!(c, teksilo_canvas::DrawCommand::PushTransform(_)))
.count();
assert_eq!(
push_count, 0,
"identity transform must be skipped, draw_order = {:?}",
frame.draw_order
);
}
#[test]
fn starts_hidden_when_signal_false_emits_zero_scale() {
let visible = Signal::new(false);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(Scale::new(visible).child(TextWidget::new(lit!("hello"))));
tree.layout(SizeProposal {
width: Some(200.0),
height: None,
});
let frame = tree.render();
let pushes: Vec<&Transform2D> = frame
.draw_order
.iter()
.filter_map(|c| match c {
teksilo_canvas::DrawCommand::PushTransform(t) => Some(t),
_ => None,
})
.collect();
assert_eq!(pushes.len(), 1);
assert!(pushes[0].m[0].abs() < 1e-3);
assert!(pushes[0].m[3].abs() < 1e-3);
}
#[test]
fn reflow_true_changes_layout_size() {
let visible = Signal::new(true);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(
Scale::new(visible.clone())
.reflow(true)
.duration(Duration::from_millis(100))
.child(TextWidget::new(lit!("content"))),
);
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
let initial_h = tree.bounds(id).height;
assert!(initial_h > 0.0);
visible.set(false);
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
tree.tick_animations(Duration::from_millis(50));
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
let mid_h = tree.bounds(id).height;
assert!(
mid_h < initial_h * 0.95,
"halfway through scale-out, height ({}) should be visibly less than initial ({})",
mid_h,
initial_h,
);
assert!(
mid_h > 0.0,
"halfway through scale-out, height ({}) should not yet be zero",
mid_h,
);
}
#[test]
fn reflow_false_keeps_layout_size_constant() {
let visible = Signal::new(true);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(
Scale::new(visible.clone())
.duration(Duration::from_millis(100))
.child(TextWidget::new(lit!("content"))),
);
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
let initial_size = tree.bounds(id).size();
visible.set(false);
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
tree.tick_animations(Duration::from_millis(50));
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
let mid_size = tree.bounds(id).size();
assert_eq!(
initial_size, mid_size,
"visual-only scale must not change layout"
);
}
#[test]
fn reduced_motion_snaps_scale() {
let visible = Signal::new(true);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.set_accessibility_preferences(false, true, 1.0);
tree.add(Scale::new(visible.clone()).child(TextWidget::new(lit!("x"))));
tree.layout(SizeProposal {
width: Some(200.0),
height: None,
});
visible.set(false);
assert!(
!tree.has_active_animations(),
"reduced-motion path must not register a scale animation"
);
}
}