Compare commits

..

2 Commits

Author SHA1 Message Date
Skyler Lehmkuhl 4bb7970234 Keep menus alive for submenu interaction 2026-07-14 13:22:48 -04:00
Skyler Lehmkuhl f5477e30e5 Add custom cursor images (OS-composited, no frame lag)
A cursor painted into the egui scene is composited with the frame, so it
always trails the pointer by a frame or more. This adds a path to hand a
cursor image to the windowing system instead, where the compositor draws
it with no lag.

- egui: PlatformOutput::cursor_image + Context::set_cursor_image().
  CursorImage carries an id alongside the pixels so backends can detect
  "same cursor as last frame" without hashing or comparing the buffer.
- egui-winit: handle_platform_output() stashes the image;
  State::apply_pending_cursor_image() builds the winit CustomCursor and
  applies it. The split is necessary because winit only creates custom
  cursors from an ActiveEventLoop, which handle_platform_output has no
  access to. Rebuilds only when the id changes, and suppresses
  set_cursor_icon while a custom cursor is active so the two don't fight.
- eframe (wgpu): run_ui_and_paint already had the ActiveEventLoop and
  simply wasn't passing it down; now it does, and applies the cursor.
- eframe (web): ignore the new field for now; the web backend could
  express it as a CSS `cursor: url(...)`.

Supported by winit on X11, Wayland, Windows, macOS and Web. The glow
backend compiles unchanged but does not apply cursor images yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:02:51 -04:00
7 changed files with 143 additions and 5 deletions

View File

