1use std::cell::Cell;
47use std::rc::Rc;
48use std::time::Duration;
49
50use teksilo_canvas::{Point, Rect, Size, SizeProposal, Transform2D};
51use teksilo_core::accessibility::AccessNodeBuilder;
52use teksilo_core::binding::BindingLevel;
53use teksilo_core::build_context::BuildContext;
54use teksilo_core::signal::{Prop, Signal};
55use teksilo_core::widget::{LayoutContext, LayoutResponse, PendingChild, Widget, WidgetPlacement};
56use teksilo_core::widget_id::WidgetId;
57use teksilo_tokens::Easing;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ScaleOrigin {
63 Center,
65 TopLeading,
67 TopTrailing,
69 BottomLeading,
71 BottomTrailing,
73}
74
75impl ScaleOrigin {
76 pub(crate) fn pivot_world(self, bounds: Rect, is_rtl: bool) -> Point {
80 let (x_anchor, y_anchor) = match self {
81 Self::Center => (Anchor::Mid, Anchor::Mid),
82 Self::TopLeading => (Anchor::Leading, Anchor::Start),
83 Self::TopTrailing => (Anchor::Trailing, Anchor::Start),
84 Self::BottomLeading => (Anchor::Leading, Anchor::End),
85 Self::BottomTrailing => (Anchor::Trailing, Anchor::End),
86 };
87 let x = match (x_anchor, is_rtl) {
88 (Anchor::Mid, _) => bounds.x + bounds.width * 0.5,
89 (Anchor::Leading, false) | (Anchor::Trailing, true) => bounds.x,
90 (Anchor::Trailing, false) | (Anchor::Leading, true) => bounds.x + bounds.width,
91 (Anchor::Start, _) | (Anchor::End, _) => unreachable!(),
92 };
93 let y = match y_anchor {
94 Anchor::Start => bounds.y,
95 Anchor::Mid => bounds.y + bounds.height * 0.5,
96 Anchor::End => bounds.y + bounds.height,
97 Anchor::Leading | Anchor::Trailing => unreachable!(),
98 };
99 Point::new(x, y)
100 }
101}
102
103#[derive(Clone, Copy)]
104enum Anchor {
105 Leading,
106 Trailing,
107 Start,
108 Mid,
109 End,
110}
111
112fn centered_scale(pivot: Point, scale: f32) -> Transform2D {
115 Transform2D {
116 m: [
117 scale,
118 0.0,
119 0.0,
120 scale,
121 pivot.x * (1.0 - scale),
122 pivot.y * (1.0 - scale),
123 ],
124 }
125}
126
127pub struct Scale {
130 visible: Prop<bool>,
131 reflow: bool,
132 origin: ScaleOrigin,
133 duration: Option<Duration>,
134 easing: Option<Easing>,
135 pending_child: Option<PendingChild>,
136 child_id: Option<WidgetId>,
137 progress: Option<Signal<f32>>,
139 transform_signal: Option<Signal<Transform2D>>,
143 natural_size: Cell<Size>,
146 last_bounds: Rc<Cell<Rect>>,
152 last_is_rtl: Rc<Cell<bool>>,
156}
157
158impl Scale {
159 pub fn new(visible: impl Into<Prop<bool>>) -> Self {
164 Self {
165 visible: visible.into(),
166 reflow: false,
167 origin: ScaleOrigin::Center,
168 duration: None,
169 easing: None,
170 pending_child: None,
171 child_id: None,
172 progress: None,
173 transform_signal: None,
174 natural_size: Cell::new(Size::ZERO),
175 last_bounds: Rc::new(Cell::new(Rect::ZERO)),
176 last_is_rtl: Rc::new(Cell::new(false)),
177 }
178 }
179
180 pub fn reflow(mut self, reflow: bool) -> Self {
184 self.reflow = reflow;
185 self
186 }
187
188 pub fn origin(mut self, origin: ScaleOrigin) -> Self {
191 self.origin = origin;
192 self
193 }
194
195 pub fn duration(mut self, duration: Duration) -> Self {
197 self.duration = Some(duration);
198 self
199 }
200
201 pub fn easing(mut self, easing: Easing) -> Self {
203 self.easing = Some(easing);
204 self
205 }
206
207 pub fn child(mut self, widget: impl Widget + 'static) -> Self {
209 self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
210 self
211 }
212
213 pub fn child_id(mut self, id: WidgetId) -> Self {
215 self.pending_child = Some(PendingChild::Id(id));
216 self
217 }
218}
219
220impl std::fmt::Debug for Scale {
221 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222 f.debug_struct("Scale")
223 .field("reflow", &self.reflow)
224 .field("origin", &self.origin)
225 .finish()
226 }
227}
228
229impl Widget for Scale {
230 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
231 if let Some(pending) = self.pending_child.take() {
232 self.child_id = Some(match pending {
233 PendingChild::Id(id) => id,
234 PendingChild::Deferred(w) => ctx.add_boxed(w),
235 });
236 }
237 let Some(child_id) = self.child_id else {
238 return vec![];
239 };
240
241 let initial = if self.visible.get() { 1.0 } else { 0.0 };
242 let progress = ctx.animated_signal(initial);
243 let transform_signal = ctx.signal(Transform2D::IDENTITY);
244
245 let id = ctx.self_id();
247 ctx.set_transform(id, transform_signal.clone());
248
249 if self.reflow {
255 let registry = ctx.binding_registry();
256 progress.bind_to(id, registry, BindingLevel::Relayout);
257 }
258
259 let last_bounds = self.last_bounds.clone();
264 let last_is_rtl = self.last_is_rtl.clone();
265 let origin = self.origin;
266 let transform_for_observer = transform_signal.clone();
267 ctx.effect(&progress, move |&p| {
268 let p = p.clamp(0.0, 1.0);
269 let bounds = last_bounds.get();
270 let pivot = origin.pivot_world(bounds, last_is_rtl.get());
271 transform_for_observer.set(centered_scale(pivot, p));
272 });
273
274 self.progress = Some(progress.clone());
275 self.transform_signal = Some(transform_signal);
276
277 if let Prop::Bound(visible_signal) = &self.visible {
279 let visible_signal = visible_signal.clone();
280 let scale_anim = if let Some(d) = self.duration {
281 ctx.animate().duration(d)
282 } else {
283 ctx.animate().normal()
284 };
285 let scale_anim = if let Some(e) = self.easing {
286 scale_anim.easing(e)
287 } else {
288 scale_anim.standard()
289 };
290 let progress_for_effect = progress;
291 ctx.effect(&visible_signal, move |&v| {
292 let target = if v { 1.0 } else { 0.0 };
293 scale_anim.to_or_snap(&progress_for_effect, target);
294 });
295 }
296
297 vec![child_id]
298 }
299
300 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
301 let Some(child_id) = self.child_id else {
302 return proposal.resolve(0.0, 0.0).into();
303 };
304 let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
305 self.natural_size.set(natural);
306 if self.reflow {
307 let p = self
308 .progress
309 .as_ref()
310 .map(|s| s.get().clamp(0.0, 1.0))
311 .unwrap_or(1.0);
312 Size::new(natural.width * p, natural.height * p).into()
313 } else {
314 natural.into()
315 }
316 }
317
318 fn place_children(
319 &self,
320 bounds: Rect,
321 _proposal: SizeProposal,
322 children: &mut [WidgetPlacement],
323 ctx: &LayoutContext,
324 ) {
325 self.last_bounds.set(bounds);
329 self.last_is_rtl.set(ctx.is_rtl());
330 if let (Some(progress), Some(t_sig)) = (&self.progress, &self.transform_signal) {
331 let p = progress.get().clamp(0.0, 1.0);
332 let pivot = self.origin.pivot_world(bounds, ctx.is_rtl());
333 t_sig.set(centered_scale(pivot, p));
334 }
335
336 let natural = self.natural_size.get();
340 for child in children.iter_mut() {
341 child.origin = Point::new(bounds.x, bounds.y);
342 child.size = natural;
343 }
344 }
345
346 fn clips_children(&self) -> bool {
347 true
351 }
352
353 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
354 }
356
357 fn children(&self) -> Vec<WidgetId> {
358 self.child_id.into_iter().collect()
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use std::time::Duration;
365
366 use super::*;
367 use crate::primitives::TextWidget;
368 use teksilo_core::widget_tree::WidgetTree;
369 use teksilo_i18n::lit;
370
371 #[test]
372 fn starts_visible_when_signal_true_emits_identity_skip() {
373 let visible = Signal::new(true);
376 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
377 tree.add(Scale::new(visible).child(TextWidget::new(lit!("hello"))));
378 tree.layout(SizeProposal {
379 width: Some(200.0),
380 height: None,
381 });
382 let frame = tree.render();
383 let push_count = frame
384 .draw_order
385 .iter()
386 .filter(|c| matches!(c, teksilo_canvas::DrawCommand::PushTransform(_)))
387 .count();
388 assert_eq!(
389 push_count, 0,
390 "identity transform must be skipped, draw_order = {:?}",
391 frame.draw_order
392 );
393 }
394
395 #[test]
396 fn starts_hidden_when_signal_false_emits_zero_scale() {
397 let visible = Signal::new(false);
401 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
402 tree.add(Scale::new(visible).child(TextWidget::new(lit!("hello"))));
403 tree.layout(SizeProposal {
404 width: Some(200.0),
405 height: None,
406 });
407 let frame = tree.render();
408 let pushes: Vec<&Transform2D> = frame
409 .draw_order
410 .iter()
411 .filter_map(|c| match c {
412 teksilo_canvas::DrawCommand::PushTransform(t) => Some(t),
413 _ => None,
414 })
415 .collect();
416 assert_eq!(pushes.len(), 1);
417 assert!(pushes[0].m[0].abs() < 1e-3);
419 assert!(pushes[0].m[3].abs() < 1e-3);
420 }
421
422 #[test]
423 fn reflow_true_changes_layout_size() {
424 let visible = Signal::new(true);
427 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
428 let id = tree.add(
429 Scale::new(visible.clone())
430 .reflow(true)
431 .duration(Duration::from_millis(100))
432 .child(TextWidget::new(lit!("content"))),
433 );
434 tree.layout(SizeProposal {
435 width: Some(300.0),
436 height: None,
437 });
438 let initial_h = tree.bounds(id).height;
439 assert!(initial_h > 0.0);
440
441 visible.set(false);
442 tree.layout(SizeProposal {
445 width: Some(300.0),
446 height: None,
447 });
448 tree.tick_animations(Duration::from_millis(50));
449 tree.layout(SizeProposal {
450 width: Some(300.0),
451 height: None,
452 });
453 let mid_h = tree.bounds(id).height;
454 assert!(
455 mid_h < initial_h * 0.95,
456 "halfway through scale-out, height ({}) should be visibly less than initial ({})",
457 mid_h,
458 initial_h,
459 );
460 assert!(
461 mid_h > 0.0,
462 "halfway through scale-out, height ({}) should not yet be zero",
463 mid_h,
464 );
465 }
466
467 #[test]
468 fn reflow_false_keeps_layout_size_constant() {
469 let visible = Signal::new(true);
472 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
473 let id = tree.add(
474 Scale::new(visible.clone())
475 .duration(Duration::from_millis(100))
476 .child(TextWidget::new(lit!("content"))),
477 );
478 tree.layout(SizeProposal {
479 width: Some(300.0),
480 height: None,
481 });
482 let initial_size = tree.bounds(id).size();
483
484 visible.set(false);
485 tree.layout(SizeProposal {
486 width: Some(300.0),
487 height: None,
488 });
489 tree.tick_animations(Duration::from_millis(50));
490 tree.layout(SizeProposal {
491 width: Some(300.0),
492 height: None,
493 });
494 let mid_size = tree.bounds(id).size();
495 assert_eq!(
496 initial_size, mid_size,
497 "visual-only scale must not change layout"
498 );
499 }
500
501 #[test]
502 fn reduced_motion_snaps_scale() {
503 let visible = Signal::new(true);
504 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
505 tree.set_accessibility_preferences(false, true, 1.0);
506 tree.add(Scale::new(visible.clone()).child(TextWidget::new(lit!("x"))));
507 tree.layout(SizeProposal {
508 width: Some(200.0),
509 height: None,
510 });
511
512 visible.set(false);
513 assert!(
516 !tree.has_active_animations(),
517 "reduced-motion path must not register a scale animation"
518 );
519 }
520}