1
0
mirror of https://github.com/emilk/egui.git synced 2026-09-01 06:10:06 -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

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