1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 22:53:14 -04:00

Add Context::set_cursor_image for OS-level custom cursors (#8155)

## What

Adds a way for apps to push an RGBA bitmap as the OS cursor — the
missing companion to `Context::set_cursor_icon`. The integration
translates it into a real `winit::CustomCursor`, so the cursor is drawn
by the compositor and can extend past the egui window edge like any
native cursor.

## Why

Apps with custom-shaped windows (Winamp-style skins, themed launchers,
kiosk apps) currently have no clean way to display a custom cursor:
- `CursorIcon` is limited to the standard system enum.
- Painting the cursor sprite via `egui::Painter` works inside the canvas
but gets clipped at the window edge — the bottom/right of the cursor
disappears the moment the pointer is near the boundary, and there's no
way to render onto the desktop area exposed by a
transparent/region-shaped window.

`winit` 0.30+ already supports `CustomCursor::from_rgba` +
`ActiveEventLoop::create_custom_cursor`, but `egui-winit` doesn't
surface it. This PR exposes it through egui.

### Visual demonstration

Driving use case: a Winamp WSZ skin player
([all3f0r1/oneamp](https://github.com/all3f0r1/oneamp)) with a
transparent + region-shaped window where the skin ships its own `.cur`
files. The bottom-right corner of the playlist exposes the resize cursor
— notice how it gets clipped at the window edge in the painter-based
approach.

| Before (cursor painted via `egui::Painter`) | After (cursor pushed via
`set_cursor_image`) |
| --- | --- |
| ![cursor clipped at the bottom-right of the playlist
window](https://raw.githubusercontent.com/all3f0r1/egui/pr-assets/cursor-clipping-before.png)
| ![cursor extends cleanly past the window edge onto the
desktop](https://raw.githubusercontent.com/all3f0r1/egui/pr-assets/cursor-clipping-after.png)
|

## API

```rust
// new in egui::data::output
pub struct CustomCursorImage {
    pub rgba: std::sync::Arc<[u8]>,
    pub size: [u16; 2],     // matches winit's u16 to avoid lossy casts
    pub hotspot: [u16; 2],
}

// new field on PlatformOutput (skipped from serde — ephemeral)
pub cursor_image: Option<CustomCursorImage>,

// new method on Context
ctx.set_cursor_image(Some(image)); // overrides cursor_icon for this frame
ctx.set_cursor_image(None);        // revert to cursor_icon
```

`Arc<[u8]>` is intentional: the integration dedupes by `Arc::as_ptr`, so
reusing the same Arc across frames means the bitmap is only uploaded to
the OS once per skin, not once per frame.

## Integration changes

- `egui_winit::State::handle_platform_output_with_event_loop(window,
Option<&ActiveEventLoop>, ...)` is a new method that threads the active
event loop so it can call `event_loop.create_custom_cursor(...)`.
- The legacy `handle_platform_output(window, ...)` delegates with `None`
and silently drops `cursor_image`. **No existing callers break.**
- The icon and bitmap paths are unified in a private `apply_cursor`. The
no-flicker dedupe of the old `set_cursor_icon` is preserved on both
paths.
- If `CustomCursor::from_rgba` rejects the bitmap (bad dimensions,
hotspot OOB, etc.), we log a warning and fall back to the icon path.
- eframe's wgpu + glow integrations thread `&ActiveEventLoop` through
`run_ui_and_paint` (glow already had it; wgpu needed one extra
parameter) and call the new method.
- Immediate viewports keep the old path because they're invoked from a
`Context` callback that doesn't have an event loop reference. Custom
cursors are a no-op in immediate viewports — acceptable since they're a
niche path.

## Fallback semantics

| backend / context              | what happens                  |
|--------------------------------|-------------------------------|
| eframe wgpu/glow main viewport | bitmap displayed via OS       |
| eframe immediate viewport      | falls back to `cursor_icon`   |
| eframe web                     | falls back to `cursor_icon`   |
| custom integrations not opted in | falls back to `cursor_icon` |
| `from_rgba` returns `BadImage` | warning + falls back to icon  |

## Verification

- `cargo fmt --all -- --check` 
- `cargo clippy -p egui -p egui-winit -p eframe --all-targets
--all-features -- -D warnings` 
- `cargo doc --lib --no-deps -p egui -p egui-winit -p eframe
--all-features` 
- `cargo check -p egui --no-default-features --features serde` 
(validates the `serde(skip)` on `cursor_image`)
- Interactive validation on Linux/Wayland with the OneAmp WSZ skin
player — see screenshots above.

I haven't run the full snapshot test suite (`scripts/check.sh`) because
we're on Linux and the snapshots are macOS-rendered — happy to run it if
you'd like.

## Notes

Drafted per the contributing guide ("open a draft PR, you may get
helpful feedback early"). Open to design feedback on:
1. Whether `CustomCursorImage` should live in `egui::viewport` rather
than `egui::data::output`.
2. Whether the legacy `handle_platform_output` should grow `event_loop`
directly (breaking) instead of getting a sibling method (non-breaking,
what I did).
3. Whether to also wire it through eframe-web (probably not —
`wasm-bindgen-cursor` would need its own path).

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
all3f0r1
2026-05-22 13:31:52 +00:00
committed by GitHub
parent e925a41419
commit dcefb2e3b8
7 changed files with 189 additions and 23 deletions

View File

@@ -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);

View File

@@ -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<EventResult> {
fn run_ui_and_paint(
&mut self,
window_id: WindowId,
event_loop: &ActiveEventLoop,
) -> Result<EventResult> {
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);

View File

@@ -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

View File

@@ -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<egui::CursorIcon>,
/// 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::<u8>() 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);
}
}
}

View File

@@ -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<crate::CustomCursorImage>) {
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) {

View File

@@ -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<CustomCursorImage>,
/// Events that may be useful to e.g. a screen reader.
pub events: Vec<OutputEvent>,
@@ -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.

View File

@@ -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,