@ -392,7 +392,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(event_loop, window_id)
} else {
Ok(EventResult::Wait)
}
@ -540,7 +540,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<EventResult> {
fn run_ui_and_paint(
&mut self,
event_loop: &ActiveEventLoop,
window_id: WindowId,
) -> Result<EventResult> {
profiling::function_scope!();
let Some(viewport_id) = self
@ -675,6 +679,9 @@ impl WgpuWinitRunning<'_> {
};
egui_winit.handle_platform_output(window, platform_output);
// Custom cursor images can only be created from an `ActiveEventLoop`, so they're applied
// here rather than inside `handle_platform_output`.
egui_winit.apply_pending_cursor_image(event_loop, window);
let clipped_primitives = egui_ctx.tessellate(shapes, pixels_per_point);

View File

@ -321,6 +321,7 @@ impl AppRunner {
let egui::PlatformOutput {
commands,
cursor_icon,
cursor_image: _, // TODO: web could express this as a CSS `cursor: url(...)`
events: _, // already handled
mutable_text_under_cursor: _, // TODO(#4569): https://github.com/emilk/egui/issues/4569
ime,

View File

@ -85,6 +85,15 @@ pub struct State {
any_pointer_button_down: bool,
current_cursor_icon: Option<egui::CursorIcon>,
/// Custom cursor image requested by the app this frame, not yet applied.
///
/// Applying it needs an `ActiveEventLoop` (that's where winit creates custom cursors), which
/// `handle_platform_output` doesn't have — so we stash it and let the integration call
/// [`State::apply_pending_cursor_image`] from somewhere that does.
pending_cursor_image: Option<Option<egui::CursorImage>>,
/// `id` of the custom cursor currently set on the window, so we only rebuild it on change.
current_cursor_image_id: Option<u64>,
clipboard: clipboard::Clipboard,
/// If `true`, mouse inputs will be treated as touches.
@ -141,6 +150,8 @@ impl State {
pointer_pos_in_points: None,
any_pointer_button_down: false,
current_cursor_icon: None,
pending_cursor_image: None,
current_cursor_image_id: None,
clipboard: clipboard::Clipboard::new(
display_target.display_handle().ok().map(|h| h.as_raw()),
@ -884,9 +895,11 @@ impl State {
let contents = contents.replace("\r\n", "\n");
if !contents.is_empty() {
self.egui_input.events.push(egui::Event::Paste(contents));
return;
}
}
return;
// Clipboard is empty or unavailable — fall through to push a
// Key event so the application can handle paste from its own cache.
}
}
@ -941,6 +954,7 @@ impl State {
let egui::PlatformOutput {
commands,
cursor_icon,
cursor_image,
events: _, // handled elsewhere
mutable_text_under_cursor: _, // only used in eframe web
ime,
@ -964,7 +978,18 @@ impl State {
}
}
self.set_cursor_icon(window, cursor_icon);
// A custom cursor image, when set, *is* the cursor — don't also apply the icon, or we'd
// fight with it every frame. Stash it for `apply_pending_cursor_image`, which runs where
// an `ActiveEventLoop` is in scope.
if cursor_image.is_some() {
self.pending_cursor_image = Some(cursor_image);
} else {
if self.current_cursor_image_id.is_some() {
// Custom cursor was just cleared — go back to a normal icon.
self.pending_cursor_image = Some(None);
}
self.set_cursor_icon(window, cursor_icon);
}
// When IME is disabled as a workaround for buggy systems, don't enable IME at all.
// This ensures the system doesn't try to intercept keyboard input.
@ -1013,6 +1038,56 @@ impl State {
}
}
/// Apply the custom cursor image requested by the last [`Self::handle_platform_output`].
///
/// Must be called from somewhere with an `ActiveEventLoop` — winit only creates custom cursors
/// there. Cheap to call every frame: it's a no-op unless the image actually changed.
pub fn apply_pending_cursor_image(
&mut self,
event_loop: &winit::event_loop::ActiveEventLoop,
window: &Window,
) {
let Some(pending) = self.pending_cursor_image.take() else {
return;
};
match pending {
Some(image) => {
if self.current_cursor_image_id == Some(image.id) {
return; // Already set — rebuilding it every frame would be wasteful.
}
let source = winit::window::CustomCursor::from_rgba(
image.rgba.as_ref().clone(),
image.size.0,
image.size.1,
image.hotspot.0,
image.hotspot.1,
);
match source {
Ok(source) => {
let cursor = event_loop.create_custom_cursor(source);
window.set_cursor_visible(true);
window.set_cursor(winit::window::Cursor::Custom(cursor));
self.current_cursor_image_id = Some(image.id);
// Force the next icon change to be re-applied, since the window is no
// longer showing whatever icon `current_cursor_icon` claims.
self.current_cursor_icon = None;
}
Err(err) => {
log::warn!("Failed to build custom cursor: {err}");
self.current_cursor_image_id = None;
}
}
}
None => {
self.current_cursor_image_id = None;
self.current_cursor_icon = None; // Force the icon to be re-applied.
}
}
}
fn set_cursor_icon(&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.

View File

@ -515,6 +515,13 @@ impl SubMenu {
content(ui)
});
if let Some(_) = &popup_response {
// Keep the submenu alive so `from_id`'s staleness check doesn't close it.
// Without this, menus whose content has no further SubMenuButtons never update
// `last_visible_pass`, causing the parent to reset `open_item` after ~2 frames.
MenuState::mark_shown(ui.ctx(), id);
}
if let Some(popup_response) = &popup_response {
// If no child sub menu is open means we must be the deepest child sub menu.
let is_deepest_submenu = MenuState::is_deepest_open_sub_menu(ui.ctx(), id);

View File

@ -1514,6 +1514,15 @@ impl Context {
self.output_mut(|o| o.cursor_icon = cursor_icon);
}
/// Replace the mouse cursor with a custom image for this frame.
///
/// This becomes the real OS cursor, so it is composited by the window system and does not lag
/// behind the pointer the way a cursor painted into the egui scene does. Pass `None` to go
/// back to the icon set by [`Self::set_cursor_icon`].
pub fn set_cursor_image(&self, cursor_image: Option<crate::CursorImage>) {
self.output_mut(|o| o.cursor_image = cursor_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) {

View File

@ -66,6 +66,34 @@ impl FullOutput {
}
}
/// A custom mouse cursor image, handed to the windowing backend to become the real OS cursor.
///
/// Because the OS composites the cursor, this has none of the one-frame lag you get from painting
/// a cursor into the egui scene.
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CursorImage {
/// Identifies this image. The backend recreates the OS cursor only when this changes, so the
/// pixels don't have to be compared (or even hashed) every frame.
pub id: u64,
/// Premultiplied-alpha RGBA8, `size.0 * size.1 * 4` bytes, row-major.
pub rgba: std::sync::Arc<Vec<u8>>,
/// Width and height in physical pixels.
pub size: (u16, u16),
/// The click point, in physical pixels from the image's top-left.
pub hotspot: (u16, u16),
}
impl PartialEq for CursorImage {
fn eq(&self, other: &Self) -> bool {
// `id` identifies the pixels; comparing the buffers themselves would be pointless work.
self.id == other.id && self.size == other.size && self.hotspot == other.hotspot
}
}
/// Information about text being edited.
///
/// Useful for IME.
@ -113,6 +141,15 @@ pub struct PlatformOutput {
/// Set the cursor to this icon.
pub cursor_icon: CursorIcon,
/// Replace the mouse cursor with a custom image.
///
/// Unlike drawing a cursor into the egui scene, this becomes the real OS cursor, so it is
/// composited by the window system and does not lag a frame behind the pointer.
///
/// Takes precedence over [`Self::cursor_icon`] when set. Backends that do not support custom
/// cursors ignore this and fall back to `cursor_icon`.
pub cursor_image: Option<CursorImage>,
/// Events that may be useful to e.g. a screen reader.
pub events: Vec<OutputEvent>,
@ -172,6 +209,7 @@ impl PlatformOutput {
let Self {
mut commands,
cursor_icon,
cursor_image,
mut events,
mutable_text_under_cursor,
ime,
@ -183,6 +221,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);

View File

@ -488,7 +488,7 @@ pub use self::{
Key, UserData,
input::*,
output::{
self, CursorIcon, FullOutput, OpenUrl, OutputCommand, PlatformOutput,
self, CursorIcon, CursorImage, FullOutput, OpenUrl, OutputCommand, PlatformOutput,
UserAttentionType, WidgetInfo,
},
},