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`) | | --- | --- | |  |  | ## 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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user