1use std::cell::Cell;
35use std::rc::Rc;
36
37use teksilo_canvas::{Point, Rect, Size, SizeProposal, Transform2D};
38use teksilo_core::accessibility::AccessNodeBuilder;
39use teksilo_core::build_context::BuildContext;
40use teksilo_core::signal::{Prop, Signal};
41use teksilo_core::widget::{LayoutContext, LayoutResponse, PendingChild, Widget, WidgetPlacement};
42use teksilo_core::widget_id::WidgetId;
43
44use super::scale::ScaleOrigin;
45
46fn pivoted_rotation(pivot: Point, theta: f32) -> Transform2D {
48 let (s, c) = theta.sin_cos();
49 Transform2D {
50 m: [
51 c,
52 s,
53 -s,
54 c,
55 pivot.x * (1.0 - c) + pivot.y * s,
56 pivot.y * (1.0 - c) - pivot.x * s,
57 ],
58 }
59}
60
61pub struct Rotate {
64 angle: Prop<f32>,
65 origin: ScaleOrigin,
66 pending_child: Option<PendingChild>,
67 child_id: Option<WidgetId>,
68 transform_signal: Option<Signal<Transform2D>>,
73 natural_size: Cell<Size>,
74 last_bounds: Rc<Cell<Rect>>,
75 last_is_rtl: Rc<Cell<bool>>,
76}
77
78impl Rotate {
79 pub fn new(angle: impl Into<Prop<f32>>) -> Self {
82 Self {
83 angle: angle.into(),
84 origin: ScaleOrigin::Center,
85 pending_child: None,
86 child_id: None,
87 transform_signal: None,
88 natural_size: Cell::new(Size::ZERO),
89 last_bounds: Rc::new(Cell::new(Rect::ZERO)),
90 last_is_rtl: Rc::new(Cell::new(false)),
91 }
92 }
93
94 pub fn origin(mut self, origin: ScaleOrigin) -> Self {
96 self.origin = origin;
97 self
98 }
99
100 pub fn child(mut self, widget: impl Widget + 'static) -> Self {
102 self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
103 self
104 }
105
106 pub fn child_id(mut self, id: WidgetId) -> Self {
108 self.pending_child = Some(PendingChild::Id(id));
109 self
110 }
111}
112
113impl std::fmt::Debug for Rotate {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 f.debug_struct("Rotate")
116 .field("origin", &self.origin)
117 .finish()
118 }
119}
120
121impl Widget for Rotate {
122 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
123 if let Some(pending) = self.pending_child.take() {
124 self.child_id = Some(match pending {
125 PendingChild::Id(id) => id,
126 PendingChild::Deferred(w) => ctx.add_boxed(w),
127 });
128 }
129 let Some(child_id) = self.child_id else {
130 return vec![];
131 };
132
133 let transform_signal = ctx.signal(Transform2D::IDENTITY);
134 let id = ctx.self_id();
135 ctx.set_transform(id, transform_signal.clone());
136
137 if let Prop::Bound(angle_signal) = &self.angle {
141 ctx.register_animated_signal(angle_signal);
150
151 let last_bounds = self.last_bounds.clone();
152 let last_is_rtl = self.last_is_rtl.clone();
153 let origin = self.origin;
154 let transform_for_observer = transform_signal.clone();
155 ctx.effect(angle_signal, move |&theta| {
156 let bounds = last_bounds.get();
157 let pivot = origin.pivot_world(bounds, last_is_rtl.get());
158 transform_for_observer.set(pivoted_rotation(pivot, theta));
159 });
160 }
161
162 self.transform_signal = Some(transform_signal);
163
164 vec![child_id]
165 }
166
167 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
168 let Some(child_id) = self.child_id else {
169 return proposal.resolve(0.0, 0.0).into();
170 };
171 let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
172 self.natural_size.set(natural);
173 natural.into()
174 }
175
176 fn place_children(
177 &self,
178 bounds: Rect,
179 _proposal: SizeProposal,
180 children: &mut [WidgetPlacement],
181 ctx: &LayoutContext,
182 ) {
183 self.last_bounds.set(bounds);
187 self.last_is_rtl.set(ctx.is_rtl());
188 if let Some(t_sig) = &self.transform_signal {
189 let theta = self.angle.get();
190 let pivot = self.origin.pivot_world(bounds, ctx.is_rtl());
191 t_sig.set(pivoted_rotation(pivot, theta));
192 }
193
194 let natural = self.natural_size.get();
195 for child in children.iter_mut() {
196 child.origin = Point::new(bounds.x, bounds.y);
197 child.size = natural;
198 }
199 }
200
201 fn clips_children(&self) -> bool {
202 false
210 }
211
212 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
213 }
215
216 fn children(&self) -> Vec<WidgetId> {
217 self.child_id.into_iter().collect()
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use crate::primitives::TextWidget;
225 use teksilo_core::widget_tree::WidgetTree;
226 use teksilo_i18n::lit;
227
228 #[test]
229 fn zero_angle_emits_identity_skip() {
230 let angle = Signal::new(0.0_f32);
231 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
232 tree.add(Rotate::new(angle).child(TextWidget::new(lit!("x"))));
233 tree.layout(SizeProposal {
234 width: Some(200.0),
235 height: None,
236 });
237 let frame = tree.render();
238 let push_count = frame
239 .draw_order
240 .iter()
241 .filter(|c| matches!(c, teksilo_canvas::DrawCommand::PushTransform(_)))
242 .count();
243 assert_eq!(push_count, 0, "zero rotation must skip the transform scope");
244 }
245
246 #[test]
247 fn nonzero_angle_emits_rotation_matrix() {
248 let angle = Signal::new(std::f32::consts::FRAC_PI_2); let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
250 tree.add(Rotate::new(angle).child(TextWidget::new(lit!("x"))));
251 tree.layout(SizeProposal {
252 width: Some(200.0),
253 height: None,
254 });
255 let frame = tree.render();
256 let pushes: Vec<&Transform2D> = frame
257 .draw_order
258 .iter()
259 .filter_map(|c| match c {
260 teksilo_canvas::DrawCommand::PushTransform(t) => Some(t),
261 _ => None,
262 })
263 .collect();
264 assert_eq!(pushes.len(), 1);
265 assert!(pushes[0].m[0].abs() < 1e-3, "a (cos) should be 0");
267 assert!((pushes[0].m[1] - 1.0).abs() < 1e-3, "b (sin) should be 1");
268 assert!(
269 (pushes[0].m[2] - (-1.0)).abs() < 1e-3,
270 "c (-sin) should be -1"
271 );
272 assert!(pushes[0].m[3].abs() < 1e-3, "d (cos) should be 0");
273 }
274
275 #[test]
276 fn layout_size_unchanged_by_rotation() {
277 let angle = Signal::new(0.0_f32);
281 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
282 let id = tree.add(Rotate::new(angle.clone()).child(TextWidget::new(lit!("hello"))));
283 tree.layout(SizeProposal {
284 width: Some(300.0),
285 height: None,
286 });
287 let initial = tree.bounds(id).size();
288
289 angle.set(std::f32::consts::FRAC_PI_4);
291 tree.layout(SizeProposal {
292 width: Some(300.0),
293 height: None,
294 });
295 let after = tree.bounds(id).size();
296 assert_eq!(initial, after, "rotation must not change layout size");
297 }
298
299 #[test]
300 fn animate_to_actually_advances_angle_value() {
301 use std::time::Duration;
306 let angle = Signal::new_animated(0.0_f32);
307 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
308 tree.add(Rotate::new(angle.clone()).child(TextWidget::new(lit!("x"))));
309 tree.layout(SizeProposal {
310 width: Some(200.0),
311 height: None,
312 });
313 let frame0 = tree.render();
315 assert_eq!(
316 frame0
317 .draw_order
318 .iter()
319 .filter(|c| matches!(c, teksilo_canvas::DrawCommand::PushTransform(_)))
320 .count(),
321 0,
322 "initial angle 0 → no transform scope"
323 );
324
325 angle.animate_to(
326 std::f32::consts::FRAC_PI_2,
327 Duration::from_millis(100),
328 teksilo_tokens::Easing::Linear,
329 );
330 tree.layout(SizeProposal {
332 width: Some(200.0),
333 height: None,
334 });
335 tree.tick_animations(Duration::from_millis(80));
337 tree.layout(SizeProposal {
338 width: Some(200.0),
339 height: None,
340 });
341 assert!(
344 angle.get() > 0.1,
345 "angle value must have advanced from 0 (got {})",
346 angle.get()
347 );
348 let frame1 = tree.render();
349 let pushes: Vec<&Transform2D> = frame1
350 .draw_order
351 .iter()
352 .filter_map(|c| match c {
353 teksilo_canvas::DrawCommand::PushTransform(t) => Some(t),
354 _ => None,
355 })
356 .collect();
357 assert_eq!(
358 pushes.len(),
359 1,
360 "advanced angle must emit a transform scope"
361 );
362 }
363
364 #[test]
365 fn rotation_pivot_in_zstack_with_center_dot() {
366 use crate::primitives::{Center, FixedSize, RectWidget, ZStack};
371 use std::time::Duration;
372 use teksilo_tokens::Color;
373 let angle = Signal::new_animated(0.0_f32);
374 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
375 tree.add(
376 FixedSize::new().width(80.0).height(80.0).child(
377 ZStack::new()
378 .child(
379 Rotate::new(angle.clone())
380 .child(RectWidget::new().background(Color::from_rgb(0.30, 0.55, 0.85))),
381 )
382 .child(
383 Center::new().child(
384 FixedSize::new()
385 .width(6.0)
386 .height(6.0)
387 .child(RectWidget::new().background(Color::BLACK)),
388 ),
389 ),
390 ),
391 );
392 tree.layout(SizeProposal::exact(120.0, 120.0));
393
394 angle.animate_to(
396 std::f32::consts::FRAC_PI_2,
397 Duration::from_millis(100),
398 teksilo_tokens::Easing::Linear,
399 );
400 tree.layout(SizeProposal::exact(120.0, 120.0));
401 tree.tick_animations(Duration::from_millis(50));
402 tree.layout(SizeProposal::exact(120.0, 120.0));
403
404 let frame = tree.render();
405
406 let push = frame
410 .draw_order
411 .iter()
412 .find_map(|c| match c {
413 teksilo_canvas::DrawCommand::PushTransform(t) => Some(*t),
414 _ => None,
415 })
416 .expect("rotation must emit a transform scope mid-tween");
417 let c = push.m[0];
418 let s = push.m[1];
419 let tx = push.m[4];
420 let ty = push.m[5];
421 let det = (1.0 - c) * (1.0 - c) + s * s;
422 let recovered_px = ((1.0 - c) * tx - s * ty) / det;
423 let recovered_py = (s * tx + (1.0 - c) * ty) / det;
424
425 let black_array = Color::BLACK.to_array();
427 let dot = frame
428 .shapes
429 .iter()
430 .find(|sh| sh.color == black_array)
431 .expect("dot must paint");
432 let dot_center_x = dot.screen[0] + dot.screen[2] * 0.5;
433 let dot_center_y = dot.screen[1] + dot.screen[3] * 0.5;
434
435 let blue_array = Color::from_rgb(0.30, 0.55, 0.85).to_array();
437 let cube = frame
438 .shapes
439 .iter()
440 .find(|sh| sh.color == blue_array)
441 .expect("cube must paint");
442 let cube_center_x = cube.screen[0] + cube.screen[2] * 0.5;
443 let cube_center_y = cube.screen[1] + cube.screen[3] * 0.5;
444
445 let dot_pivot_err =
447 (recovered_px - dot_center_x).abs() + (recovered_py - dot_center_y).abs();
448 let cube_pivot_err =
449 (recovered_px - cube_center_x).abs() + (recovered_py - cube_center_y).abs();
450 assert!(
451 dot_pivot_err < 1.0,
452 "pivot ({}, {}) must match DOT center ({}, {}); err = {}",
453 recovered_px,
454 recovered_py,
455 dot_center_x,
456 dot_center_y,
457 dot_pivot_err,
458 );
459 assert!(
460 cube_pivot_err < 1.0,
461 "pivot ({}, {}) must match CUBE center ({}, {}); err = {}",
462 recovered_px,
463 recovered_py,
464 cube_center_x,
465 cube_center_y,
466 cube_pivot_err,
467 );
468 }
469
470 #[test]
471 fn rotation_pivot_inside_scroll_area_matches_visual_center() {
472 use crate::primitives::{FixedSize, HStack, Padding, RectWidget, VStack};
477 use crate::scroll_area::ScrollArea;
478 use std::time::Duration;
479 use teksilo_tokens::Color;
480 let angle = Signal::new_animated(0.0_f32);
481 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
482 tree.add(
483 ScrollArea::new().child(
484 Padding::uniform(24.0).child(
485 VStack::new()
486 .spacing(20.0)
487 .child(TextWidget::new(lit!("filler 1")))
488 .child(TextWidget::new(lit!("filler 2")))
489 .child(TextWidget::new(lit!("filler 3")))
490 .child(
491 HStack::new()
492 .spacing(12.0)
493 .child(
494 Rotate::new(angle.clone()).child(
495 FixedSize::new()
496 .width(28.0)
497 .height(28.0)
498 .child(RectWidget::new().background(Color::RED)),
499 ),
500 )
501 .child(TextWidget::new(lit!("Rotate 90°"))),
502 ),
503 ),
504 ),
505 );
506 tree.layout(SizeProposal::exact(560.0, 720.0));
507
508 angle.animate_to(
509 std::f32::consts::FRAC_PI_2,
510 Duration::from_millis(100),
511 teksilo_tokens::Easing::Linear,
512 );
513 tree.layout(SizeProposal::exact(560.0, 720.0));
514 tree.tick_animations(Duration::from_millis(50));
515 tree.layout(SizeProposal::exact(560.0, 720.0));
516
517 let frame = tree.render();
518 let push = frame
519 .draw_order
520 .iter()
521 .find_map(|c| match c {
522 teksilo_canvas::DrawCommand::PushTransform(t) => Some(*t),
523 _ => None,
524 })
525 .expect("rotation must emit a transform scope mid-tween");
526 let c = push.m[0];
527 let s = push.m[1];
528 let tx = push.m[4];
529 let ty = push.m[5];
530 let det = (1.0 - c) * (1.0 - c) + s * s;
531 let recovered_px = ((1.0 - c) * tx - s * ty) / det;
532 let recovered_py = (s * tx + (1.0 - c) * ty) / det;
533
534 let cube_shape = frame
535 .shapes
536 .iter()
537 .find(|s| s.color == Color::RED.to_array())
538 .expect("cube must paint a shape");
539 let visual_center_x = cube_shape.screen[0] + cube_shape.screen[2] * 0.5;
540 let visual_center_y = cube_shape.screen[1] + cube_shape.screen[3] * 0.5;
541 let err_x = (recovered_px - visual_center_x).abs();
542 let err_y = (recovered_py - visual_center_y).abs();
543 assert!(
544 err_x < 1.0 && err_y < 1.0,
545 "ScrollArea-nested pivot ({}, {}) must match visual center ({}, {}); err = ({}, {})",
546 recovered_px,
547 recovered_py,
548 visual_center_x,
549 visual_center_y,
550 err_x,
551 err_y,
552 );
553 }
554
555 #[test]
556 fn rotation_pivot_in_kit_like_structure_after_animate_to() {
557 use crate::primitives::{FixedSize, HStack, Padding, RectWidget, VStack};
563 use std::time::Duration;
564 use teksilo_tokens::Color;
565 let angle = Signal::new_animated(0.0_f32);
566 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
567 tree.add(
570 Padding::uniform(24.0).child(
571 VStack::new()
572 .spacing(20.0)
573 .child(TextWidget::new(lit!("filler")))
574 .child(TextWidget::new(lit!("filler")))
575 .child(TextWidget::new(lit!("filler")))
576 .child(
577 HStack::new()
578 .spacing(12.0)
579 .child(
580 Rotate::new(angle.clone()).child(
581 FixedSize::new()
582 .width(28.0)
583 .height(28.0)
584 .child(RectWidget::new().background(Color::RED)),
585 ),
586 )
587 .child(TextWidget::new(lit!("Rotate 90°"))),
588 ),
589 ),
590 );
591 tree.layout(SizeProposal {
592 width: Some(560.0),
593 height: None,
594 });
595
596 angle.animate_to(
598 std::f32::consts::FRAC_PI_2,
599 Duration::from_millis(100),
600 teksilo_tokens::Easing::Linear,
601 );
602 tree.layout(SizeProposal {
603 width: Some(560.0),
604 height: None,
605 });
606 tree.tick_animations(Duration::from_millis(50));
607 tree.layout(SizeProposal {
608 width: Some(560.0),
609 height: None,
610 });
611
612 let frame = tree.render();
613 let push = frame
614 .draw_order
615 .iter()
616 .find_map(|c| match c {
617 teksilo_canvas::DrawCommand::PushTransform(t) => Some(*t),
618 _ => None,
619 })
620 .expect("rotation must emit a transform scope mid-tween");
621 let c = push.m[0];
628 let s = push.m[1];
629 let tx = push.m[4];
630 let ty = push.m[5];
631 let det = (1.0 - c) * (1.0 - c) + s * s;
632 assert!(det > 1e-3, "non-trivial rotation expected");
633 let recovered_px = ((1.0 - c) * tx - s * ty) / det;
634 let recovered_py = (s * tx + (1.0 - c) * ty) / det;
635
636 let cube_shape = frame
638 .shapes
639 .iter()
640 .find(|s| s.color == Color::RED.to_array())
641 .expect("cube must paint a shape");
642 let visual_center_x = cube_shape.screen[0] + cube_shape.screen[2] * 0.5;
643 let visual_center_y = cube_shape.screen[1] + cube_shape.screen[3] * 0.5;
644 let err_x = (recovered_px - visual_center_x).abs();
645 let err_y = (recovered_py - visual_center_y).abs();
646 assert!(
647 err_x < 1.0 && err_y < 1.0,
648 "pivot ({}, {}) must match cube's visual center ({}, {}); err = ({}, {})",
649 recovered_px,
650 recovered_py,
651 visual_center_x,
652 visual_center_y,
653 err_x,
654 err_y,
655 );
656 }
657
658 #[test]
659 fn rotation_pivot_lands_at_visual_center_when_inside_hstack() {
660 use crate::primitives::{FixedSize, HStack, RectWidget};
669 use teksilo_tokens::Color;
670 let angle = Signal::new(std::f32::consts::FRAC_PI_2); let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
672 tree.add(
675 HStack::new()
676 .spacing(0.0)
677 .child(
678 Rotate::new(angle).child(
679 FixedSize::new()
680 .width(28.0)
681 .height(28.0)
682 .child(RectWidget::new().background(Color::RED)),
683 ),
684 )
685 .child(
686 FixedSize::new()
687 .width(40.0)
688 .height(80.0)
689 .child(RectWidget::new().background(Color::BLUE)),
690 ),
691 );
692 tree.layout(SizeProposal {
693 width: None,
694 height: None,
695 });
696 let frame = tree.render();
697 let pushes: Vec<&Transform2D> = frame
698 .draw_order
699 .iter()
700 .filter_map(|c| match c {
701 teksilo_canvas::DrawCommand::PushTransform(t) => Some(t),
702 _ => None,
703 })
704 .collect();
705 assert_eq!(pushes.len(), 1, "Rotate must emit one push");
706
707 let tx = pushes[0].m[4];
713 let ty = pushes[0].m[5];
714 let recovered_pivot_x = (tx - ty) * 0.5;
715 let recovered_pivot_y = (tx + ty) * 0.5;
716
717 let red_array = Color::RED.to_array();
725 let cube_shape = frame
726 .shapes
727 .iter()
728 .find(|s| s.color == red_array)
729 .expect("cube must paint a shape");
730 let visual_center_x = cube_shape.screen[0] + cube_shape.screen[2] * 0.5;
731 let visual_center_y = cube_shape.screen[1] + cube_shape.screen[3] * 0.5;
732
733 let pivot_err_x = (recovered_pivot_x - visual_center_x).abs();
734 let pivot_err_y = (recovered_pivot_y - visual_center_y).abs();
735 assert!(
736 pivot_err_x < 0.5 && pivot_err_y < 0.5,
737 "rotation pivot ({}, {}) must match visual center ({}, {}); err = ({}, {})",
738 recovered_pivot_x,
739 recovered_pivot_y,
740 visual_center_x,
741 visual_center_y,
742 pivot_err_x,
743 pivot_err_y,
744 );
745 }
746
747 #[test]
748 fn user_provided_animated_signal_is_registered_with_scheduler() {
749 use std::time::Duration;
759 let angle = Signal::new_animated(0.0_f32);
760 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
761 tree.add(Rotate::new(angle.clone()).child(TextWidget::new(lit!("x"))));
762 tree.layout(SizeProposal {
763 width: Some(200.0),
764 height: None,
765 });
766
767 angle.animate_to(
768 std::f32::consts::FRAC_PI_2,
769 Duration::from_millis(100),
770 teksilo_tokens::Easing::Linear,
771 );
772 tree.layout(SizeProposal {
774 width: Some(200.0),
775 height: None,
776 });
777 assert!(
778 tree.has_active_animations(),
779 "user's animate_to on a Signal::new_animated must reach the scheduler"
780 );
781 }
782
783 #[test]
784 fn angle_signal_drives_emitted_matrix() {
785 let angle = Signal::new(0.0_f32);
788 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
789 tree.add(Rotate::new(angle.clone()).child(TextWidget::new(lit!("x"))));
790 tree.layout(SizeProposal {
791 width: Some(200.0),
792 height: None,
793 });
794 let frame0 = tree.render();
796 assert_eq!(
797 frame0
798 .draw_order
799 .iter()
800 .filter(|c| matches!(c, teksilo_canvas::DrawCommand::PushTransform(_)))
801 .count(),
802 0
803 );
804
805 angle.set(std::f32::consts::FRAC_PI_2);
806 let frame1 = tree.render();
807 let pushes: Vec<&Transform2D> = frame1
808 .draw_order
809 .iter()
810 .filter_map(|c| match c {
811 teksilo_canvas::DrawCommand::PushTransform(t) => Some(t),
812 _ => None,
813 })
814 .collect();
815 assert_eq!(pushes.len(), 1, "rotated subtree should now emit one push");
816 }
817}