Skip to main content

egui_sdl2/
rotation.rs

1//! Presenting the UI at a quarter turn to the window, for a panel that is not
2//! mounted the way it is read: egui lays out for the turned screen, and the
3//! backend puts that frame on the window the other way round.
4
5use egui::{ClippedPrimitive, Pos2, Rect, Vec2};
6
7/// How far the UI is turned clockwise on its way to the window.
8///
9/// A quarter turn trades the screen's width and height, so egui lays out for a
10/// portrait screen on a landscape panel (and the other way round). Pointer and
11/// touch positions travel back the same way, so a tap lands where it looks.
12#[derive(Copy, Clone, PartialEq, Eq, Debug, Default, Hash)]
13pub enum Rotation {
14    #[default]
15    None,
16    /// A quarter turn clockwise: the screen's top edge runs down the window's
17    /// right side.
18    Cw90,
19    /// Upside down, for a panel mounted that way.
20    Cw180,
21    /// A quarter turn anticlockwise: the screen's top edge runs up the window's
22    /// left side.
23    Cw270,
24}
25
26impl Rotation {
27    /// In turn order, for a setting that cycles them.
28    pub const ALL: [Rotation; 4] = [
29        Rotation::None,
30        Rotation::Cw90,
31        Rotation::Cw180,
32        Rotation::Cw270,
33    ];
34
35    /// Quarter turns clockwise, 0 to 3.
36    #[inline]
37    pub fn quarter_turns(self) -> u8 {
38        match self {
39            Rotation::None => 0,
40            Rotation::Cw90 => 1,
41            Rotation::Cw180 => 2,
42            Rotation::Cw270 => 3,
43        }
44    }
45
46    /// Wrapping, and negative turns count anticlockwise, so callers may add
47    /// turns without normalising first.
48    #[inline]
49    pub fn from_quarter_turns(turns: i32) -> Self {
50        Self::ALL[turns.rem_euclid(4) as usize]
51    }
52
53    /// Clockwise degrees, as `SDL_RenderCopyEx` takes them.
54    #[inline]
55    pub fn degrees(self) -> f64 {
56        self.quarter_turns() as f64 * 90.0
57    }
58
59    /// Whether width and height trade places.
60    #[inline]
61    pub fn swaps_axes(self) -> bool {
62        matches!(self, Rotation::Cw90 | Rotation::Cw270)
63    }
64
65    /// The screen egui lays out for, inside a window of `window`.
66    #[inline]
67    pub fn screen_size(self, window: Vec2) -> Vec2 {
68        if self.swaps_axes() {
69            Vec2::new(window.y, window.x)
70        } else {
71            window
72        }
73    }
74
75    /// Where a point of the turned screen lands in the window. `window` is the
76    /// window's size in the same unit as `p` — points for geometry egui laid
77    /// out, pixels for a frame being presented.
78    #[inline]
79    pub fn to_window(self, p: Pos2, window: Vec2) -> Pos2 {
80        match self {
81            Rotation::None => p,
82            Rotation::Cw90 => Pos2::new(window.x - p.y, p.x),
83            Rotation::Cw180 => Pos2::new(window.x - p.x, window.y - p.y),
84            Rotation::Cw270 => Pos2::new(p.y, window.y - p.x),
85        }
86    }
87
88    /// Where a point of the window falls in the turned screen — the way
89    /// pointer input travels.
90    #[inline]
91    pub fn from_window(self, p: Pos2, window: Vec2) -> Pos2 {
92        match self {
93            Rotation::None => p,
94            Rotation::Cw90 => Pos2::new(p.y, window.x - p.x),
95            Rotation::Cw180 => Pos2::new(window.x - p.x, window.y - p.y),
96            Rotation::Cw270 => Pos2::new(window.y - p.y, p.x),
97        }
98    }
99
100    /// A quarter turn maps an axis-aligned rect onto another one, so a clip
101    /// rect survives the trip.
102    #[inline]
103    pub fn rect_to_window(self, rect: Rect, window: Vec2) -> Rect {
104        if self == Rotation::None {
105            return rect;
106        }
107        Rect::from_two_pos(
108            self.to_window(rect.min, window),
109            self.to_window(rect.max, window),
110        )
111    }
112
113    /// Turn a tessellated frame into window space, for backends that put their
114    /// geometry on screen as it comes. `window` is the window's size in points.
115    ///
116    /// Paint callbacks are moved but not turned: what they draw is the app's
117    /// own, out of reach here.
118    pub fn turn_primitives(self, jobs: &mut [ClippedPrimitive], window: Vec2) {
119        if self == Rotation::None {
120            return;
121        }
122        for job in jobs.iter_mut() {
123            job.clip_rect = self.rect_to_window(job.clip_rect, window);
124            match &mut job.primitive {
125                egui::epaint::Primitive::Mesh(mesh) => {
126                    for vertex in &mut mesh.vertices {
127                        vertex.pos = self.to_window(vertex.pos, window);
128                    }
129                }
130                egui::epaint::Primitive::Callback(callback) => {
131                    log::warn!("a paint callback is not turned with the screen");
132                    callback.rect = self.rect_to_window(callback.rect, window);
133                }
134            }
135        }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    const WINDOW: Vec2 = Vec2::new(640.0, 480.0);
144
145    /// The corners of the turned screen, clockwise from its top left.
146    fn corners(rotation: Rotation) -> Vec<Pos2> {
147        let screen = rotation.screen_size(WINDOW);
148        [
149            Pos2::new(0.0, 0.0),
150            Pos2::new(screen.x, 0.0),
151            Pos2::new(screen.x, screen.y),
152            Pos2::new(0.0, screen.y),
153        ]
154        .iter()
155        .map(|p| rotation.to_window(*p, WINDOW))
156        .collect()
157    }
158
159    #[test]
160    fn a_quarter_turn_trades_width_for_height() {
161        assert_eq!(Rotation::None.screen_size(WINDOW), WINDOW);
162        assert_eq!(Rotation::Cw180.screen_size(WINDOW), WINDOW);
163        let turned = Vec2::new(WINDOW.y, WINDOW.x);
164        assert_eq!(Rotation::Cw90.screen_size(WINDOW), turned);
165        assert_eq!(Rotation::Cw270.screen_size(WINDOW), turned);
166    }
167
168    #[test]
169    fn the_screen_lands_on_the_window_and_nowhere_else() {
170        // Every turn covers the window exactly: no gap, no overhang, and the
171        // corners still go round in order.
172        for rotation in Rotation::ALL {
173            let corners = corners(rotation);
174            let rect = Rect::from_points(&corners);
175            assert_eq!(rect.min, Pos2::ZERO, "{rotation:?} leaves the window");
176            assert_eq!(rect.max, WINDOW.to_pos2(), "{rotation:?} leaves the window");
177        }
178    }
179
180    #[test]
181    fn the_screens_top_left_goes_where_the_turn_says() {
182        let top_left = |rotation: Rotation| corners(rotation)[0];
183        assert_eq!(top_left(Rotation::None), Pos2::new(0.0, 0.0));
184        assert_eq!(top_left(Rotation::Cw90), Pos2::new(WINDOW.x, 0.0));
185        assert_eq!(top_left(Rotation::Cw180), WINDOW.to_pos2());
186        assert_eq!(top_left(Rotation::Cw270), Pos2::new(0.0, WINDOW.y));
187    }
188
189    #[test]
190    fn a_pointer_comes_back_to_where_it_was_pressed() {
191        // What the backend presents and what input undoes must be one map, or a
192        // tap lands somewhere other than what it is under.
193        for rotation in Rotation::ALL {
194            let screen = rotation.screen_size(WINDOW);
195            for p in [
196                Pos2::ZERO,
197                Pos2::new(screen.x, screen.y),
198                Pos2::new(screen.x / 3.0, screen.y / 7.0),
199            ] {
200                let there_and_back = rotation.from_window(rotation.to_window(p, WINDOW), WINDOW);
201                assert!(
202                    there_and_back.distance(p) < 1e-3,
203                    "{rotation:?}: {p:?} came back as {there_and_back:?}"
204                );
205            }
206        }
207    }
208
209    #[test]
210    fn turns_wrap_in_both_directions() {
211        assert_eq!(Rotation::from_quarter_turns(4), Rotation::None);
212        assert_eq!(Rotation::from_quarter_turns(-1), Rotation::Cw270);
213        assert_eq!(Rotation::from_quarter_turns(5), Rotation::Cw90);
214        for rotation in Rotation::ALL {
215            assert_eq!(
216                Rotation::from_quarter_turns(rotation.quarter_turns() as i32),
217                rotation
218            );
219        }
220    }
221
222    #[test]
223    fn a_clip_rect_stays_a_rect_the_right_way_up() {
224        let rect = Rect::from_min_max(Pos2::new(10.0, 20.0), Pos2::new(110.0, 60.0));
225        for rotation in Rotation::ALL {
226            let turned = rotation.rect_to_window(rect, WINDOW);
227            assert!(turned.min.x <= turned.max.x && turned.min.y <= turned.max.y);
228            let (w, h) = (rect.width(), rect.height());
229            let (tw, th) = (turned.width(), turned.height());
230            if rotation.swaps_axes() {
231                assert!(
232                    (tw - h).abs() < 1e-3 && (th - w).abs() < 1e-3,
233                    "{rotation:?}"
234                );
235            } else {
236                assert!(
237                    (tw - w).abs() < 1e-3 && (th - h).abs() < 1e-3,
238                    "{rotation:?}"
239                );
240            }
241        }
242    }
243
244    #[test]
245    fn a_turned_mesh_holds_its_shape() {
246        let mut mesh = egui::Mesh::default();
247        let vertex = |x: f32, y: f32| egui::epaint::Vertex {
248            pos: Pos2::new(x, y),
249            uv: Pos2::ZERO,
250            color: egui::Color32::WHITE,
251        };
252        mesh.vertices = vec![vertex(0.0, 0.0), vertex(40.0, 0.0), vertex(40.0, 10.0)];
253        let mut jobs = vec![ClippedPrimitive {
254            clip_rect: Rect::from_min_max(Pos2::ZERO, Pos2::new(480.0, 640.0)),
255            primitive: egui::epaint::Primitive::Mesh(mesh),
256        }];
257        Rotation::Cw90.turn_primitives(&mut jobs, WINDOW);
258        let egui::epaint::Primitive::Mesh(mesh) = &jobs[0].primitive else {
259            unreachable!("the job was built as a mesh");
260        };
261        // The screen's top-left corner is the window's top-right one, and the
262        // run of text along its top edge now runs down that side.
263        assert_eq!(mesh.vertices[0].pos, Pos2::new(640.0, 0.0));
264        assert_eq!(mesh.vertices[1].pos, Pos2::new(640.0, 40.0));
265        assert_eq!(mesh.vertices[2].pos, Pos2::new(630.0, 40.0));
266        assert_eq!(
267            jobs[0].clip_rect,
268            Rect::from_min_max(Pos2::new(0.0, 0.0), Pos2::new(640.0, 480.0))
269        );
270    }
271}