1
0
mirror of https://github.com/emilk/egui.git synced 2026-08-29 04:40:03 -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

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