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 Widget + 'static) -> Self {
102        self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
103        self
104    }
105
106    /// Pre-registered child by `WidgetId`.
107    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        // Recompute on angle changes — reads bounds captured by
138        // place_children, writes to transform_signal which marks self
139        // for repaint via the RepaintOnly binding in set_transform.
140        if let Prop::Bound(angle_signal) = &self.angle {
141            // Register the user-provided signal with the tree's
142            // animation scheduler. Without this, an animation-capable
143            // `Signal::new_animated(0.0)` (typical pattern when the
144            // signal is created outside any build context) would have
145            // its `animate_to` requests silently dropped — the
146            // scheduler only ticks registered signals.
147            // `register_animated_signal` is a no-op for signals that
148            // aren't animation-capable, so this is always safe.
149            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        // Capture bounds + RTL for the angle observer; publish the
184        // current matrix immediately so the first frame paints with
185        // the right rotation even before any signal change.
186        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        // Intentionally false: rotated content visibly extends past
203        // the slot bounds at every non-90°-multiple angle (a 28×28
204        // square at 45° has corners ~6 px past the original bounds).
205        // Clipping cuts those corners off — the rotation looks like a
206        // flickering hexagon mid-tween. Users who need bounded layout
207        // can wrap the Rotate in a clipping container (`MaxSize`,
208        // `ScrollArea`, …) sized to fit the rotated bounding box.
209        false
210    }
211
212    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
213        // Visual-modulation wrapper. Child owns its own a11y.
214    }
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); // 90°
249        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        // 90° rotation: cos=0, sin=1 → matrix linear part = [0, 1, -1, 0].
266        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        // Set the angle to a value via plain signal mutation — no
278        // animation infrastructure needed; the assertion here is
279        // about layout size, not animation timing.
280        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        // Spin 45° instantly. Layout-stable wrapper must not change size.
290        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        // End-to-end: user creates `Signal::new_animated`, builds a
302        // Rotate around it, calls `animate_to`. After ticking the
303        // scheduler, the signal's *value* must actually have changed
304        // (the matrix being pushed must reflect the new angle).
305        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        // Pre-condition: angle is 0, no push emitted (identity skip).
314        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        // Drain pending request → scheduler.
331        tree.layout(SizeProposal {
332            width: Some(200.0),
333            height: None,
334        });
335        // Tick most of the duration.
336        tree.tick_animations(Duration::from_millis(80));
337        tree.layout(SizeProposal {
338            width: Some(200.0),
339            height: None,
340        });
341        // Angle must have advanced past zero AND past the identity
342        // skip threshold; the wrapper must now emit a real push.
343        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        // Exact mirror of the kit's diagnostic structure:
367        // FixedSize(80) > ZStack > [Rotate(RectWidget), Center(FixedSize(6x6)(dot))].
368        // The cube fills the ZStack slot; the dot sits at slot center.
369        // After rotation, the cube must rotate around the dot.
370        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        // Start the rotation, drain pending into the scheduler, tick.
395        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        // Pivot recovered from the matrix should match the dot's
407        // painted center (since the cube is supposed to rotate around
408        // the dot).
409        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        // Find the dot — black 6x6 rect.
426        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        // Find the cube — the blue rect.
436        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        // All three centers (dot, cube, recovered pivot) must coincide.
446        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        // Closer to the kit's actual structure: the cube is deep
473        // inside a ScrollArea > Padding > VStack > ... > HStack chain.
474        // ScrollArea positions content children with a `bounds.origin -
475        // scroll_offset` shift; a wrong pivot would surface here.
476        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        // Repros the kit example: cube is deep inside a VStack →
558        // ... → HStack → Rotate(FixedSize(RectWidget)) chain. After
559        // animate_to ticks the angle past zero, the matrix being
560        // pushed must use a pivot near the cube's actual world
561        // position, not (0, 0) or stale bounds.
562        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        // Mirror animations-kit: lots of vertical content above the
568        // Rotate row, so the cube lives at a non-trivial y offset.
569        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        // Drive the angle past zero.
597        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        // Recover pivot from the matrix as before. Mid-tween (linear,
622        // ~50ms of 100) the angle is roughly π/4. cos≈sin≈0.707.
623        // tx = px*(1-c) + py*s; ty = py*(1-c) - px*s.
624        // Solve: px*(1-c) + py*s = tx; py*(1-c) - px*s = ty.
625        // [(1-c) s ; -s (1-c)] * [px;py] = [tx;ty]
626        // Determinant = (1-c)² + s² = 2(1-c) for unit rotation.
627        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        // Compare to the cube's actual paint position.
637        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        // Regression: Rotate's pivot is the wrapper's slot center,
661        // and the cube renders at its slot's origin (top-left). When
662        // an HStack assigns Rotate a slot whose VERTICAL extent is
663        // taller than the cube's natural height (typical: HStack
664        // height = max child height, taller siblings stretch the
665        // row), Rotate's slot would be taller than 28 — and pivot
666        // would drift below the visual center. Confirm the pivot
667        // matches the visual rect's actual center.
668        use crate::primitives::{FixedSize, HStack, RectWidget};
669        use teksilo_tokens::Color;
670        let angle = Signal::new(std::f32::consts::FRAC_PI_2); // 90°
671        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
672        // Build an HStack with a 28x28 cube AND a much taller sibling
673        // so the row's cross-axis stretches past 28.
674        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        // The matrix is `T(pivot) * R * T(-pivot)` — recover pivot
708        // from the translation column. For 90°: cos=0, sin=1 →
709        // tx = pivot.x*1 + pivot.y*1 = pivot.x + pivot.y
710        // ty = pivot.y*1 - pivot.x*1 = pivot.y - pivot.x
711        // → pivot.x = (tx - ty) / 2, pivot.y = (tx + ty) / 2
712        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        // The cube is the FIRST child of HStack. Its slot's top-left
718        // is at HStack's origin (0, alignment_offset). Its visual
719        // (the FixedSize → RectWidget chain at 28x28) sits at the
720        // *cube's slot top-left*. Visual center should be at
721        // (slot.x + 14, slot.y + 14). The pivot MUST match.
722        // Find the FillWidget's bounds via the rendered shapes' first
723        // RED entry — that's the cube's actual paint position.
724        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        // Regression: Rotate accepts a user-provided Signal<f32> via
750        // its Prop<f32> argument. If the user creates the signal with
751        // `Signal::new_animated(0.0)` (the natural pattern when the
752        // signal is constructed outside any build context — e.g. in
753        // the example's `build_kit` function), `animate_to` queues a
754        // pending request that the scheduler can only pick up if the
755        // signal is registered with the tree. Rotate's build() must
756        // auto-register so user `animate_to` calls actually drive the
757        // angle.
758        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        // Drain the pending request onto the scheduler.
773        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        // Bumping the angle signal must update the next frame's
786        // PushTransform value — no rebuild required.
787        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        // Initial: zero angle, no push.
795        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}