egui_rotate/plugin.rs
1//! [`RotationPlugin`] — viewport rotation as a self-contained [`egui::Plugin`].
2//!
3//! Register it once and rotation becomes transparent for the whole pipeline,
4//! with **no integration code** and **no eframe hooks** — it works the same on
5//! `egui_glow`, `egui_wgpu`, or any custom backend:
6//!
7//! ```no_run
8//! # let ctx = egui::Context::default();
9//! use egui_rotate::{Rotation, RotationPlugin};
10//!
11//! ctx.add_plugin(RotationPlugin::new(Rotation::CW90));
12//! ```
13//!
14//! ## Multiple windows (child viewports)
15//!
16//! Rotation is **per-viewport and opt-in**: [`RotationPlugin::new`] configures the
17//! root window, and any viewport you don't configure passes through untouched. So
18//! a rotated cabinet window can coexist with normal child windows (settings
19//! dialogs, etc.). Configure a child explicitly with
20//! [`RotationPlugin::set_viewport_rotation`].
21//!
22//! What the plugin does each frame, per viewport:
23//! - [`input_hook`](egui::Plugin::input_hook): rotates that viewport's pointer/touch
24//! input into logical space and remembers its logical size for the output stage.
25//! - [`on_end_pass`](egui::Plugin::on_end_pass): with a [`SoftwareCursor`], draws
26//! the virtual cursor on top (only on the cursor's viewport), in logical space.
27//! - [`output_hook`](egui::Plugin::output_hook): rotates that viewport's
28//! pre-tessellation shapes back to physical space and either remaps the OS cursor
29//! icon or hides it while a software cursor is active.
30//!
31//! This plugin supersedes the now-deprecated free helpers
32//! ([`crate::transform_raw_input`] / [`crate::transform_clipped_primitives`]):
33//! use one or the other, never both, or rotation is applied twice.
34
35use std::collections::HashMap;
36
37use egui::{FullOutput, RawInput, Vec2, ViewportId};
38
39use crate::{CursorIconExt, Rotation};
40
41#[cfg(feature = "software-cursor")]
42use crate::SoftwareCursor;
43#[cfg(feature = "software-cursor")]
44use egui::Pos2;
45
46/// Per-pass state, pushed in `input_hook` and popped in `output_hook`.
47///
48/// `output_hook` is not told which viewport its `FullOutput` belongs to, but the
49/// two hooks are called as a strict begin/end pair per pass — and nested
50/// (immediate) child viewports pair up LIFO — so a stack reunites each output
51/// with the rotation and logical size computed for its input.
52#[derive(Clone, Copy, Debug)]
53struct PassState {
54 rotation: Rotation,
55 logical_size: Vec2,
56 /// Whether this pass is the software cursor's viewport.
57 #[cfg(feature = "software-cursor")]
58 cursor_here: bool,
59}
60
61impl PassState {
62 fn passthrough() -> Self {
63 Self {
64 rotation: Rotation::None,
65 logical_size: Vec2::ZERO,
66 #[cfg(feature = "software-cursor")]
67 cursor_here: false,
68 }
69 }
70}
71
72/// A [`egui::Plugin`] that applies per-viewport rotation transparently.
73///
74/// One instance per [`egui::Context`]. Change the root rotation at runtime through
75/// the registered handle:
76///
77/// ```no_run
78/// # let ctx = egui::Context::default();
79/// # use egui_rotate::{Rotation, RotationPlugin};
80/// # ctx.add_plugin(RotationPlugin::new(Rotation::None));
81/// ctx.plugin::<RotationPlugin>().lock().set_rotation(Rotation::CW270);
82/// ```
83#[derive(Clone, Debug, Default)]
84pub struct RotationPlugin {
85 /// Rotation per viewport. A viewport absent from the map is not rotated.
86 rotations: HashMap<ViewportId, Rotation>,
87 /// Begin/end pairing across (possibly nested) viewport passes.
88 pass_stack: Vec<PassState>,
89
90 /// Optional software cursor (cabinet / kiosk displays). See
91 /// [`Self::with_software_cursor`].
92 #[cfg(feature = "software-cursor")]
93 cursor: Option<SoftwareCursor>,
94 /// The viewport the software cursor belongs to.
95 #[cfg(feature = "software-cursor")]
96 cursor_viewport: ViewportId,
97 /// Pending OS-cursor warp request (non-locked edge release), drained via
98 /// [`Self::take_pending_warp`].
99 #[cfg(feature = "software-cursor")]
100 pending_warp: Option<Pos2>,
101}
102
103impl RotationPlugin {
104 /// Create a plugin rotating the **root** viewport by `rotation`.
105 ///
106 /// Child viewports are left untouched unless configured with
107 /// [`Self::set_viewport_rotation`].
108 pub fn new(rotation: Rotation) -> Self {
109 let mut plugin = Self::default();
110 plugin.rotations.insert(ViewportId::ROOT, rotation);
111 plugin
112 }
113
114 fn rotation_for(&self, viewport: ViewportId) -> Rotation {
115 self.rotations
116 .get(&viewport)
117 .copied()
118 .unwrap_or(Rotation::None)
119 }
120
121 /// The root viewport's rotation.
122 pub fn rotation(&self) -> Rotation {
123 self.rotation_for(ViewportId::ROOT)
124 }
125
126 /// Set the root viewport's rotation. Takes effect on the next frame.
127 pub fn set_rotation(&mut self, rotation: Rotation) {
128 self.rotations.insert(ViewportId::ROOT, rotation);
129 }
130
131 /// The rotation configured for a specific viewport (`None` if unconfigured).
132 pub fn viewport_rotation(&self, viewport: ViewportId) -> Rotation {
133 self.rotation_for(viewport)
134 }
135
136 /// Set the rotation for a specific viewport (e.g. a child window). Pass
137 /// [`Rotation::None`] to stop rotating it.
138 pub fn set_viewport_rotation(&mut self, viewport: ViewportId, rotation: Rotation) {
139 self.rotations.insert(viewport, rotation);
140 }
141}
142
143#[cfg(feature = "software-cursor")]
144impl RotationPlugin {
145 /// Attach a [`SoftwareCursor`] to the **root** viewport. See
146 /// [`Self::with_software_cursor_on`].
147 pub fn with_software_cursor(self, cursor: SoftwareCursor) -> Self {
148 self.with_software_cursor_on(ViewportId::ROOT, cursor)
149 }
150
151 /// Attach a [`SoftwareCursor`] to a specific viewport: the plugin then captures
152 /// the OS cursor, draws a virtual cursor in logical space on that viewport, and
153 /// hides the OS cursor while captured.
154 ///
155 /// In **locked** mode (see [`SoftwareCursor::with_lock`]) this is fully
156 /// self-contained — no integration code (ideal for fullscreen kiosk / pinball
157 /// cabinets). In **non-locked** mode the cursor is released to the OS at the
158 /// screen edge; the integration must warp the OS cursor to the position
159 /// returned by [`Self::take_pending_warp`] each frame.
160 pub fn with_software_cursor_on(mut self, viewport: ViewportId, cursor: SoftwareCursor) -> Self {
161 self.cursor = Some(cursor);
162 self.cursor_viewport = viewport;
163 self
164 }
165
166 /// Shared access to the attached [`SoftwareCursor`] (e.g. to query
167 /// [`SoftwareCursor::is_captured`]). Returns `None` if none was attached.
168 pub fn software_cursor(&self) -> Option<&SoftwareCursor> {
169 self.cursor.as_ref()
170 }
171
172 /// Mutable access to the attached [`SoftwareCursor`], e.g. to change scale or
173 /// lock at runtime. Returns `None` if no software cursor was attached.
174 pub fn software_cursor_mut(&mut self) -> Option<&mut SoftwareCursor> {
175 self.cursor.as_mut()
176 }
177
178 /// Take a pending OS-cursor warp request (physical-space position), if any.
179 ///
180 /// In non-locked software-cursor mode, call this once per frame after running
181 /// egui: when `Some`, warp the OS cursor to that position and make it visible.
182 /// Always `None` in locked mode.
183 pub fn take_pending_warp(&mut self) -> Option<Pos2> {
184 self.pending_warp.take()
185 }
186}
187
188impl egui::Plugin for RotationPlugin {
189 fn debug_name(&self) -> &'static str {
190 "egui_rotate::RotationPlugin"
191 }
192
193 fn input_hook(&mut self, input: &mut RawInput) {
194 let viewport = input.viewport_id;
195 let rotation = self.rotation_for(viewport);
196
197 if rotation.is_none() {
198 self.pass_stack.push(PassState::passthrough());
199 return;
200 }
201
202 #[cfg(feature = "software-cursor")]
203 let cursor_here = match self.cursor.as_mut() {
204 Some(cursor) if viewport == self.cursor_viewport => {
205 // `screen_rect` is still physical here.
206 let physical_size = input.screen_rect.map(|r| r.size()).unwrap_or_default();
207 let out = cursor.process_input(input, rotation, physical_size);
208 if let Some(warp) = out.release_os_cursor_to {
209 self.pending_warp = Some(warp);
210 }
211 true
212 }
213 _ => {
214 crate::input::rotate_raw_input(input, rotation);
215 false
216 }
217 };
218 #[cfg(not(feature = "software-cursor"))]
219 crate::input::rotate_raw_input(input, rotation);
220
221 // After rotation, `screen_rect` is in logical space.
222 let logical_size = input.screen_rect.map(|r| r.size()).unwrap_or_default();
223 self.pass_stack.push(PassState {
224 rotation,
225 logical_size,
226 #[cfg(feature = "software-cursor")]
227 cursor_here,
228 });
229 }
230
231 #[cfg(feature = "software-cursor")]
232 fn on_end_pass(&mut self, ui: &mut egui::Ui) {
233 let ctx = ui.ctx().clone();
234 let viewport = ctx.viewport_id();
235 if viewport != self.cursor_viewport || self.rotation_for(viewport).is_none() {
236 return;
237 }
238 let Some(cursor) = &self.cursor else { return };
239 if cursor.virtual_pos().is_none() {
240 return;
241 }
242
243 // Draw the virtual cursor in logical space, on a top-most layer; the
244 // `output_hook` below rotates it into physical space along with the rest.
245 let icon = ctx.output(|o| o.cursor_icon);
246 let painter = ctx.layer_painter(egui::LayerId::new(
247 egui::Order::Foreground,
248 egui::Id::new("egui_rotate::software_cursor"),
249 ));
250 cursor.draw(&painter, icon);
251 }
252
253 fn output_hook(&mut self, output: &mut FullOutput) {
254 let Some(state) = self.pass_stack.pop() else {
255 return;
256 };
257 if state.rotation.is_none() {
258 return;
259 }
260
261 crate::rotate_clipped_shapes(&mut output.shapes, state.rotation, state.logical_size);
262
263 // On the software cursor's viewport, hide the OS cursor while captured (we
264 // draw our own). Otherwise remap directional icons so the OS cursor, which
265 // the OS draws un-rotated, still points the right way on screen.
266 #[cfg(feature = "software-cursor")]
267 if state.cursor_here && self.cursor.as_ref().is_some_and(|c| c.is_captured()) {
268 output.platform_output.cursor_icon = egui::CursorIcon::None;
269 return;
270 }
271
272 output.platform_output.cursor_icon =
273 output.platform_output.cursor_icon.rotate(state.rotation);
274 }
275}