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 teksilo_core::IntoTeksiChild) -> Self {
209 self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
210 self
211 }
212 pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
219 match widget {
220 Some(w) => self.child(w),
221 None => self,
222 }
223 }
224}
225
226impl std::fmt::Debug for Scale {
227 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228 f.debug_struct("Scale")
229 .field("reflow", &self.reflow)
230 .field("origin", &self.origin)
231 .finish()
232 }
233}
234
235impl Widget for Scale {
236 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
237 if let Some(pending) = self.pending_child.take() {
238 self.child_id = Some(match pending {
239 PendingChild::Id(id) => id,
240 PendingChild::Deferred(w) => ctx.add_boxed(w),
241 });
242 }
243 let Some(child_id) = self.child_id else {
244 return vec![];
245 };
246
247 let initial = if self.visible.get() { 1.0 } else { 0.0 };
248 let progress = ctx.animated_signal(initial);
249 let transform_signal = ctx.signal(Transform2D::IDENTITY);
250
251 let id = ctx.self_id();
253 ctx.set_transform(id, transform_signal.clone());
254
255 if self.reflow {
261 let registry = ctx.binding_registry();
262 progress.bind_to(id, registry, BindingLevel::Relayout);
263 }
264
265 let last_bounds = self.last_bounds.clone();
270 let last_is_rtl = self.last_is_rtl.clone();
271 let origin = self.origin;
272 let transform_for_observer = transform_signal.clone();
273 ctx.effect(&progress, move |&p| {
274 let p = p.clamp(0.0, 1.0);
275 let bounds = last_bounds.get();
276 let pivot = origin.pivot_world(bounds, last_is_rtl.get());
277 transform_for_observer.set(centered_scale(pivot, p));
278 });
279
280 self.progress = Some(progress.clone());
281 self.transform_signal = Some(transform_signal);
282
283 if let Prop::Bound(visible_signal) = &self.visible {
285 let visible_signal = visible_signal.clone();
286 let scale_anim = if let Some(d) = self.duration {
287 ctx.animate().duration(d)
288 } else {
289 ctx.animate().normal()
290 };
291 let scale_anim = if let Some(e) = self.easing {
292 scale_anim.easing(e)
293 } else {
294 scale_anim.standard()
295 };
296 let progress_for_effect = progress;
297 ctx.effect(&visible_signal, move |&v| {
298 let target = if v { 1.0 } else { 0.0 };
299 scale_anim.to_or_snap(&progress_for_effect, target);
300 });
301 }
302
303 vec![child_id]
304 }
305
306 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
307 let Some(child_id) = self.child_id else {
308 return proposal.resolve(0.0, 0.0).into();
309 };
310 let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
311 self.natural_size.set(natural);
312 if self.reflow {
313 let p = self
314 .progress
315 .as_ref()
316 .map(|s| s.get().clamp(0.0, 1.0))
317 .unwrap_or(1.0);
318 Size::new(natural.width * p, natural.height * p).into()
319 } else {
320 natural.into()
321 }
322 }
323
324 fn place_children(
325 &self,
326 bounds: Rect,
327 _proposal: SizeProposal,
328 children: &mut [WidgetPlacement],
329 ctx: &LayoutContext,
330 ) {
331 self.last_bounds.set(bounds);
335 self.last_is_rtl.set(ctx.is_rtl());
336 if let (Some(progress), Some(t_sig)) = (&self.progress, &self.transform_signal) {
337 let p = progress.get().clamp(0.0, 1.0);
338 let pivot = self.origin.pivot_world(bounds, ctx.is_rtl());
339 t_sig.set(centered_scale(pivot, p));
340 }
341
342 let natural = self.natural_size.get();
346 for child in children.iter_mut() {
347 child.origin = Point::new(bounds.x, bounds.y);
348 child.size = natural;
349 }
350 }
351
352 fn clips_children(&self) -> bool {
353 true
357 }
358
359 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
360 }
362
363 fn children(&self) -> Vec<WidgetId> {
364 self.child_id.into_iter().collect()
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use std::time::Duration;
371
372 use super::*;
373 use crate::primitives::TextWidget;
374 use teksilo_core::widget_tree::WidgetTree;
375 use teksilo_i18n::lit;
376
377 #[test]
378 fn starts_visible_when_signal_true_emits_identity_skip() {
379 let visible = Signal::new(true);
382 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
383 tree.add(Scale::new(visible).child(TextWidget::new(lit!("hello"))));
384 tree.layout(SizeProposal {
385 width: Some(200.0),
386 height: None,
387 });
388 let frame = tree.render();
389 let push_count = frame
390 .draw_order
391 .iter()
392 .filter(|c| matches!(c, teksilo_canvas::DrawCommand::PushTransform(_)))
393 .count();
394 assert_eq!(
395 push_count, 0,
396 "identity transform must be skipped, draw_order = {:?}",
397 frame.draw_order
398 );
399 }
400
401 #[test]
402 fn starts_hidden_when_signal_false_emits_zero_scale() {
403 let visible = Signal::new(false);
407 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
408 tree.add(Scale::new(visible).child(TextWidget::new(lit!("hello"))));
409 tree.layout(SizeProposal {
410 width: Some(200.0),
411 height: None,
412 });
413 let frame = tree.render();
414 let pushes: Vec<&Transform2D> = frame
415 .draw_order
416 .iter()
417 .filter_map(|c| match c {
418 teksilo_canvas::DrawCommand::PushTransform(t) => Some(t),
419 _ => None,
420 })
421 .collect();
422 assert_eq!(pushes.len(), 1);
423 assert!(pushes[0].m[0].abs() < 1e-3);
425 assert!(pushes[0].m[3].abs() < 1e-3);
426 }
427
428 #[test]
429 fn reflow_true_changes_layout_size() {
430 let visible = Signal::new(true);
433 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
434 let id = tree.add(
435 Scale::new(visible.clone())
436 .reflow(true)
437 .duration(Duration::from_millis(100))
438 .child(TextWidget::new(lit!("content"))),
439 );
440 tree.layout(SizeProposal {
441 width: Some(300.0),
442 height: None,
443 });
444 let initial_h = tree.bounds(id).height;
445 assert!(initial_h > 0.0);
446
447 visible.set(false);
448 tree.layout(SizeProposal {
451 width: Some(300.0),
452 height: None,
453 });
454 tree.tick_animations(Duration::from_millis(50));
455 tree.layout(SizeProposal {
456 width: Some(300.0),
457 height: None,
458 });
459 let mid_h = tree.bounds(id).height;
460 assert!(
461 mid_h < initial_h * 0.95,
462 "halfway through scale-out, height ({}) should be visibly less than initial ({})",
463 mid_h,
464 initial_h,
465 );
466 assert!(
467 mid_h > 0.0,
468 "halfway through scale-out, height ({}) should not yet be zero",
469 mid_h,
470 );
471 }
472
473 #[test]
474 fn reflow_false_keeps_layout_size_constant() {
475 let visible = Signal::new(true);
478 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
479 let id = tree.add(
480 Scale::new(visible.clone())
481 .duration(Duration::from_millis(100))
482 .child(TextWidget::new(lit!("content"))),
483 );
484 tree.layout(SizeProposal {
485 width: Some(300.0),
486 height: None,
487 });
488 let initial_size = tree.bounds(id).size();
489
490 visible.set(false);
491 tree.layout(SizeProposal {
492 width: Some(300.0),
493 height: None,
494 });
495 tree.tick_animations(Duration::from_millis(50));
496 tree.layout(SizeProposal {
497 width: Some(300.0),
498 height: None,
499 });
500 let mid_size = tree.bounds(id).size();
501 assert_eq!(
502 initial_size, mid_size,
503 "visual-only scale must not change layout"
504 );
505 }
506
507 #[test]
508 fn reduced_motion_snaps_scale() {
509 let visible = Signal::new(true);
510 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
511 tree.set_accessibility_preferences(false, true, 1.0);
512 tree.add(Scale::new(visible.clone()).child(TextWidget::new(lit!("x"))));
513 tree.layout(SizeProposal {
514 width: Some(200.0),
515 height: None,
516 });
517
518 visible.set(false);
519 assert!(
522 !tree.has_active_animations(),
523 "reduced-motion path must not register a scale animation"
524 );
525 }
526}