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 teksilo_core::IntoTeksiChild) -> Self {
102 self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
103 self
104 }
105 pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
112 match widget {
113 Some(w) => self.child(w),
114 None => self,
115 }
116 }
117}
118
119impl std::fmt::Debug for Rotate {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 f.debug_struct("Rotate")
122 .field("origin", &self.origin)
123 .finish()
124 }
125}
126
127impl Widget for Rotate {
128 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
129 if let Some(pending) = self.pending_child.take() {
130 self.child_id = Some(match pending {
131 PendingChild::Id(id) => id,
132 PendingChild::Deferred(w) => ctx.add_boxed(w),
133 });
134 }
135 let Some(child_id) = self.child_id else {
136 return vec![];
137 };
138
139 let transform_signal = ctx.signal(Transform2D::IDENTITY);
140 let id = ctx.self_id();
141 ctx.set_transform(id, transform_signal.clone());
142
143 if let Prop::Bound(angle_signal) = &self.angle {
147 ctx.register_animated_signal(angle_signal);
156
157 let last_bounds = self.last_bounds.clone();
158 let last_is_rtl = self.last_is_rtl.clone();
159 let origin = self.origin;
160 let transform_for_observer = transform_signal.clone();
161 ctx.effect(angle_signal, move |&theta| {
162 let bounds = last_bounds.get();
163 let pivot = origin.pivot_world(bounds, last_is_rtl.get());
164 transform_for_observer.set(pivoted_rotation(pivot, theta));
165 });
166 }
167
168 self.transform_signal = Some(transform_signal);
169
170 vec![child_id]
171 }
172
173 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
174 let Some(child_id) = self.child_id else {
175 return proposal.resolve(0.0, 0.0).into();
176 };
177 let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
178 self.natural_size.set(natural);
179 natural.into()
180 }
181
182 fn place_children(
183 &self,
184 bounds: Rect,
185 _proposal: SizeProposal,
186 children: &mut [WidgetPlacement],
187 ctx: &LayoutContext,
188 ) {
189 self.last_bounds.set(bounds);
193 self.last_is_rtl.set(ctx.is_rtl());
194 if let Some(t_sig) = &self.transform_signal {
195 let theta = self.angle.get();
196 let pivot = self.origin.pivot_world(bounds, ctx.is_rtl());
197 t_sig.set(pivoted_rotation(pivot, theta));
198 }
199
200 let natural = self.natural_size.get();
201 for child in children.iter_mut() {
202 child.origin = Point::new(bounds.x, bounds.y);
203 child.size = natural;
204 }
205 }
206
207 fn clips_children(&self) -> bool {
208 false
216 }
217
218 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
219 }
221
222 fn children(&self) -> Vec<WidgetId> {
223 self.child_id.into_iter().collect()
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use crate::primitives::TextWidget;
231 use teksilo_core::widget_tree::WidgetTree;
232 use teksilo_i18n::lit;
233
234 #[test]
235 fn zero_angle_emits_identity_skip() {
236 let angle = Signal::new(0.0_f32);
237 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
238 tree.add(Rotate::new(angle).child(TextWidget::new(lit!("x"))));
239 tree.layout(SizeProposal {
240 width: Some(200.0),
241 height: None,
242 });
243 let frame = tree.render();
244 let push_count = frame
245 .draw_order
246 .iter()
247 .filter(|c| matches!(c, teksilo_canvas::DrawCommand::PushTransform(_)))
248 .count();
249 assert_eq!(push_count, 0, "zero rotation must skip the transform scope");
250 }
251
252 #[test]
253 fn nonzero_angle_emits_rotation_matrix() {
254 let angle = Signal::new(std::f32::consts::FRAC_PI_2); let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
256 tree.add(Rotate::new(angle).child(TextWidget::new(lit!("x"))));
257 tree.layout(SizeProposal {
258 width: Some(200.0),
259 height: None,
260 });
261 let frame = tree.render();
262 let pushes: Vec<&Transform2D> = frame
263 .draw_order
264 .iter()
265 .filter_map(|c| match c {
266 teksilo_canvas::DrawCommand::PushTransform(t) => Some(t),
267 _ => None,
268 })
269 .collect();
270 assert_eq!(pushes.len(), 1);
271 assert!(pushes[0].m[0].abs() < 1e-3, "a (cos) should be 0");
273 assert!((pushes[0].m[1] - 1.0).abs() < 1e-3, "b (sin) should be 1");
274 assert!(
275 (pushes[0].m[2] - (-1.0)).abs() < 1e-3,
276 "c (-sin) should be -1"
277 );
278 assert!(pushes[0].m[3].abs() < 1e-3, "d (cos) should be 0");
279 }
280
281 #[test]
282 fn layout_size_unchanged_by_rotation() {
283 let angle = Signal::new(0.0_f32);
287 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
288 let id = tree.add(Rotate::new(angle.clone()).child(TextWidget::new(lit!("hello"))));
289 tree.layout(SizeProposal {
290 width: Some(300.0),
291 height: None,
292 });
293 let initial = tree.bounds(id).size();
294
295 angle.set(std::f32::consts::FRAC_PI_4);
297 tree.layout(SizeProposal {
298 width: Some(300.0),
299 height: None,
300 });
301 let after = tree.bounds(id).size();
302 assert_eq!(initial, after, "rotation must not change layout size");
303 }
304
305 #[test]
306 fn animate_to_actually_advances_angle_value() {
307 use std::time::Duration;
312 let angle = Signal::new_animated(0.0_f32);
313 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
314 tree.add(Rotate::new(angle.clone()).child(TextWidget::new(lit!("x"))));
315 tree.layout(SizeProposal {
316 width: Some(200.0),
317 height: None,
318 });
319 let frame0 = tree.render();
321 assert_eq!(
322 frame0
323 .draw_order
324 .iter()
325 .filter(|c| matches!(c, teksilo_canvas::DrawCommand::PushTransform(_)))
326 .count(),
327 0,
328 "initial angle 0 → no transform scope"
329 );
330
331 angle.animate_to(
332 std::f32::consts::FRAC_PI_2,
333 Duration::from_millis(100),
334 teksilo_tokens::Easing::Linear,
335 );
336 tree.layout(SizeProposal {
338 width: Some(200.0),
339 height: None,
340 });
341 tree.tick_animations(Duration::from_millis(80));
343 tree.layout(SizeProposal {
344 width: Some(200.0),
345 height: None,
346 });
347 assert!(
350 angle.get() > 0.1,
351 "angle value must have advanced from 0 (got {})",
352 angle.get()
353 );
354 let frame1 = tree.render();
355 let pushes: Vec<&Transform2D> = frame1
356 .draw_order
357 .iter()
358 .filter_map(|c| match c {
359 teksilo_canvas::DrawCommand::PushTransform(t) => Some(t),
360 _ => None,
361 })
362 .collect();
363 assert_eq!(
364 pushes.len(),
365 1,
366 "advanced angle must emit a transform scope"
367 );
368 }
369
370 #[test]
371 fn rotation_pivot_in_zstack_with_center_dot() {
372 use crate::primitives::{Center, FixedSize, RectWidget, ZStack};
377 use std::time::Duration;
378 use teksilo_tokens::Color;
379 let angle = Signal::new_animated(0.0_f32);
380 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
381 tree.add(
382 FixedSize::new().width(80.0).height(80.0).child(
383 ZStack::new()
384 .child(
385 Rotate::new(angle.clone())
386 .child(RectWidget::new().background(Color::from_rgb(0.30, 0.55, 0.85))),
387 )
388 .child(
389 Center::new().child(
390 FixedSize::new()
391 .width(6.0)
392 .height(6.0)
393 .child(RectWidget::new().background(Color::BLACK)),
394 ),
395 ),
396 ),
397 );
398 tree.layout(SizeProposal::exact(120.0, 120.0));
399
400 angle.animate_to(
402 std::f32::consts::FRAC_PI_2,
403 Duration::from_millis(100),
404 teksilo_tokens::Easing::Linear,
405 );
406 tree.layout(SizeProposal::exact(120.0, 120.0));
407 tree.tick_animations(Duration::from_millis(50));
408 tree.layout(SizeProposal::exact(120.0, 120.0));
409
410 let frame = tree.render();
411
412 let push = frame
416 .draw_order
417 .iter()
418 .find_map(|c| match c {
419 teksilo_canvas::DrawCommand::PushTransform(t) => Some(*t),
420 _ => None,
421 })
422 .expect("rotation must emit a transform scope mid-tween");
423 let c = push.m[0];
424 let s = push.m[1];
425 let tx = push.m[4];
426 let ty = push.m[5];
427 let det = (1.0 - c) * (1.0 - c) + s * s;
428 let recovered_px = ((1.0 - c) * tx - s * ty) / det;
429 let recovered_py = (s * tx + (1.0 - c) * ty) / det;
430
431 let black_array = Color::BLACK.to_array();
433 let dot = frame
434 .shapes
435 .iter()
436 .find(|sh| sh.color == black_array)
437 .expect("dot must paint");
438 let dot_center_x = dot.screen[0] + dot.screen[2] * 0.5;
439 let dot_center_y = dot.screen[1] + dot.screen[3] * 0.5;
440
441 let blue_array = Color::from_rgb(0.30, 0.55, 0.85).to_array();
443 let cube = frame
444 .shapes
445 .iter()
446 .find(|sh| sh.color == blue_array)
447 .expect("cube must paint");
448 let cube_center_x = cube.screen[0] + cube.screen[2] * 0.5;
449 let cube_center_y = cube.screen[1] + cube.screen[3] * 0.5;
450
451 let dot_pivot_err =
453 (recovered_px - dot_center_x).abs() + (recovered_py - dot_center_y).abs();
454 let cube_pivot_err =
455 (recovered_px - cube_center_x).abs() + (recovered_py - cube_center_y).abs();
456 assert!(
457 dot_pivot_err < 1.0,
458 "pivot ({}, {}) must match DOT center ({}, {}); err = {}",
459 recovered_px,
460 recovered_py,
461 dot_center_x,
462 dot_center_y,
463 dot_pivot_err,
464 );
465 assert!(
466 cube_pivot_err < 1.0,
467 "pivot ({}, {}) must match CUBE center ({}, {}); err = {}",
468 recovered_px,
469 recovered_py,
470 cube_center_x,
471 cube_center_y,
472 cube_pivot_err,
473 );
474 }
475
476 #[test]
477 fn rotation_pivot_inside_scroll_area_matches_visual_center() {
478 use crate::primitives::{FixedSize, HStack, Padding, RectWidget, VStack};
483 use crate::scroll_area::ScrollArea;
484 use std::time::Duration;
485 use teksilo_tokens::Color;
486 let angle = Signal::new_animated(0.0_f32);
487 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
488 tree.add(
489 ScrollArea::new().child(
490 Padding::uniform(24.0).child(
491 VStack::new()
492 .spacing(20.0)
493 .child(TextWidget::new(lit!("filler 1")))
494 .child(TextWidget::new(lit!("filler 2")))
495 .child(TextWidget::new(lit!("filler 3")))
496 .child(
497 HStack::new()
498 .spacing(12.0)
499 .child(
500 Rotate::new(angle.clone()).child(
501 FixedSize::new()
502 .width(28.0)
503 .height(28.0)
504 .child(RectWidget::new().background(Color::RED)),
505 ),
506 )
507 .child(TextWidget::new(lit!("Rotate 90°"))),
508 ),
509 ),
510 ),
511 );
512 tree.layout(SizeProposal::exact(560.0, 720.0));
513
514 angle.animate_to(
515 std::f32::consts::FRAC_PI_2,
516 Duration::from_millis(100),
517 teksilo_tokens::Easing::Linear,
518 );
519 tree.layout(SizeProposal::exact(560.0, 720.0));
520 tree.tick_animations(Duration::from_millis(50));
521 tree.layout(SizeProposal::exact(560.0, 720.0));
522
523 let frame = tree.render();
524 let push = frame
525 .draw_order
526 .iter()
527 .find_map(|c| match c {
528 teksilo_canvas::DrawCommand::PushTransform(t) => Some(*t),
529 _ => None,
530 })
531 .expect("rotation must emit a transform scope mid-tween");
532 let c = push.m[0];
533 let s = push.m[1];
534 let tx = push.m[4];
535 let ty = push.m[5];
536 let det = (1.0 - c) * (1.0 - c) + s * s;
537 let recovered_px = ((1.0 - c) * tx - s * ty) / det;
538 let recovered_py = (s * tx + (1.0 - c) * ty) / det;
539
540 let cube_shape = frame
541 .shapes
542 .iter()
543 .find(|s| s.color == Color::RED.to_array())
544 .expect("cube must paint a shape");
545 let visual_center_x = cube_shape.screen[0] + cube_shape.screen[2] * 0.5;
546 let visual_center_y = cube_shape.screen[1] + cube_shape.screen[3] * 0.5;
547 let err_x = (recovered_px - visual_center_x).abs();
548 let err_y = (recovered_py - visual_center_y).abs();
549 assert!(
550 err_x < 1.0 && err_y < 1.0,
551 "ScrollArea-nested pivot ({}, {}) must match visual center ({}, {}); err = ({}, {})",
552 recovered_px,
553 recovered_py,
554 visual_center_x,
555 visual_center_y,
556 err_x,
557 err_y,
558 );
559 }
560
561 #[test]
562 fn rotation_pivot_in_kit_like_structure_after_animate_to() {
563 use crate::primitives::{FixedSize, HStack, Padding, RectWidget, VStack};
569 use std::time::Duration;
570 use teksilo_tokens::Color;
571 let angle = Signal::new_animated(0.0_f32);
572 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
573 tree.add(
576 Padding::uniform(24.0).child(
577 VStack::new()
578 .spacing(20.0)
579 .child(TextWidget::new(lit!("filler")))
580 .child(TextWidget::new(lit!("filler")))
581 .child(TextWidget::new(lit!("filler")))
582 .child(
583 HStack::new()
584 .spacing(12.0)
585 .child(
586 Rotate::new(angle.clone()).child(
587 FixedSize::new()
588 .width(28.0)
589 .height(28.0)
590 .child(RectWidget::new().background(Color::RED)),
591 ),
592 )
593 .child(TextWidget::new(lit!("Rotate 90°"))),
594 ),
595 ),
596 );
597 tree.layout(SizeProposal {
598 width: Some(560.0),
599 height: None,
600 });
601
602 angle.animate_to(
604 std::f32::consts::FRAC_PI_2,
605 Duration::from_millis(100),
606 teksilo_tokens::Easing::Linear,
607 );
608 tree.layout(SizeProposal {
609 width: Some(560.0),
610 height: None,
611 });
612 tree.tick_animations(Duration::from_millis(50));
613 tree.layout(SizeProposal {
614 width: Some(560.0),
615 height: None,
616 });
617
618 let frame = tree.render();
619 let push = frame
620 .draw_order
621 .iter()
622 .find_map(|c| match c {
623 teksilo_canvas::DrawCommand::PushTransform(t) => Some(*t),
624 _ => None,
625 })
626 .expect("rotation must emit a transform scope mid-tween");
627 let c = push.m[0];
634 let s = push.m[1];
635 let tx = push.m[4];
636 let ty = push.m[5];
637 let det = (1.0 - c) * (1.0 - c) + s * s;
638 assert!(det > 1e-3, "non-trivial rotation expected");
639 let recovered_px = ((1.0 - c) * tx - s * ty) / det;
640 let recovered_py = (s * tx + (1.0 - c) * ty) / det;
641
642 let cube_shape = frame
644 .shapes
645 .iter()
646 .find(|s| s.color == Color::RED.to_array())
647 .expect("cube must paint a shape");
648 let visual_center_x = cube_shape.screen[0] + cube_shape.screen[2] * 0.5;
649 let visual_center_y = cube_shape.screen[1] + cube_shape.screen[3] * 0.5;
650 let err_x = (recovered_px - visual_center_x).abs();
651 let err_y = (recovered_py - visual_center_y).abs();
652 assert!(
653 err_x < 1.0 && err_y < 1.0,
654 "pivot ({}, {}) must match cube's visual center ({}, {}); err = ({}, {})",
655 recovered_px,
656 recovered_py,
657 visual_center_x,
658 visual_center_y,
659 err_x,
660 err_y,
661 );
662 }
663
664 #[test]
665 fn rotation_pivot_lands_at_visual_center_when_inside_hstack() {
666 use crate::primitives::{FixedSize, HStack, RectWidget};
675 use teksilo_tokens::Color;
676 let angle = Signal::new(std::f32::consts::FRAC_PI_2); let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
678 tree.add(
681 HStack::new()
682 .spacing(0.0)
683 .child(
684 Rotate::new(angle).child(
685 FixedSize::new()
686 .width(28.0)
687 .height(28.0)
688 .child(RectWidget::new().background(Color::RED)),
689 ),
690 )
691 .child(
692 FixedSize::new()
693 .width(40.0)
694 .height(80.0)
695 .child(RectWidget::new().background(Color::BLUE)),
696 ),
697 );
698 tree.layout(SizeProposal {
699 width: None,
700 height: None,
701 });
702 let frame = tree.render();
703 let pushes: Vec<&Transform2D> = frame
704 .draw_order
705 .iter()
706 .filter_map(|c| match c {
707 teksilo_canvas::DrawCommand::PushTransform(t) => Some(t),
708 _ => None,
709 })
710 .collect();
711 assert_eq!(pushes.len(), 1, "Rotate must emit one push");
712
713 let tx = pushes[0].m[4];
719 let ty = pushes[0].m[5];
720 let recovered_pivot_x = (tx - ty) * 0.5;
721 let recovered_pivot_y = (tx + ty) * 0.5;
722
723 let red_array = Color::RED.to_array();
731 let cube_shape = frame
732 .shapes
733 .iter()
734 .find(|s| s.color == red_array)
735 .expect("cube must paint a shape");
736 let visual_center_x = cube_shape.screen[0] + cube_shape.screen[2] * 0.5;
737 let visual_center_y = cube_shape.screen[1] + cube_shape.screen[3] * 0.5;
738
739 let pivot_err_x = (recovered_pivot_x - visual_center_x).abs();
740 let pivot_err_y = (recovered_pivot_y - visual_center_y).abs();
741 assert!(
742 pivot_err_x < 0.5 && pivot_err_y < 0.5,
743 "rotation pivot ({}, {}) must match visual center ({}, {}); err = ({}, {})",
744 recovered_pivot_x,
745 recovered_pivot_y,
746 visual_center_x,
747 visual_center_y,
748 pivot_err_x,
749 pivot_err_y,
750 );
751 }
752
753 #[test]
754 fn user_provided_animated_signal_is_registered_with_scheduler() {
755 use std::time::Duration;
765 let angle = Signal::new_animated(0.0_f32);
766 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
767 tree.add(Rotate::new(angle.clone()).child(TextWidget::new(lit!("x"))));
768 tree.layout(SizeProposal {
769 width: Some(200.0),
770 height: None,
771 });
772
773 angle.animate_to(
774 std::f32::consts::FRAC_PI_2,
775 Duration::from_millis(100),
776 teksilo_tokens::Easing::Linear,
777 );
778 tree.layout(SizeProposal {
780 width: Some(200.0),
781 height: None,
782 });
783 assert!(
784 tree.has_active_animations(),
785 "user's animate_to on a Signal::new_animated must reach the scheduler"
786 );
787 }
788
789 #[test]
790 fn angle_signal_drives_emitted_matrix() {
791 let angle = Signal::new(0.0_f32);
794 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
795 tree.add(Rotate::new(angle.clone()).child(TextWidget::new(lit!("x"))));
796 tree.layout(SizeProposal {
797 width: Some(200.0),
798 height: None,
799 });
800 let frame0 = tree.render();
802 assert_eq!(
803 frame0
804 .draw_order
805 .iter()
806 .filter(|c| matches!(c, teksilo_canvas::DrawCommand::PushTransform(_)))
807 .count(),
808 0
809 );
810
811 angle.set(std::f32::consts::FRAC_PI_2);
812 let frame1 = tree.render();
813 let pushes: Vec<&Transform2D> = frame1
814 .draw_order
815 .iter()
816 .filter_map(|c| match c {
817 teksilo_canvas::DrawCommand::PushTransform(t) => Some(t),
818 _ => None,
819 })
820 .collect();
821 assert_eq!(pushes.len(), 1, "rotated subtree should now emit one push");
822 }
823}