Skip to main content

teksilo_widgets/animations/
rotate.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `Rotate` — wraps a child and applies a 2D rotation to its entire
5//! subtree, driven by an external `Prop<f32>` of radians. Layout-
6//! stable: the wrapper reports the child's natural size at all
7//! angles; only the visual content rotates within the slot.
8//!
9//! ```ignore
10//! let angle = ctx.animated_signal(0.0);
11//! ctx.add(Rotate::new(angle.clone()).child(chevron));
12//! // Animate to 90° on expand:
13//! angle.animate_to(std::f32::consts::FRAC_PI_2, Duration::from_millis(150), Easing::EaseOut);
14//! ```
15//!
16//! No internal animation — the caller owns the angle signal and pairs
17//! it with `Signal::animate_to` (or `ctx.animate()`) for animated
18//! rotations. This keeps the widget composable: bind it to interaction
19//! state for hover-on rotation, to an animated signal for spinning
20//! loaders, to a constant for static decorative rotation.
21//!
22//! Use cases: animated chevrons (the disclosure-state pattern, today
23//! faked by visibility-toggling two static chevron icons), spinning
24//! loaders not covered by [`Spinner`](crate::Spinner), "shake your
25//! head no" rotation feedback, dial controls.
26//!
27//! ## Reduced motion
28//!
29//! Rotate doesn't introduce motion — it just applies whatever the
30//! caller's angle signal currently holds. Reduced-motion handling
31//! belongs at the *caller's* `animate_to` site (use `to_or_snap` or
32//! gate the animation behind `prefers_reduced_motion`).
33
34use 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
46/// Rotation matrix `T(pivot) * R(theta) * T(-pivot)`.
47fn 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
61/// Wraps a child widget and rotates its entire subtree by an
62/// externally-driven angle in radians.
63pub struct Rotate {
64    angle: Prop<f32>,
65    origin: ScaleOrigin,
66    pending_child: Option<PendingChild>,
67    child_id: Option<WidgetId>,
68    /// The transform matrix the render walker reads. Recomputed from
69    /// (angle, bounds) in `place_children`, plus by an effect on
70    /// `angle` when bounds are known but angle changes between
71    /// layouts.
72    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    /// Create a rotate wrapper bound to `angle` (radians); accepts a
80    /// static `f32` or a reactive `Signal<f32>`. Default pivot: `Center`.
81    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    /// Pivot point for the rotation. Default `Center`.
95    pub fn origin(mut self, origin: ScaleOrigin) -> Self {
96        self.origin = origin;
97        self
98    }
99
100    /// Inline child widget (deferred insertion).
101    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    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
106    ///
107    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
108    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
109    /// adds no arena node, so nothing is laid out, painted, or published to the
110    /// accessibility tree, and a stack applies no spacing around it.
111    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        // Recompute on angle changes — reads bounds captured by
144        // place_children, writes to transform_signal which marks self
145        // for repaint via the RepaintOnly binding in set_transform.
146        if let Prop::Bound(angle_signal) = &self.angle {
147            // Register the user-provided signal with the tree's
148            // animation scheduler. Without this, an animation-capable
149            // `Signal::new_animated(0.0)` (typical pattern when the
150            // signal is created outside any build context) would have
151            // its `animate_to` requests silently dropped — the
152            // scheduler only ticks registered signals.
153            // `register_animated_signal` is a no-op for signals that
154            // aren't animation-capable, so this is always safe.
155            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        // Capture bounds + RTL for the angle observer; publish the
190        // current matrix immediately so the first frame paints with
191        // the right rotation even before any signal change.
192        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        // Intentionally false: rotated content visibly extends past
209        // the slot bounds at every non-90°-multiple angle (a 28×28
210        // square at 45° has corners ~6 px past the original bounds).
211        // Clipping cuts those corners off — the rotation looks like a
212        // flickering hexagon mid-tween. Users who need bounded layout
213        // can wrap the Rotate in a clipping container (`MaxSize`,
214        // `ScrollArea`, …) sized to fit the rotated bounding box.
215        false
216    }
217
218    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
219        // Visual-modulation wrapper. Child owns its own a11y.
220    }
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); // 90°
255        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        // 90° rotation: cos=0, sin=1 → matrix linear part = [0, 1, -1, 0].
272        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        // Set the angle to a value via plain signal mutation — no
284        // animation infrastructure needed; the assertion here is
285        // about layout size, not animation timing.
286        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        // Spin 45° instantly. Layout-stable wrapper must not change size.
296        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        // End-to-end: user creates `Signal::new_animated`, builds a
308        // Rotate around it, calls `animate_to`. After ticking the
309        // scheduler, the signal's *value* must actually have changed
310        // (the matrix being pushed must reflect the new angle).
311        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        // Pre-condition: angle is 0, no push emitted (identity skip).
320        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        // Drain pending request → scheduler.
337        tree.layout(SizeProposal {
338            width: Some(200.0),
339            height: None,
340        });
341        // Tick most of the duration.
342        tree.tick_animations(Duration::from_millis(80));
343        tree.layout(SizeProposal {
344            width: Some(200.0),
345            height: None,
346        });
347        // Angle must have advanced past zero AND past the identity
348        // skip threshold; the wrapper must now emit a real push.
349        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        // Exact mirror of the kit's diagnostic structure:
373        // FixedSize(80) > ZStack > [Rotate(RectWidget), Center(FixedSize(6x6)(dot))].
374        // The cube fills the ZStack slot; the dot sits at slot center.
375        // After rotation, the cube must rotate around the dot.
376        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        // Start the rotation, drain pending into the scheduler, tick.
401        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        // Pivot recovered from the matrix should match the dot's
413        // painted center (since the cube is supposed to rotate around
414        // the dot).
415        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        // Find the dot — black 6x6 rect.
432        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        // Find the cube — the blue rect.
442        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        // All three centers (dot, cube, recovered pivot) must coincide.
452        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        // Closer to the kit's actual structure: the cube is deep
479        // inside a ScrollArea > Padding > VStack > ... > HStack chain.
480        // ScrollArea positions content children with a `bounds.origin -
481        // scroll_offset` shift; a wrong pivot would surface here.
482        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        // Repros the kit example: cube is deep inside a VStack →
564        // ... → HStack → Rotate(FixedSize(RectWidget)) chain. After
565        // animate_to ticks the angle past zero, the matrix being
566        // pushed must use a pivot near the cube's actual world
567        // position, not (0, 0) or stale bounds.
568        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        // Mirror animations-kit: lots of vertical content above the
574        // Rotate row, so the cube lives at a non-trivial y offset.
575        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        // Drive the angle past zero.
603        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        // Recover pivot from the matrix as before. Mid-tween (linear,
628        // ~50ms of 100) the angle is roughly π/4. cos≈sin≈0.707.
629        // tx = px*(1-c) + py*s; ty = py*(1-c) - px*s.
630        // Solve: px*(1-c) + py*s = tx; py*(1-c) - px*s = ty.
631        // [(1-c) s ; -s (1-c)] * [px;py] = [tx;ty]
632        // Determinant = (1-c)² + s² = 2(1-c) for unit rotation.
633        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        // Compare to the cube's actual paint position.
643        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        // Regression: Rotate's pivot is the wrapper's slot center,
667        // and the cube renders at its slot's origin (top-left). When
668        // an HStack assigns Rotate a slot whose VERTICAL extent is
669        // taller than the cube's natural height (typical: HStack
670        // height = max child height, taller siblings stretch the
671        // row), Rotate's slot would be taller than 28 — and pivot
672        // would drift below the visual center. Confirm the pivot
673        // matches the visual rect's actual center.
674        use crate::primitives::{FixedSize, HStack, RectWidget};
675        use teksilo_tokens::Color;
676        let angle = Signal::new(std::f32::consts::FRAC_PI_2); // 90°
677        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
678        // Build an HStack with a 28x28 cube AND a much taller sibling
679        // so the row's cross-axis stretches past 28.
680        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        // The matrix is `T(pivot) * R * T(-pivot)` — recover pivot
714        // from the translation column. For 90°: cos=0, sin=1 →
715        // tx = pivot.x*1 + pivot.y*1 = pivot.x + pivot.y
716        // ty = pivot.y*1 - pivot.x*1 = pivot.y - pivot.x
717        // → pivot.x = (tx - ty) / 2, pivot.y = (tx + ty) / 2
718        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        // The cube is the FIRST child of HStack. Its slot's top-left
724        // is at HStack's origin (0, alignment_offset). Its visual
725        // (the FixedSize → RectWidget chain at 28x28) sits at the
726        // *cube's slot top-left*. Visual center should be at
727        // (slot.x + 14, slot.y + 14). The pivot MUST match.
728        // Find the FillWidget's bounds via the rendered shapes' first
729        // RED entry — that's the cube's actual paint position.
730        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        // Regression: Rotate accepts a user-provided Signal<f32> via
756        // its Prop<f32> argument. If the user creates the signal with
757        // `Signal::new_animated(0.0)` (the natural pattern when the
758        // signal is constructed outside any build context — e.g. in
759        // the example's `build_kit` function), `animate_to` queues a
760        // pending request that the scheduler can only pick up if the
761        // signal is registered with the tree. Rotate's build() must
762        // auto-register so user `animate_to` calls actually drive the
763        // angle.
764        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        // Drain the pending request onto the scheduler.
779        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        // Bumping the angle signal must update the next frame's
792        // PushTransform value — no rebuild required.
793        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        // Initial: zero angle, no push.
801        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}