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>
This commit is contained in:
parent
eb1756df3f
commit
f5477e30e5
|
|
@ -392,7 +392,7 @@ impl WinitApp for WgpuWinitApp<'_> {
|
||||||
self.initialized_all_windows(event_loop);
|
self.initialized_all_windows(event_loop);
|
||||||
|
|
||||||
if let Some(running) = &mut self.running {
|
if let Some(running) = &mut self.running {
|
||||||
running.run_ui_and_paint(window_id)
|
running.run_ui_and_paint(event_loop, window_id)
|
||||||
} else {
|
} else {
|
||||||
Ok(EventResult::Wait)
|
Ok(EventResult::Wait)
|
||||||
}
|
}
|
||||||
|
|
@ -540,7 +540,11 @@ impl WgpuWinitRunning<'_> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This is called both for the root viewport, and all deferred viewports
|
/// 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!();
|
profiling::function_scope!();
|
||||||
|
|
||||||
let Some(viewport_id) = self
|
let Some(viewport_id) = self
|
||||||
|
|
@ -675,6 +679,9 @@ impl WgpuWinitRunning<'_> {
|
||||||
};
|
};
|
||||||
|
|
||||||
egui_winit.handle_platform_output(window, platform_output);
|
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);
|
let clipped_primitives = egui_ctx.tessellate(shapes, pixels_per_point);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,7 @@ impl AppRunner {
|
||||||
let egui::PlatformOutput {
|
let egui::PlatformOutput {
|
||||||
commands,
|
commands,
|
||||||
cursor_icon,
|
cursor_icon,
|
||||||
|
cursor_image: _, // TODO: web could express this as a CSS `cursor: url(...)`
|
||||||
events: _, // already handled
|
events: _, // already handled
|
||||||
mutable_text_under_cursor: _, // TODO(#4569): https://github.com/emilk/egui/issues/4569
|
mutable_text_under_cursor: _, // TODO(#4569): https://github.com/emilk/egui/issues/4569
|
||||||
ime,
|
ime,
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,15 @@ pub struct State {
|
||||||
any_pointer_button_down: bool,
|
any_pointer_button_down: bool,
|
||||||
current_cursor_icon: Option<egui::CursorIcon>,
|
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,
|
clipboard: clipboard::Clipboard,
|
||||||
|
|
||||||
/// If `true`, mouse inputs will be treated as touches.
|
/// If `true`, mouse inputs will be treated as touches.
|
||||||
|
|
@ -141,6 +150,8 @@ impl State {
|
||||||
pointer_pos_in_points: None,
|
pointer_pos_in_points: None,
|
||||||
any_pointer_button_down: false,
|
any_pointer_button_down: false,
|
||||||
current_cursor_icon: None,
|
current_cursor_icon: None,
|
||||||
|
pending_cursor_image: None,
|
||||||
|
current_cursor_image_id: None,
|
||||||
|
|
||||||
clipboard: clipboard::Clipboard::new(
|
clipboard: clipboard::Clipboard::new(
|
||||||
display_target.display_handle().ok().map(|h| h.as_raw()),
|
display_target.display_handle().ok().map(|h| h.as_raw()),
|
||||||
|
|
@ -884,9 +895,11 @@ impl State {
|
||||||
let contents = contents.replace("\r\n", "\n");
|
let contents = contents.replace("\r\n", "\n");
|
||||||
if !contents.is_empty() {
|
if !contents.is_empty() {
|
||||||
self.egui_input.events.push(egui::Event::Paste(contents));
|
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 {
|
let egui::PlatformOutput {
|
||||||
commands,
|
commands,
|
||||||
cursor_icon,
|
cursor_icon,
|
||||||
|
cursor_image,
|
||||||
events: _, // handled elsewhere
|
events: _, // handled elsewhere
|
||||||
mutable_text_under_cursor: _, // only used in eframe web
|
mutable_text_under_cursor: _, // only used in eframe web
|
||||||
ime,
|
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.
|
// 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.
|
// 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) {
|
fn set_cursor_icon(&mut self, window: &Window, cursor_icon: egui::CursorIcon) {
|
||||||
if self.current_cursor_icon == Some(cursor_icon) {
|
if self.current_cursor_icon == Some(cursor_icon) {
|
||||||
// Prevent flickering near frame boundary when Windows OS tries to control cursor icon for window resizing.
|
// Prevent flickering near frame boundary when Windows OS tries to control cursor icon for window resizing.
|
||||||
|
|
|
||||||
|
|
@ -1514,6 +1514,15 @@ impl Context {
|
||||||
self.output_mut(|o| o.cursor_icon = cursor_icon);
|
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`],
|
/// Add a command to [`PlatformOutput::commands`],
|
||||||
/// for the integration to execute at the end of the frame.
|
/// for the integration to execute at the end of the frame.
|
||||||
pub fn send_cmd(&self, cmd: crate::OutputCommand) {
|
pub fn send_cmd(&self, cmd: crate::OutputCommand) {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// Information about text being edited.
|
||||||
///
|
///
|
||||||
/// Useful for IME.
|
/// Useful for IME.
|
||||||
|
|
@ -113,6 +141,15 @@ pub struct PlatformOutput {
|
||||||
/// Set the cursor to this icon.
|
/// Set the cursor to this icon.
|
||||||
pub cursor_icon: CursorIcon,
|
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.
|
/// Events that may be useful to e.g. a screen reader.
|
||||||
pub events: Vec<OutputEvent>,
|
pub events: Vec<OutputEvent>,
|
||||||
|
|
||||||
|
|
@ -172,6 +209,7 @@ impl PlatformOutput {
|
||||||
let Self {
|
let Self {
|
||||||
mut commands,
|
mut commands,
|
||||||
cursor_icon,
|
cursor_icon,
|
||||||
|
cursor_image,
|
||||||
mut events,
|
mut events,
|
||||||
mutable_text_under_cursor,
|
mutable_text_under_cursor,
|
||||||
ime,
|
ime,
|
||||||
|
|
@ -183,6 +221,7 @@ impl PlatformOutput {
|
||||||
|
|
||||||
self.commands.append(&mut commands);
|
self.commands.append(&mut commands);
|
||||||
self.cursor_icon = cursor_icon;
|
self.cursor_icon = cursor_icon;
|
||||||
|
self.cursor_image = cursor_image;
|
||||||
self.events.append(&mut events);
|
self.events.append(&mut events);
|
||||||
self.mutable_text_under_cursor = mutable_text_under_cursor;
|
self.mutable_text_under_cursor = mutable_text_under_cursor;
|
||||||
self.ime = ime.or(self.ime);
|
self.ime = ime.or(self.ime);
|
||||||
|
|
|
||||||
|
|
@ -488,7 +488,7 @@ pub use self::{
|
||||||
Key, UserData,
|
Key, UserData,
|
||||||
input::*,
|
input::*,
|
||||||
output::{
|
output::{
|
||||||
self, CursorIcon, FullOutput, OpenUrl, OutputCommand, PlatformOutput,
|
self, CursorIcon, CursorImage, FullOutput, OpenUrl, OutputCommand, PlatformOutput,
|
||||||
UserAttentionType, WidgetInfo,
|
UserAttentionType, WidgetInfo,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue