Skip to main content

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}