Skip to content

Commit 47c4e30

Browse files
eero-lehtinenalice-i-cecilejanhohenheim
authored
Add custom cursors (#14284)
# Objective - Add custom images as cursors - Fixes #9557 ## Solution - Change cursor type to accommodate both native and image cursors - I don't really like this solution because I couldn't use `Handle<Image>` directly. I would need to import `bevy_assets` and that causes a circular dependency. Alternatively we could use winit's `CustomCursor` smart pointers, but that seems hard because the event loop is needed to create those and is not easily accessable for users. So now I need to copy around rgba buffers which is sad. - I use a cache because especially on the web creating cursor images is really slow - Sorry to #14196 for yoinking, I just wanted to make a quick solution for myself and thought that I should probably share it too. Update: - Now uses `Handle<Image>`, reads rgba data in `bevy_render` and uses resources to send the data to `bevy_winit`, where the final cursors are created. ## Testing - Added example which works fine at least on Linux Wayland (winit side has been tested with all platforms). - I haven't tested if the url cursor works. ## Migration Guide - `CursorIcon` is no longer a field in `Window`, but a separate component can be inserted to a window entity. It has been changed to an enum that can hold custom images in addition to system icons. - `Cursor` is renamed to `CursorOptions` and `cursor` field of `Window` is renamed to `cursor_options` - `CursorIcon` is renamed to `SystemCursorIcon` --------- Co-authored-by: Alice Cecile <[email protected]> Co-authored-by: Jan Hohenheim <[email protected]>
1 parent d4ec80d commit 47c4e30

File tree

16 files changed

+375
-111
lines changed

16 files changed

+375
-111
lines changed

crates/bevy_render/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ bevy_render_macros = { path = "macros", version = "0.15.0-dev" }
5858
bevy_time = { path = "../bevy_time", version = "0.15.0-dev" }
5959
bevy_transform = { path = "../bevy_transform", version = "0.15.0-dev" }
6060
bevy_window = { path = "../bevy_window", version = "0.15.0-dev" }
61+
bevy_winit = { path = "../bevy_winit", version = "0.15.0-dev" }
6162
bevy_utils = { path = "../bevy_utils", version = "0.15.0-dev" }
6263
bevy_tasks = { path = "../bevy_tasks", version = "0.15.0-dev" }
6364

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
use bevy_asset::{AssetId, Assets, Handle};
2+
use bevy_ecs::{
3+
change_detection::DetectChanges,
4+
component::Component,
5+
entity::Entity,
6+
query::With,
7+
reflect::ReflectComponent,
8+
system::{Commands, Local, Query, Res},
9+
world::Ref,
10+
};
11+
use bevy_reflect::{std_traits::ReflectDefault, Reflect};
12+
use bevy_utils::{tracing::warn, HashSet};
13+
use bevy_window::{SystemCursorIcon, Window};
14+
use bevy_winit::{
15+
convert_system_cursor_icon, CursorSource, CustomCursorCache, CustomCursorCacheKey,
16+
PendingCursor,
17+
};
18+
use wgpu::TextureFormat;
19+
20+
use crate::prelude::Image;
21+
22+
/// Insert into a window entity to set the cursor for that window.
23+
#[derive(Component, Debug, Clone, Reflect, PartialEq, Eq)]
24+
#[reflect(Component, Debug, Default)]
25+
pub enum CursorIcon {
26+
/// Custom cursor image.
27+
Custom(CustomCursor),
28+
/// System provided cursor icon.
29+
System(SystemCursorIcon),
30+
}
31+
32+
impl Default for CursorIcon {
33+
fn default() -> Self {
34+
CursorIcon::System(Default::default())
35+
}
36+
}
37+
38+
impl From<SystemCursorIcon> for CursorIcon {
39+
fn from(icon: SystemCursorIcon) -> Self {
40+
CursorIcon::System(icon)
41+
}
42+
}
43+
44+
impl From<CustomCursor> for CursorIcon {
45+
fn from(cursor: CustomCursor) -> Self {
46+
CursorIcon::Custom(cursor)
47+
}
48+
}
49+
50+
/// Custom cursor image data.
51+
#[derive(Debug, Clone, Reflect, PartialEq, Eq, Hash)]
52+
pub enum CustomCursor {
53+
/// Image to use as a cursor.
54+
Image {
55+
/// The image must be in 8 bit int or 32 bit float rgba. PNG images
56+
/// work well for this.
57+
handle: Handle<Image>,
58+
/// X and Y coordinates of the hotspot in pixels. The hotspot must be
59+
/// within the image bounds.
60+
hotspot: (u16, u16),
61+
},
62+
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
63+
/// A URL to an image to use as the cursor.
64+
Url {
65+
/// Web URL to an image to use as the cursor. PNGs preferred. Cursor
66+
/// creation can fail if the image is invalid or not reachable.
67+
url: String,
68+
/// X and Y coordinates of the hotspot in pixels. The hotspot must be
69+
/// within the image bounds.
70+
hotspot: (u16, u16),
71+
},
72+
}
73+
74+
pub fn update_cursors(
75+
mut commands: Commands,
76+
mut windows: Query<(Entity, Ref<CursorIcon>), With<Window>>,
77+
cursor_cache: Res<CustomCursorCache>,
78+
images: Res<Assets<Image>>,
79+
mut queue: Local<HashSet<Entity>>,
80+
) {
81+
for (entity, cursor) in windows.iter_mut() {
82+
if !(queue.remove(&entity) || cursor.is_changed()) {
83+
continue;
84+
}
85+
86+
let cursor_source = match cursor.as_ref() {
87+
CursorIcon::Custom(CustomCursor::Image { handle, hotspot }) => {
88+
let cache_key = match handle.id() {
89+
AssetId::Index { index, .. } => {
90+
CustomCursorCacheKey::AssetIndex(index.to_bits())
91+
}
92+
AssetId::Uuid { uuid } => CustomCursorCacheKey::AssetUuid(uuid.as_u128()),
93+
};
94+
95+
if cursor_cache.0.contains_key(&cache_key) {
96+
CursorSource::CustomCached(cache_key)
97+
} else {
98+
let Some(image) = images.get(handle) else {
99+
warn!(
100+
"Cursor image {handle:?} is not loaded yet and couldn't be used. Trying again next frame."
101+
);
102+
queue.insert(entity);
103+
continue;
104+
};
105+
let Some(rgba) = image_to_rgba_pixels(image) else {
106+
warn!("Cursor image {handle:?} not accepted because it's not rgba8 or rgba32float format");
107+
continue;
108+
};
109+
110+
let width = image.texture_descriptor.size.width;
111+
let height = image.texture_descriptor.size.height;
112+
let source = match bevy_winit::WinitCustomCursor::from_rgba(
113+
rgba,
114+
width as u16,
115+
height as u16,
116+
hotspot.0,
117+
hotspot.1,
118+
) {
119+
Ok(source) => source,
120+
Err(err) => {
121+
warn!("Cursor image {handle:?} is invalid: {err}");
122+
continue;
123+
}
124+
};
125+
126+
CursorSource::Custom((cache_key, source))
127+
}
128+
}
129+
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
130+
CursorIcon::Custom(CustomCursor::Url { url, hotspot }) => {
131+
let cache_key = CustomCursorCacheKey::Url(url.clone());
132+
133+
if cursor_cache.0.contains_key(&cache_key) {
134+
CursorSource::CustomCached(cache_key)
135+
} else {
136+
use bevy_winit::CustomCursorExtWebSys;
137+
let source =
138+
bevy_winit::WinitCustomCursor::from_url(url.clone(), hotspot.0, hotspot.1);
139+
CursorSource::Custom((cache_key, source))
140+
}
141+
}
142+
CursorIcon::System(system_cursor_icon) => {
143+
CursorSource::System(convert_system_cursor_icon(*system_cursor_icon))
144+
}
145+
};
146+
147+
commands
148+
.entity(entity)
149+
.insert(PendingCursor(Some(cursor_source)));
150+
}
151+
}
152+
153+
/// Returns the image data as a `Vec<u8>`.
154+
/// Only supports rgba8 and rgba32float formats.
155+
fn image_to_rgba_pixels(image: &Image) -> Option<Vec<u8>> {
156+
match image.texture_descriptor.format {
157+
TextureFormat::Rgba8Unorm
158+
| TextureFormat::Rgba8UnormSrgb
159+
| TextureFormat::Rgba8Snorm
160+
| TextureFormat::Rgba8Uint
161+
| TextureFormat::Rgba8Sint => Some(image.data.clone()),
162+
TextureFormat::Rgba32Float => Some(
163+
image
164+
.data
165+
.chunks(4)
166+
.map(|chunk| {
167+
let chunk = chunk.try_into().unwrap();
168+
let num = bytemuck::cast_ref::<[u8; 4], f32>(chunk);
169+
(num * 255.0) as u8
170+
})
171+
.collect(),
172+
),
173+
_ => None,
174+
}
175+
}

crates/bevy_render/src/view/window/mod.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ use crate::{
66
texture::TextureFormatPixelInfo,
77
Extract, ExtractSchedule, Render, RenderApp, RenderSet, WgpuWrapper,
88
};
9-
use bevy_app::{App, Plugin};
9+
use bevy_app::{App, Last, Plugin};
1010
use bevy_ecs::{entity::EntityHashMap, prelude::*};
1111
#[cfg(target_os = "linux")]
1212
use bevy_utils::warn_once;
1313
use bevy_utils::{default, tracing::debug, HashSet};
1414
use bevy_window::{
1515
CompositeAlphaMode, PresentMode, PrimaryWindow, RawHandleWrapper, Window, WindowClosing,
1616
};
17+
use bevy_winit::CustomCursorCache;
1718
use std::{
1819
num::NonZeroU32,
1920
ops::{Deref, DerefMut},
@@ -24,17 +25,22 @@ use wgpu::{
2425
TextureViewDescriptor,
2526
};
2627

28+
pub mod cursor;
2729
pub mod screenshot;
2830

2931
use screenshot::{
3032
ScreenshotManager, ScreenshotPlugin, ScreenshotPreparedState, ScreenshotToScreenPipeline,
3133
};
3234

35+
use self::cursor::update_cursors;
36+
3337
pub struct WindowRenderPlugin;
3438

3539
impl Plugin for WindowRenderPlugin {
3640
fn build(&self, app: &mut App) {
37-
app.add_plugins(ScreenshotPlugin);
41+
app.add_plugins(ScreenshotPlugin)
42+
.init_resource::<CustomCursorCache>()
43+
.add_systems(Last, update_cursors);
3844

3945
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
4046
render_app

crates/bevy_window/src/lib.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,27 +15,27 @@ use std::sync::{Arc, Mutex};
1515

1616
use bevy_a11y::Focus;
1717

18-
mod cursor;
1918
mod event;
2019
mod monitor;
2120
mod raw_handle;
2221
mod system;
22+
mod system_cursor;
2323
mod window;
2424

2525
pub use crate::raw_handle::*;
2626

27-
pub use cursor::*;
2827
pub use event::*;
2928
pub use monitor::*;
3029
pub use system::*;
30+
pub use system_cursor::*;
3131
pub use window::*;
3232

3333
#[allow(missing_docs)]
3434
pub mod prelude {
3535
#[allow(deprecated)]
3636
#[doc(hidden)]
3737
pub use crate::{
38-
CursorEntered, CursorIcon, CursorLeft, CursorMoved, FileDragAndDrop, Ime, MonitorSelection,
38+
CursorEntered, CursorLeft, CursorMoved, FileDragAndDrop, Ime, MonitorSelection,
3939
ReceivedCharacter, Window, WindowMoved, WindowPlugin, WindowPosition,
4040
WindowResizeConstraints,
4141
};

crates/bevy_window/src/cursor.rs renamed to crates/bevy_window/src/system_cursor.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ use bevy_reflect::{prelude::ReflectDefault, Reflect};
7373
#[cfg(feature = "serialize")]
7474
use bevy_reflect::{ReflectDeserialize, ReflectSerialize};
7575

76-
/// The icon to display for a [`Window`](crate::window::Window)'s [`Cursor`](crate::window::Cursor).
76+
/// The icon to display for a window.
7777
///
7878
/// Examples of all of these cursors can be found [here](https://www.w3schools.com/cssref/playit.php?filename=playcss_cursor&preval=crosshair).
7979
/// This `enum` is simply a copy of a similar `enum` found in [`winit`](https://docs.rs/winit/latest/winit/window/enum.CursorIcon.html).
@@ -89,7 +89,7 @@ use bevy_reflect::{ReflectDeserialize, ReflectSerialize};
8989
reflect(Serialize, Deserialize)
9090
)]
9191
#[reflect(Debug, PartialEq, Default)]
92-
pub enum CursorIcon {
92+
pub enum SystemCursorIcon {
9393
/// The platform-dependent default cursor. Often rendered as arrow.
9494
#[default]
9595
Default,
@@ -107,7 +107,7 @@ pub enum CursorIcon {
107107
Pointer,
108108

109109
/// A progress indicator. The program is performing some processing, but is
110-
/// different from [`CursorIcon::Wait`] in that the user may still interact
110+
/// different from [`SystemCursorIcon::Wait`] in that the user may still interact
111111
/// with the program.
112112
Progress,
113113

crates/bevy_window/src/window.rs

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ use bevy_reflect::{ReflectDeserialize, ReflectSerialize};
1212

1313
use bevy_utils::tracing::warn;
1414

15-
use crate::CursorIcon;
16-
1715
/// Marker [`Component`] for the window considered the primary window.
1816
///
1917
/// Currently this is assumed to only exist on 1 entity at a time.
@@ -107,16 +105,16 @@ impl NormalizedWindowRef {
107105
///
108106
/// Because this component is synchronized with `winit`, it can be used to perform
109107
/// OS-integrated windowing operations. For example, here's a simple system
110-
/// to change the cursor type:
108+
/// to change the window mode:
111109
///
112110
/// ```
113111
/// # use bevy_ecs::query::With;
114112
/// # use bevy_ecs::system::Query;
115-
/// # use bevy_window::{CursorIcon, PrimaryWindow, Window};
116-
/// fn change_cursor(mut windows: Query<&mut Window, With<PrimaryWindow>>) {
113+
/// # use bevy_window::{WindowMode, PrimaryWindow, Window, MonitorSelection};
114+
/// fn change_window_mode(mut windows: Query<&mut Window, With<PrimaryWindow>>) {
117115
/// // Query returns one window typically.
118116
/// for mut window in windows.iter_mut() {
119-
/// window.cursor.icon = CursorIcon::Wait;
117+
/// window.mode = WindowMode::Fullscreen(MonitorSelection::Current);
120118
/// }
121119
/// }
122120
/// ```
@@ -128,8 +126,9 @@ impl NormalizedWindowRef {
128126
)]
129127
#[reflect(Component, Default)]
130128
pub struct Window {
131-
/// The cursor of this window.
132-
pub cursor: Cursor,
129+
/// The cursor options of this window. Cursor icons are set with the `Cursor` component on the
130+
/// window entity.
131+
pub cursor_options: CursorOptions,
133132
/// What presentation mode to give the window.
134133
pub present_mode: PresentMode,
135134
/// Which fullscreen or windowing mode should be used.
@@ -316,7 +315,7 @@ impl Default for Window {
316315
Self {
317316
title: "App".to_owned(),
318317
name: None,
319-
cursor: Default::default(),
318+
cursor_options: Default::default(),
320319
present_mode: Default::default(),
321320
mode: Default::default(),
322321
position: Default::default(),
@@ -543,23 +542,20 @@ impl WindowResizeConstraints {
543542
}
544543

545544
/// Cursor data for a [`Window`].
546-
#[derive(Debug, Copy, Clone, Reflect)]
545+
#[derive(Debug, Clone, Reflect)]
547546
#[cfg_attr(
548547
feature = "serialize",
549548
derive(serde::Serialize, serde::Deserialize),
550549
reflect(Serialize, Deserialize)
551550
)]
552551
#[reflect(Debug, Default)]
553-
pub struct Cursor {
554-
/// What the cursor should look like while inside the window.
555-
pub icon: CursorIcon,
556-
552+
pub struct CursorOptions {
557553
/// Whether the cursor is visible or not.
558554
///
559555
/// ## Platform-specific
560556
///
561557
/// - **`Windows`**, **`X11`**, and **`Wayland`**: The cursor is hidden only when inside the window.
562-
/// To stop the cursor from leaving the window, change [`Cursor::grab_mode`] to [`CursorGrabMode::Locked`] or [`CursorGrabMode::Confined`]
558+
/// To stop the cursor from leaving the window, change [`CursorOptions::grab_mode`] to [`CursorGrabMode::Locked`] or [`CursorGrabMode::Confined`]
563559
/// - **`macOS`**: The cursor is hidden only when the window is focused.
564560
/// - **`iOS`** and **`Android`** do not have cursors
565561
pub visible: bool,
@@ -583,10 +579,9 @@ pub struct Cursor {
583579
pub hit_test: bool,
584580
}
585581

586-
impl Default for Cursor {
582+
impl Default for CursorOptions {
587583
fn default() -> Self {
588-
Cursor {
589-
icon: CursorIcon::Default,
584+
CursorOptions {
590585
visible: true,
591586
grab_mode: CursorGrabMode::None,
592587
hit_test: true,
@@ -870,7 +865,7 @@ impl From<DVec2> for WindowResolution {
870865
}
871866
}
872867

873-
/// Defines if and how the [`Cursor`] is grabbed by a [`Window`].
868+
/// Defines if and how the cursor is grabbed by a [`Window`].
874869
///
875870
/// ## Platform-specific
876871
///

0 commit comments

Comments
 (0)