Skip to main content

frus_widgets/
rotatedbox.rs

1//! [`RotatedBox`]: rotates its child by a whole **quarter turn** — and, unlike
2//! [`crate::Transform`], the rotation **affects layout** (the box swaps width
3//! and height for an odd number of quarters).
4
5use frus_core::{Rect, Scene};
6use frus_layout::Style;
7
8use crate::interaction::Status;
9use crate::theme::Theme;
10use crate::widget::Widget;
11
12/// Rotates its child by `quarter_turns` **quarter turns** (90° each, clockwise),
13/// **changing the layout**: for an odd number of quarters, the box presented to
14/// the parent has the child's **width and height swapped** (a vertical label in
15/// a sidebar, a rotated chart axis label…).
16///
17/// The child is measured at its **natural** size, centred, then rotated about the
18/// centre of the box — hit-testing counter-rotates the point (as `Transform` does).
19/// A negative `quarter_turns` turns the other way; only the remainder modulo 4 counts.
20pub struct RotatedBox<Msg = crate::callback::Callback> {
21    quarter_turns: i32,
22    children: Vec<Box<dyn Widget<Msg>>>,
23}
24
25impl<Msg> RotatedBox<Msg> {
26    /// Rotates the child by `quarter_turns` clockwise quarter turns.
27    pub fn new(quarter_turns: i32) -> Self {
28        Self {
29            quarter_turns,
30            children: Vec::new(),
31        }
32    }
33
34    /// Sets the rotated child.
35    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
36        self.children.clear();
37        self.children.push(Box::new(child));
38        self
39    }
40}
41
42impl<Msg: Clone> Widget<Msg> for RotatedBox<Msg> {
43    fn style(&self) -> Style {
44        // The real box (dimensions swapped for an odd quarter) is computed at layout
45        // time from the child's natural size (see `build_layout`).
46        Style::default()
47    }
48
49    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
50        &self.children
51    }
52
53    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
54        // A pure rotation widget: no decoration of its own.
55    }
56
57    fn on_click(&self) -> Option<Msg> {
58        None
59    }
60
61    fn rotated_quarter_turns(&self) -> Option<i32> {
62        Some(self.quarter_turns)
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use crate::{Container, Flex};
70    use frus_core::{Color, Primitive, Size};
71
72    /// A quarter turn **swaps** the box's width and height: an 80×20 child occupies
73    /// a 20×80 box in the column (the next sibling starts at y=80).
74    #[test]
75    fn odd_quarter_turn_swaps_the_layout_box() {
76        let green = Color::rgb(0.0, 1.0, 0.0);
77        let root = Flex::<()>::column()
78            .child(
79                RotatedBox::new(1).child(
80                    Container::new()
81                        .width(80.0)
82                        .height(20.0)
83                        .color(Color::rgb(0.3, 0.3, 0.3)),
84                ),
85            )
86            .child(Container::new().width(40.0).height(30.0).color(green));
87        let rt = crate::runtime::Runtime::default();
88        let theme = crate::Theme::dark();
89        let ui = crate::ui::build_ui(&root, Size::new(200.0, 400.0), &rt, &theme);
90        let sibling_y = ui
91            .scene()
92            .primitives()
93            .iter()
94            .find_map(|p| match p {
95                Primitive::Rect { rect, color, .. } if color.g > 0.5 && color.r < 0.5 => {
96                    Some(rect.y)
97                }
98                _ => None,
99            })
100            .expect("the green sibling");
101        assert!(
102            (sibling_y - 80.0).abs() < 0.5,
103            "rotated box = 20×80 → the sibling follows at y=80: {sibling_y}"
104        );
105    }
106
107    /// A half turn (2 quarters) **does not change** the box's dimensions.
108    #[test]
109    fn even_quarter_turn_keeps_the_box() {
110        let green = Color::rgb(0.0, 1.0, 0.0);
111        let root = Flex::<()>::column()
112            .child(
113                RotatedBox::new(2).child(
114                    Container::new()
115                        .width(80.0)
116                        .height(20.0)
117                        .color(Color::rgb(0.3, 0.3, 0.3)),
118                ),
119            )
120            .child(Container::new().width(40.0).height(30.0).color(green));
121        let rt = crate::runtime::Runtime::default();
122        let theme = crate::Theme::dark();
123        let ui = crate::ui::build_ui(&root, Size::new(200.0, 400.0), &rt, &theme);
124        let sibling_y = ui
125            .scene()
126            .primitives()
127            .iter()
128            .find_map(|p| match p {
129                Primitive::Rect { rect, color, .. } if color.g > 0.5 && color.r < 0.5 => {
130                    Some(rect.y)
131                }
132                _ => None,
133            })
134            .expect("the green sibling");
135        assert!(
136            (sibling_y - 20.0).abs() < 0.5,
137            "box unchanged 80×20 → sibling at y=20: {sibling_y}"
138        );
139    }
140
141    /// The rotation emits a **rotated layer** (an off-diagonal linear part).
142    #[test]
143    fn emits_a_rotated_layer() {
144        let root = RotatedBox::<()>::new(1).child(
145            Container::new()
146                .width(80.0)
147                .height(20.0)
148                .color(Color::rgb(0.3, 0.3, 0.3)),
149        );
150        let rt = crate::runtime::Runtime::default();
151        let theme = crate::Theme::dark();
152        let ui = crate::ui::build_ui(&root, Size::new(200.0, 400.0), &rt, &theme);
153        let m = ui
154            .scene()
155            .primitives()
156            .iter()
157            .find_map(|p| match p {
158                Primitive::Layer {
159                    transform: Some(t), ..
160                } => Some(t.affine),
161                _ => None,
162            })
163            .expect("a rotated layer");
164        // +90°: linear part ≈ [0, 1, -1, 0].
165        assert!(
166            m.m[0].abs() < 1e-3 && (m.m[1] - 1.0).abs() < 1e-3,
167            "rotation +90° : {:?}",
168            m.m
169        );
170    }
171}