diff --git a/crates/eframe/src/native/glow_integration.rs b/crates/eframe/src/native/glow_integration.rs index fd61b0cd1..9074507a5 100644 --- a/crates/eframe/src/native/glow_integration.rs +++ b/crates/eframe/src/native/glow_integration.rs @@ -679,7 +679,7 @@ impl GlowWinitRunning<'_> { let gl_surface = viewport.gl_surface.as_ref().unwrap(); let egui_winit = viewport.egui_winit.as_mut().unwrap(); - egui_winit.handle_platform_output(&window, platform_output); + egui_winit.handle_platform_output_with_event_loop(&window, event_loop, platform_output); if is_visible { let clipped_primitives = integration.egui_ctx.tessellate(shapes, pixels_per_point); diff --git a/crates/eframe/src/native/wgpu_integration.rs b/crates/eframe/src/native/wgpu_integration.rs index 161c3f84b..59490bdb2 100644 --- a/crates/eframe/src/native/wgpu_integration.rs +++ b/crates/eframe/src/native/wgpu_integration.rs @@ -409,7 +409,7 @@ impl WinitApp for WgpuWinitApp<'_> { self.initialized_all_windows(event_loop); if let Some(running) = &mut self.running { - running.run_ui_and_paint(window_id) + running.run_ui_and_paint(window_id, event_loop) } else { Ok(EventResult::Wait) } @@ -569,7 +569,11 @@ impl WgpuWinitRunning<'_> { } /// This is called both for the root viewport, and all deferred viewports - fn run_ui_and_paint(&mut self, window_id: WindowId) -> Result { + fn run_ui_and_paint( + &mut self, + window_id: WindowId, + event_loop: &ActiveEventLoop, + ) -> Result { profiling::function_scope!(); let Some(viewport_id) = self @@ -710,7 +714,7 @@ impl WgpuWinitRunning<'_> { return Ok(EventResult::Wait); }; - egui_winit.handle_platform_output(window, platform_output); + egui_winit.handle_platform_output_with_event_loop(window, event_loop, platform_output); let vsync_secs = if is_visible { let clipped_primitives = egui_ctx.tessellate(shapes, pixels_per_point); diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index c15e78d68..5388bf023 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -368,7 +368,8 @@ impl AppRunner { let egui::PlatformOutput { commands, cursor_icon, - events: _, // already handled + cursor_image: _, // TODO(alextournai): support custom bitmap cursors on the web (via CSS `url(...)`) + events: _, // already handled mutable_text_under_cursor: _, // TODO(#4569): https://github.com/emilk/egui/issues/4569 ime, accesskit_update: _, // not currently implemented diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index 331c1f526..166a38c28 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -32,7 +32,7 @@ use winit::{ dpi::{PhysicalPosition, PhysicalSize}, event::ElementState, event_loop::ActiveEventLoop, - window::{CursorGrabMode, Window, WindowButtons, WindowLevel}, + window::{CursorGrabMode, CustomCursor, Window, WindowButtons, WindowLevel}, }; pub fn screen_size_in_pixels(window: &Window) -> egui::Vec2 { @@ -88,6 +88,14 @@ pub struct State { any_pointer_button_down: bool, current_cursor_icon: Option, + /// Cached `CustomCursor` for the last RGBA bitmap pushed through + /// `PlatformOutput::cursor_image`. We dedupe by `Arc::as_ptr` so the + /// integration only re-uploads the bitmap to the OS when the app + /// switches sprite, not every frame the cursor moves. `usize` is the + /// raw pointer of the source `Arc<[u8]>` — opaque, only used as a + /// cache key. + current_custom_cursor: Option<(usize, CustomCursor)>, + clipboard: clipboard::Clipboard, /// If `true`, mouse inputs will be treated as touches. @@ -141,6 +149,7 @@ impl State { pointer_pos_in_points: None, any_pointer_button_down: false, current_cursor_icon: None, + current_custom_cursor: None, clipboard: clipboard::Clipboard::new( display_target.display_handle().ok().map(|h| h.as_raw()), @@ -1020,12 +1029,38 @@ impl State { &mut self, window: &Window, platform_output: egui::PlatformOutput, + ) { + self.handle_platform_output_inner(window, None, platform_output); + } + + /// Same as [`Self::handle_platform_output`] but threads the + /// `ActiveEventLoop` so we can register a `winit::CustomCursor` from + /// `PlatformOutput::cursor_image`. Integration paths that don't have + /// access to the event loop (e.g. immediate viewports) should call + /// [`Self::handle_platform_output`] instead — any custom cursor + /// request is silently dropped there and the standard `cursor_icon` + /// path still runs. + pub fn handle_platform_output_with_event_loop( + &mut self, + window: &Window, + event_loop: &ActiveEventLoop, + platform_output: egui::PlatformOutput, + ) { + self.handle_platform_output_inner(window, Some(event_loop), platform_output); + } + + fn handle_platform_output_inner( + &mut self, + window: &Window, + event_loop: Option<&ActiveEventLoop>, + platform_output: egui::PlatformOutput, ) { profiling::function_scope!(); let egui::PlatformOutput { commands, cursor_icon, + cursor_image, events: _, // handled elsewhere mutable_text_under_cursor: _, // only used in eframe web ime, @@ -1048,7 +1083,7 @@ impl State { } } - self.set_cursor_icon(window, cursor_icon); + self.apply_cursor(window, event_loop, cursor_icon, cursor_image.as_ref()); let allow_ime = ime.is_some(); let is_toggling_ime = self.allow_ime != allow_ime; @@ -1111,26 +1146,92 @@ impl State { let _ = accesskit_update; } - fn set_cursor_icon(&mut self, window: &Window, cursor_icon: egui::CursorIcon) { + /// Apply either a bitmap cursor (preferred when both `cursor_image` + /// and `event_loop` are `Some`) or the standard `cursor_icon` to the + /// window. Mirrors the no-flicker dedupe the old `set_cursor_icon` + /// did, on the appropriate cache key for whichever path is active. + fn apply_cursor( + &mut self, + window: &Window, + event_loop: Option<&ActiveEventLoop>, + cursor_icon: egui::CursorIcon, + cursor_image: Option<&egui::CustomCursorImage>, + ) { + let is_pointer_in_window = self.pointer_pos_in_points.is_some(); + if !is_pointer_in_window { + // Drop both caches so the cursor gets re-applied (and the + // bitmap re-checked for staleness) once the pointer comes + // back. Same contract the old `set_cursor_icon` followed. + self.current_cursor_icon = None; + self.current_custom_cursor = None; + return; + } + + // Bitmap cursor wins over CursorIcon when both are present and we + // have an event loop to register it with. Otherwise the bitmap is + // dropped and we fall through to the icon path — this is the + // documented fallback for integrations that didn't opt in. + if let (Some(image), Some(event_loop)) = (cursor_image, event_loop) { + let key = std::sync::Arc::as_ptr(&image.rgba).cast::() as usize; + let cached = self + .current_custom_cursor + .as_ref() + .filter(|(k, _)| *k == key) + .map(|(_, c)| c.clone()); + + let custom = match cached { + Some(c) => c, + None => match winit::window::CustomCursor::from_rgba( + image.rgba.to_vec(), + image.size[0], + image.size[1], + image.hotspot[0], + image.hotspot[1], + ) { + Ok(source) => { + let c = event_loop.create_custom_cursor(source); + self.current_custom_cursor = Some((key, c.clone())); + c + } + Err(err) => { + log::warn!( + "egui-winit: invalid cursor bitmap, falling back to cursor_icon: {err:?}" + ); + self.current_custom_cursor = None; + self.set_cursor_icon_inner(window, cursor_icon); + return; + } + }, + }; + + window.set_cursor_visible(true); + window.set_cursor(custom); + // Resync `current_cursor_icon` so the next icon-only path + // notices a real change rather than dedupe-skipping it. + self.current_cursor_icon = None; + return; + } + + self.current_custom_cursor = None; + self.set_cursor_icon_inner(window, cursor_icon); + } + + /// Icon-only path, factored out so `apply_cursor` can fall back to it + /// when the bitmap path bails. Preserves the original dedupe. + fn set_cursor_icon_inner(&mut self, window: &Window, cursor_icon: egui::CursorIcon) { if self.current_cursor_icon == Some(cursor_icon) { // Prevent flickering near frame boundary when Windows OS tries to control cursor icon for window resizing. // On other platforms: just early-out to save CPU. return; } - let is_pointer_in_window = self.pointer_pos_in_points.is_some(); - if is_pointer_in_window { - self.current_cursor_icon = Some(cursor_icon); + self.current_cursor_icon = Some(cursor_icon); - if let Some(winit_cursor_icon) = translate_cursor(cursor_icon) { - window.set_cursor_visible(true); - window.set_cursor(winit_cursor_icon); - } else { - window.set_cursor_visible(false); - } + if let Some(winit_cursor_icon) = translate_cursor(cursor_icon) { + window.set_cursor_visible(true); + window.set_cursor(winit_cursor_icon); } else { - // Remember to set the cursor again once the cursor returns to the screen: - self.current_cursor_icon = None; + window.set_cursor_visible(false); } } } diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index dee71020f..ddfc7e93d 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -1533,6 +1533,19 @@ impl Context { self.output_mut(|o| o.cursor_icon = cursor_icon); } + /// Request that the integration display this RGBA bitmap as the OS + /// cursor for the next frame, instead of the standard `cursor_icon`. + /// Backends that don't support custom cursors (web, eframe with + /// non-winit integrations) silently fall back to the icon. + /// + /// Pass `None` to clear and revert to `cursor_icon` selection. + /// + /// The integration is expected to dedupe by `Arc` pointer identity, + /// so reusing the same `Arc<[u8]>` across frames is cheap. + pub fn set_cursor_image(&self, image: Option) { + self.output_mut(|o| o.cursor_image = image); + } + /// Add a command to [`PlatformOutput::commands`], /// for the integration to execute at the end of the frame. pub fn send_cmd(&self, cmd: crate::OutputCommand) { diff --git a/crates/egui/src/data/output.rs b/crates/egui/src/data/output.rs index c3a4cf382..4793fa8a0 100644 --- a/crates/egui/src/data/output.rs +++ b/crates/egui/src/data/output.rs @@ -116,6 +116,16 @@ pub struct PlatformOutput { /// Set the cursor to this icon. pub cursor_icon: CursorIcon, + /// If set, the integration should display this RGBA image as the OS + /// cursor (via e.g. `winit::window::CustomCursor`) instead of the + /// standard `cursor_icon`. Set per frame; integrations that don't + /// support custom cursors fall back to `cursor_icon`. + /// + /// Skipped from serde because the bitmap is ephemeral and shouldn't + /// roundtrip through persisted state. + #[cfg_attr(feature = "serde", serde(skip))] + pub cursor_image: Option, + /// Events that may be useful to e.g. a screen reader. pub events: Vec, @@ -177,6 +187,7 @@ impl PlatformOutput { let Self { mut commands, cursor_icon, + cursor_image, mut events, mutable_text_under_cursor, ime, @@ -187,6 +198,7 @@ impl PlatformOutput { self.commands.append(&mut commands); self.cursor_icon = cursor_icon; + self.cursor_image = cursor_image; self.events.append(&mut events); self.mutable_text_under_cursor = mutable_text_under_cursor; self.ime = ime.or(self.ime); @@ -198,10 +210,12 @@ impl PlatformOutput { self.accesskit_update = accesskit_update; } - /// Take everything ephemeral (everything except `cursor_icon` currently) + /// Take everything ephemeral (everything except `cursor_icon` and + /// `cursor_image` currently) pub fn take(&mut self) -> Self { let taken = std::mem::take(self); - self.cursor_icon = taken.cursor_icon; // everything else is ephemeral + self.cursor_icon = taken.cursor_icon; // sticky between frames + self.cursor_image = taken.cursor_image.clone(); // sticky between frames taken } @@ -261,6 +275,39 @@ pub enum UserAttentionType { Reset, } +/// A bitmap cursor pushed to the integration via [`PlatformOutput::cursor_image`]. +/// +/// The integration is expected to upload this to the OS as a real cursor +/// (so the image is not clipped by the egui window — what `egui::Painter` +/// drawn cursors suffer from). Backends that don't support it should fall +/// back to [`PlatformOutput::cursor_icon`]. +/// +/// `rgba` is straight (non-premultiplied) RGBA — same encoding as +/// `winit::window::CustomCursor::from_rgba`. The buffer length must be +/// exactly `size[0] * size[1] * 4` bytes. `size` and `hotspot` use +/// `u16` to match winit's native types and avoid a lossy cast in the +/// integration layer. +/// +/// `Arc<[u8]>` is used so integrations can dedupe / cache by pointer +/// identity (`Arc::ptr_eq`) and avoid re-uploading the same bitmap to +/// the OS every frame. +#[derive(Clone, PartialEq, Eq)] +pub struct CustomCursorImage { + pub rgba: std::sync::Arc<[u8]>, + pub size: [u16; 2], + pub hotspot: [u16; 2], +} + +impl std::fmt::Debug for CustomCursorImage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CustomCursorImage") + .field("size", &self.size) + .field("hotspot", &self.hotspot) + .field("rgba_len", &self.rgba.len()) + .finish() + } +} + /// A mouse cursor icon. /// /// egui emits a [`CursorIcon`] in [`PlatformOutput`] each frame as a request to the integration. diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 681aacdd0..e93d76787 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -467,8 +467,8 @@ pub use self::{ Key, UserData, input::*, output::{ - self, CursorIcon, FullOutput, OpenUrl, OutputCommand, PlatformOutput, - UserAttentionType, WidgetInfo, + self, CursorIcon, CustomCursorImage, FullOutput, OpenUrl, OutputCommand, + PlatformOutput, UserAttentionType, WidgetInfo, }, }, drag_and_drop::DragAndDrop,