bevy_winit/
cursor.rs

1//! Components to customize winit cursor
2
3use crate::{
4    converters::convert_system_cursor_icon,
5    state::{CursorSource, PendingCursor},
6};
7#[cfg(feature = "custom_cursor")]
8use crate::{
9    state::{CustomCursorCache, CustomCursorCacheKey},
10    WinitCustomCursor,
11};
12use bevy_app::{App, Last, Plugin};
13#[cfg(feature = "custom_cursor")]
14use bevy_asset::{Assets, Handle};
15#[cfg(feature = "custom_cursor")]
16use bevy_ecs::system::Res;
17use bevy_ecs::{
18    change_detection::DetectChanges,
19    component::Component,
20    entity::Entity,
21    observer::Trigger,
22    query::With,
23    reflect::ReflectComponent,
24    system::{Commands, Local, Query},
25    world::{OnRemove, Ref},
26};
27#[cfg(feature = "custom_cursor")]
28use bevy_image::Image;
29use bevy_reflect::{std_traits::ReflectDefault, Reflect};
30#[cfg(feature = "custom_cursor")]
31use bevy_utils::tracing::warn;
32use bevy_utils::HashSet;
33use bevy_window::{SystemCursorIcon, Window};
34#[cfg(feature = "custom_cursor")]
35use wgpu_types::TextureFormat;
36
37pub(crate) struct CursorPlugin;
38
39impl Plugin for CursorPlugin {
40    fn build(&self, app: &mut App) {
41        #[cfg(feature = "custom_cursor")]
42        app.init_resource::<CustomCursorCache>();
43
44        app.register_type::<CursorIcon>()
45            .add_systems(Last, update_cursors);
46
47        app.add_observer(on_remove_cursor_icon);
48    }
49}
50
51/// Insert into a window entity to set the cursor for that window.
52#[derive(Component, Debug, Clone, Reflect, PartialEq, Eq)]
53#[reflect(Component, Debug, Default, PartialEq)]
54pub enum CursorIcon {
55    #[cfg(feature = "custom_cursor")]
56    /// Custom cursor image.
57    Custom(CustomCursor),
58    /// System provided cursor icon.
59    System(SystemCursorIcon),
60}
61
62impl Default for CursorIcon {
63    fn default() -> Self {
64        CursorIcon::System(Default::default())
65    }
66}
67
68impl From<SystemCursorIcon> for CursorIcon {
69    fn from(icon: SystemCursorIcon) -> Self {
70        CursorIcon::System(icon)
71    }
72}
73
74#[cfg(feature = "custom_cursor")]
75impl From<CustomCursor> for CursorIcon {
76    fn from(cursor: CustomCursor) -> Self {
77        CursorIcon::Custom(cursor)
78    }
79}
80
81#[cfg(feature = "custom_cursor")]
82/// Custom cursor image data.
83#[derive(Debug, Clone, Reflect, PartialEq, Eq, Hash)]
84pub enum CustomCursor {
85    /// Image to use as a cursor.
86    Image {
87        /// The image must be in 8 bit int or 32 bit float rgba. PNG images
88        /// work well for this.
89        handle: Handle<Image>,
90        /// X and Y coordinates of the hotspot in pixels. The hotspot must be
91        /// within the image bounds.
92        hotspot: (u16, u16),
93    },
94    #[cfg(all(target_family = "wasm", target_os = "unknown"))]
95    /// A URL to an image to use as the cursor.
96    Url {
97        /// Web URL to an image to use as the cursor. PNGs preferred. Cursor
98        /// creation can fail if the image is invalid or not reachable.
99        url: String,
100        /// X and Y coordinates of the hotspot in pixels. The hotspot must be
101        /// within the image bounds.
102        hotspot: (u16, u16),
103    },
104}
105
106fn update_cursors(
107    mut commands: Commands,
108    windows: Query<(Entity, Ref<CursorIcon>), With<Window>>,
109    #[cfg(feature = "custom_cursor")] cursor_cache: Res<CustomCursorCache>,
110    #[cfg(feature = "custom_cursor")] images: Res<Assets<Image>>,
111    mut queue: Local<HashSet<Entity>>,
112) {
113    for (entity, cursor) in windows.iter() {
114        if !(queue.remove(&entity) || cursor.is_changed()) {
115            continue;
116        }
117
118        let cursor_source = match cursor.as_ref() {
119            #[cfg(feature = "custom_cursor")]
120            CursorIcon::Custom(CustomCursor::Image { handle, hotspot }) => {
121                let cache_key = CustomCursorCacheKey::Asset(handle.id());
122
123                if cursor_cache.0.contains_key(&cache_key) {
124                    CursorSource::CustomCached(cache_key)
125                } else {
126                    let Some(image) = images.get(handle) else {
127                        warn!(
128                            "Cursor image {handle:?} is not loaded yet and couldn't be used. Trying again next frame."
129                        );
130                        queue.insert(entity);
131                        continue;
132                    };
133                    let Some(rgba) = image_to_rgba_pixels(image) else {
134                        warn!("Cursor image {handle:?} not accepted because it's not rgba8 or rgba32float format");
135                        continue;
136                    };
137
138                    let width = image.texture_descriptor.size.width;
139                    let height = image.texture_descriptor.size.height;
140                    let source = match WinitCustomCursor::from_rgba(
141                        rgba,
142                        width as u16,
143                        height as u16,
144                        hotspot.0,
145                        hotspot.1,
146                    ) {
147                        Ok(source) => source,
148                        Err(err) => {
149                            warn!("Cursor image {handle:?} is invalid: {err}");
150                            continue;
151                        }
152                    };
153
154                    CursorSource::Custom((cache_key, source))
155                }
156            }
157            #[cfg(all(
158                feature = "custom_cursor",
159                target_family = "wasm",
160                target_os = "unknown"
161            ))]
162            CursorIcon::Custom(CustomCursor::Url { url, hotspot }) => {
163                let cache_key = CustomCursorCacheKey::Url(url.clone());
164
165                if cursor_cache.0.contains_key(&cache_key) {
166                    CursorSource::CustomCached(cache_key)
167                } else {
168                    use crate::CustomCursorExtWebSys;
169                    let source = WinitCustomCursor::from_url(url.clone(), hotspot.0, hotspot.1);
170                    CursorSource::Custom((cache_key, source))
171                }
172            }
173            CursorIcon::System(system_cursor_icon) => {
174                CursorSource::System(convert_system_cursor_icon(*system_cursor_icon))
175            }
176        };
177
178        commands
179            .entity(entity)
180            .insert(PendingCursor(Some(cursor_source)));
181    }
182}
183
184/// Resets the cursor to the default icon when `CursorIcon` is removed.
185fn on_remove_cursor_icon(trigger: Trigger<OnRemove, CursorIcon>, mut commands: Commands) {
186    // Use `try_insert` to avoid panic if the window is being destroyed.
187    commands
188        .entity(trigger.entity())
189        .try_insert(PendingCursor(Some(CursorSource::System(
190            convert_system_cursor_icon(SystemCursorIcon::Default),
191        ))));
192}
193
194#[cfg(feature = "custom_cursor")]
195/// Returns the image data as a `Vec<u8>`.
196/// Only supports rgba8 and rgba32float formats.
197fn image_to_rgba_pixels(image: &Image) -> Option<Vec<u8>> {
198    match image.texture_descriptor.format {
199        TextureFormat::Rgba8Unorm
200        | TextureFormat::Rgba8UnormSrgb
201        | TextureFormat::Rgba8Snorm
202        | TextureFormat::Rgba8Uint
203        | TextureFormat::Rgba8Sint => Some(image.data.clone()),
204        TextureFormat::Rgba32Float => Some(
205            image
206                .data
207                .chunks(4)
208                .map(|chunk| {
209                    let chunk = chunk.try_into().unwrap();
210                    let num = bytemuck::cast_ref::<[u8; 4], f32>(chunk);
211                    (num * 255.0) as u8
212                })
213                .collect(),
214        ),
215        _ => None,
216    }
217}