bevy_winit/
custom_cursor.rs

1use bevy_app::{App, Plugin};
2use bevy_asset::{Assets, Handle};
3use bevy_image::{Image, TextureAtlas, TextureAtlasLayout, TextureAtlasPlugin};
4use bevy_math::{ops, Rect, URect, UVec2, Vec2};
5use bevy_reflect::{std_traits::ReflectDefault, Reflect};
6use wgpu_types::TextureFormat;
7
8use crate::{cursor::CursorIcon, state::CustomCursorCache};
9
10/// A custom cursor created from an image.
11#[derive(Debug, Clone, Default, Reflect, PartialEq, Eq, Hash)]
12#[reflect(Debug, Default, Hash, PartialEq, Clone)]
13pub struct CustomCursorImage {
14    /// Handle to the image to use as the cursor. The image must be in 8 bit int
15    /// or 32 bit float rgba. PNG images work well for this.
16    pub handle: Handle<Image>,
17    /// An optional texture atlas used to render the image.
18    pub texture_atlas: Option<TextureAtlas>,
19    /// Whether the image should be flipped along its x-axis.
20    ///
21    /// If true, the cursor's `hotspot` automatically flips along with the
22    /// image.
23    pub flip_x: bool,
24    /// Whether the image should be flipped along its y-axis.
25    ///
26    /// If true, the cursor's `hotspot` automatically flips along with the
27    /// image.
28    pub flip_y: bool,
29    /// An optional rectangle representing the region of the image to render,
30    /// instead of rendering the full image. This is an easy one-off alternative
31    /// to using a [`TextureAtlas`].
32    ///
33    /// When used with a [`TextureAtlas`], the rect is offset by the atlas's
34    /// minimal (top-left) corner position.
35    pub rect: Option<URect>,
36    /// X and Y coordinates of the hotspot in pixels. The hotspot must be within
37    /// the image bounds.
38    ///
39    /// If you are flipping the image using `flip_x` or `flip_y`, you don't need
40    /// to adjust this field to account for the flip because it is adjusted
41    /// automatically.
42    pub hotspot: (u16, u16),
43}
44
45#[cfg(all(target_family = "wasm", target_os = "unknown"))]
46/// A custom cursor created from a URL.
47#[derive(Debug, Clone, Default, Reflect, PartialEq, Eq, Hash)]
48#[reflect(Debug, Default, Hash, PartialEq, Clone)]
49pub struct CustomCursorUrl {
50    /// Web URL to an image to use as the cursor. PNGs are preferred. Cursor
51    /// creation can fail if the image is invalid or not reachable.
52    pub url: String,
53    /// X and Y coordinates of the hotspot in pixels. The hotspot must be within
54    /// the image bounds.
55    pub hotspot: (u16, u16),
56}
57
58/// Custom cursor image data.
59#[derive(Debug, Clone, Reflect, PartialEq, Eq, Hash)]
60#[reflect(Clone, PartialEq, Hash)]
61pub enum CustomCursor {
62    /// Use an image as the cursor.
63    Image(CustomCursorImage),
64    #[cfg(all(target_family = "wasm", target_os = "unknown"))]
65    /// Use a URL to an image as the cursor.
66    Url(CustomCursorUrl),
67}
68
69impl From<CustomCursor> for CursorIcon {
70    fn from(cursor: CustomCursor) -> Self {
71        CursorIcon::Custom(cursor)
72    }
73}
74
75/// Adds support for custom cursors.
76pub(crate) struct CustomCursorPlugin;
77
78impl Plugin for CustomCursorPlugin {
79    fn build(&self, app: &mut App) {
80        if !app.is_plugin_added::<TextureAtlasPlugin>() {
81            app.add_plugins(TextureAtlasPlugin);
82        }
83
84        app.init_resource::<CustomCursorCache>();
85    }
86}
87
88/// Determines the effective rect and returns it along with a flag to indicate
89/// whether a sub-image operation is needed. The flag allows the caller to
90/// determine whether the image data needs a sub-image extracted from it. Note:
91/// To avoid lossy comparisons between [`Rect`] and [`URect`], the flag is
92/// always set to `true` when a [`TextureAtlas`] is used.
93#[inline(always)]
94pub(crate) fn calculate_effective_rect(
95    texture_atlas_layouts: &Assets<TextureAtlasLayout>,
96    image: &Image,
97    texture_atlas: &Option<TextureAtlas>,
98    rect: &Option<URect>,
99) -> (Rect, bool) {
100    let atlas_rect = texture_atlas
101        .as_ref()
102        .and_then(|s| s.texture_rect(texture_atlas_layouts))
103        .map(|r| r.as_rect());
104
105    match (atlas_rect, rect) {
106        (None, None) => (
107            Rect {
108                min: Vec2::ZERO,
109                max: Vec2::new(
110                    image.texture_descriptor.size.width as f32,
111                    image.texture_descriptor.size.height as f32,
112                ),
113            },
114            false,
115        ),
116        (None, Some(image_rect)) => (
117            image_rect.as_rect(),
118            image_rect
119                != &URect {
120                    min: UVec2::ZERO,
121                    max: UVec2::new(
122                        image.texture_descriptor.size.width,
123                        image.texture_descriptor.size.height,
124                    ),
125                },
126        ),
127        (Some(atlas_rect), None) => (atlas_rect, true),
128        (Some(atlas_rect), Some(image_rect)) => (
129            {
130                let mut image_rect = image_rect.as_rect();
131                image_rect.min += atlas_rect.min;
132                image_rect.max += atlas_rect.min;
133                image_rect
134            },
135            true,
136        ),
137    }
138}
139
140/// Extracts the RGBA pixel data from `image`, converting it if necessary.
141///
142/// Only supports rgba8 and rgba32float formats.
143pub(crate) fn extract_rgba_pixels(image: &Image) -> Option<Vec<u8>> {
144    match image.texture_descriptor.format {
145        TextureFormat::Rgba8Unorm
146        | TextureFormat::Rgba8UnormSrgb
147        | TextureFormat::Rgba8Snorm
148        | TextureFormat::Rgba8Uint
149        | TextureFormat::Rgba8Sint => Some(image.data.clone()?),
150        TextureFormat::Rgba32Float => image.data.as_ref().map(|data| {
151            data.chunks(4)
152                .map(|chunk| {
153                    let chunk = chunk.try_into().unwrap();
154                    let num = bytemuck::cast_ref::<[u8; 4], f32>(chunk);
155                    ops::round(num.clamp(0.0, 1.0) * 255.0) as u8
156                })
157                .collect()
158        }),
159        _ => None,
160    }
161}
162
163/// Returns the `image` data as a `Vec<u8>` for the specified sub-region.
164///
165/// The image is flipped along the x and y axes if `flip_x` and `flip_y` are
166/// `true`, respectively.
167///
168/// Only supports rgba8 and rgba32float formats.
169pub(crate) fn extract_and_transform_rgba_pixels(
170    image: &Image,
171    flip_x: bool,
172    flip_y: bool,
173    rect: Rect,
174) -> Option<Vec<u8>> {
175    let image_data = extract_rgba_pixels(image)?;
176
177    let width = rect.width() as usize;
178    let height = rect.height() as usize;
179    let mut sub_image_data = Vec::with_capacity(width * height * 4); // assuming 4 bytes per pixel (RGBA8)
180
181    for y in 0..height {
182        for x in 0..width {
183            let src_x = if flip_x { width - 1 - x } else { x };
184            let src_y = if flip_y { height - 1 - y } else { y };
185            let index = ((rect.min.y as usize + src_y)
186                * image.texture_descriptor.size.width as usize
187                + (rect.min.x as usize + src_x))
188                * 4;
189            sub_image_data.extend_from_slice(&image_data[index..index + 4]);
190        }
191    }
192
193    Some(sub_image_data)
194}
195
196/// Transforms the `hotspot` coordinates based on whether the image is flipped
197/// or not. The `rect` is used to determine the image's dimensions.
198pub(crate) fn transform_hotspot(
199    hotspot: (u16, u16),
200    flip_x: bool,
201    flip_y: bool,
202    rect: Rect,
203) -> (u16, u16) {
204    let hotspot_x = hotspot.0 as f32;
205    let hotspot_y = hotspot.1 as f32;
206
207    let (width, height) = (rect.width(), rect.height());
208
209    let hotspot_x = if flip_x {
210        (width - 1.0).max(0.0) - hotspot_x
211    } else {
212        hotspot_x
213    };
214    let hotspot_y = if flip_y {
215        (height - 1.0).max(0.0) - hotspot_y
216    } else {
217        hotspot_y
218    };
219
220    (hotspot_x as u16, hotspot_y as u16)
221}
222
223#[cfg(test)]
224mod tests {
225    use bevy_app::App;
226    use bevy_asset::RenderAssetUsages;
227    use bevy_image::Image;
228    use bevy_math::Rect;
229    use bevy_math::Vec2;
230    use wgpu_types::{Extent3d, TextureDimension};
231
232    use super::*;
233
234    fn create_image_rgba8(data: &[u8]) -> Image {
235        Image::new(
236            Extent3d {
237                width: 3,
238                height: 3,
239                depth_or_array_layers: 1,
240            },
241            TextureDimension::D2,
242            data.to_vec(),
243            TextureFormat::Rgba8UnormSrgb,
244            RenderAssetUsages::default(),
245        )
246    }
247
248    macro_rules! test_calculate_effective_rect {
249        ($name:ident, $use_texture_atlas:expr, $rect:expr, $expected_rect:expr, $expected_needs_sub_image:expr) => {
250            #[test]
251            fn $name() {
252                let mut app = App::new();
253                let mut texture_atlas_layout_assets = Assets::<TextureAtlasLayout>::default();
254
255                // Create a simple 3x3 texture atlas layout for the test cases
256                // that use a texture atlas. In the future we could adjust the
257                // test cases to use different texture atlas layouts.
258                let layout = TextureAtlasLayout::from_grid(UVec2::new(3, 3), 1, 1, None, None);
259                let layout_handle = texture_atlas_layout_assets.add(layout);
260
261                app.insert_resource(texture_atlas_layout_assets);
262
263                let texture_atlases = app
264                    .world()
265                    .get_resource::<Assets<TextureAtlasLayout>>()
266                    .unwrap();
267
268                let image = create_image_rgba8(&[0; 3 * 3 * 4]); // 3x3 image
269
270                let texture_atlas = if $use_texture_atlas {
271                    Some(TextureAtlas::from(layout_handle))
272                } else {
273                    None
274                };
275
276                let rect = $rect;
277
278                let (result_rect, needs_sub_image) =
279                    calculate_effective_rect(&texture_atlases, &image, &texture_atlas, &rect);
280
281                assert_eq!(result_rect, $expected_rect);
282                assert_eq!(needs_sub_image, $expected_needs_sub_image);
283            }
284        };
285    }
286
287    test_calculate_effective_rect!(
288        no_texture_atlas_no_rect,
289        false,
290        None,
291        Rect {
292            min: Vec2::ZERO,
293            max: Vec2::new(3.0, 3.0)
294        },
295        false
296    );
297
298    test_calculate_effective_rect!(
299        no_texture_atlas_with_partial_rect,
300        false,
301        Some(URect {
302            min: UVec2::new(1, 1),
303            max: UVec2::new(3, 3)
304        }),
305        Rect {
306            min: Vec2::new(1.0, 1.0),
307            max: Vec2::new(3.0, 3.0)
308        },
309        true
310    );
311
312    test_calculate_effective_rect!(
313        no_texture_atlas_with_full_rect,
314        false,
315        Some(URect {
316            min: UVec2::ZERO,
317            max: UVec2::new(3, 3)
318        }),
319        Rect {
320            min: Vec2::ZERO,
321            max: Vec2::new(3.0, 3.0)
322        },
323        false
324    );
325
326    test_calculate_effective_rect!(
327        texture_atlas_no_rect,
328        true,
329        None,
330        Rect {
331            min: Vec2::ZERO,
332            max: Vec2::new(3.0, 3.0)
333        },
334        true // always needs sub-image to avoid comparing Rect against URect
335    );
336
337    test_calculate_effective_rect!(
338        texture_atlas_rect,
339        true,
340        Some(URect {
341            min: UVec2::ZERO,
342            max: UVec2::new(3, 3)
343        }),
344        Rect {
345            min: Vec2::new(0.0, 0.0),
346            max: Vec2::new(3.0, 3.0)
347        },
348        true // always needs sub-image to avoid comparing Rect against URect
349    );
350
351    fn create_image_rgba32float(data: &[u8]) -> Image {
352        let float_data: Vec<f32> = data
353            .chunks(4)
354            .flat_map(|chunk| {
355                chunk
356                    .iter()
357                    .map(|&x| x as f32 / 255.0) // convert each channel to f32
358                    .collect::<Vec<f32>>()
359            })
360            .collect();
361
362        Image::new(
363            Extent3d {
364                width: 3,
365                height: 3,
366                depth_or_array_layers: 1,
367            },
368            TextureDimension::D2,
369            bytemuck::cast_slice(&float_data).to_vec(),
370            TextureFormat::Rgba32Float,
371            RenderAssetUsages::default(),
372        )
373    }
374
375    macro_rules! test_extract_and_transform_rgba_pixels {
376        ($name:ident, $flip_x:expr, $flip_y:expr, $rect:expr, $expected:expr) => {
377            #[test]
378            fn $name() {
379                let image_data: &[u8] = &[
380                    // Row 1: Red, Green, Blue
381                    255, 0, 0, 255, // Red
382                    0, 255, 0, 255, // Green
383                    0, 0, 255, 255, // Blue
384                    // Row 2: Yellow, Cyan, Magenta
385                    255, 255, 0, 255, // Yellow
386                    0, 255, 255, 255, // Cyan
387                    255, 0, 255, 255, // Magenta
388                    // Row 3: White, Gray, Black
389                    255, 255, 255, 255, // White
390                    128, 128, 128, 255, // Gray
391                    0, 0, 0, 255, // Black
392                ];
393
394                // RGBA8 test
395                {
396                    let image = create_image_rgba8(image_data);
397                    let rect = $rect;
398                    let result = extract_and_transform_rgba_pixels(&image, $flip_x, $flip_y, rect);
399                    assert_eq!(result, Some($expected.to_vec()));
400                }
401
402                // RGBA32Float test
403                {
404                    let image = create_image_rgba32float(image_data);
405                    let rect = $rect;
406                    let result = extract_and_transform_rgba_pixels(&image, $flip_x, $flip_y, rect);
407                    assert_eq!(result, Some($expected.to_vec()));
408                }
409            }
410        };
411    }
412
413    test_extract_and_transform_rgba_pixels!(
414        no_flip_full_image,
415        false,
416        false,
417        Rect {
418            min: Vec2::ZERO,
419            max: Vec2::new(3.0, 3.0)
420        },
421        [
422            // Row 1: Red, Green, Blue
423            255, 0, 0, 255, // Red
424            0, 255, 0, 255, // Green
425            0, 0, 255, 255, // Blue
426            // Row 2: Yellow, Cyan, Magenta
427            255, 255, 0, 255, // Yellow
428            0, 255, 255, 255, // Cyan
429            255, 0, 255, 255, // Magenta
430            // Row 3: White, Gray, Black
431            255, 255, 255, 255, // White
432            128, 128, 128, 255, // Gray
433            0, 0, 0, 255, // Black
434        ]
435    );
436
437    test_extract_and_transform_rgba_pixels!(
438        flip_x_full_image,
439        true,
440        false,
441        Rect {
442            min: Vec2::ZERO,
443            max: Vec2::new(3.0, 3.0)
444        },
445        [
446            // Row 1 flipped: Blue, Green, Red
447            0, 0, 255, 255, // Blue
448            0, 255, 0, 255, // Green
449            255, 0, 0, 255, // Red
450            // Row 2 flipped: Magenta, Cyan, Yellow
451            255, 0, 255, 255, // Magenta
452            0, 255, 255, 255, // Cyan
453            255, 255, 0, 255, // Yellow
454            // Row 3 flipped: Black, Gray, White
455            0, 0, 0, 255, // Black
456            128, 128, 128, 255, // Gray
457            255, 255, 255, 255, // White
458        ]
459    );
460
461    test_extract_and_transform_rgba_pixels!(
462        flip_y_full_image,
463        false,
464        true,
465        Rect {
466            min: Vec2::ZERO,
467            max: Vec2::new(3.0, 3.0)
468        },
469        [
470            // Row 3: White, Gray, Black
471            255, 255, 255, 255, // White
472            128, 128, 128, 255, // Gray
473            0, 0, 0, 255, // Black
474            // Row 2: Yellow, Cyan, Magenta
475            255, 255, 0, 255, // Yellow
476            0, 255, 255, 255, // Cyan
477            255, 0, 255, 255, // Magenta
478            // Row 1: Red, Green, Blue
479            255, 0, 0, 255, // Red
480            0, 255, 0, 255, // Green
481            0, 0, 255, 255, // Blue
482        ]
483    );
484
485    test_extract_and_transform_rgba_pixels!(
486        flip_both_full_image,
487        true,
488        true,
489        Rect {
490            min: Vec2::ZERO,
491            max: Vec2::new(3.0, 3.0)
492        },
493        [
494            // Row 3 flipped: Black, Gray, White
495            0, 0, 0, 255, // Black
496            128, 128, 128, 255, // Gray
497            255, 255, 255, 255, // White
498            // Row 2 flipped: Magenta, Cyan, Yellow
499            255, 0, 255, 255, // Magenta
500            0, 255, 255, 255, // Cyan
501            255, 255, 0, 255, // Yellow
502            // Row 1 flipped: Blue, Green, Red
503            0, 0, 255, 255, // Blue
504            0, 255, 0, 255, // Green
505            255, 0, 0, 255, // Red
506        ]
507    );
508
509    test_extract_and_transform_rgba_pixels!(
510        no_flip_rect,
511        false,
512        false,
513        Rect {
514            min: Vec2::new(1.0, 1.0),
515            max: Vec2::new(3.0, 3.0)
516        },
517        [
518            // Only includes part of the original image (sub-rectangle)
519            // Row 2, columns 2-3: Cyan, Magenta
520            0, 255, 255, 255, // Cyan
521            255, 0, 255, 255, // Magenta
522            // Row 3, columns 2-3: Gray, Black
523            128, 128, 128, 255, // Gray
524            0, 0, 0, 255, // Black
525        ]
526    );
527
528    test_extract_and_transform_rgba_pixels!(
529        flip_x_rect,
530        true,
531        false,
532        Rect {
533            min: Vec2::new(1.0, 1.0),
534            max: Vec2::new(3.0, 3.0)
535        },
536        [
537            // Row 2 flipped: Magenta, Cyan
538            255, 0, 255, 255, // Magenta
539            0, 255, 255, 255, // Cyan
540            // Row 3 flipped: Black, Gray
541            0, 0, 0, 255, // Black
542            128, 128, 128, 255, // Gray
543        ]
544    );
545
546    test_extract_and_transform_rgba_pixels!(
547        flip_y_rect,
548        false,
549        true,
550        Rect {
551            min: Vec2::new(1.0, 1.0),
552            max: Vec2::new(3.0, 3.0)
553        },
554        [
555            // Row 3 first: Gray, Black
556            128, 128, 128, 255, // Gray
557            0, 0, 0, 255, // Black
558            // Row 2 second: Cyan, Magenta
559            0, 255, 255, 255, // Cyan
560            255, 0, 255, 255, // Magenta
561        ]
562    );
563
564    test_extract_and_transform_rgba_pixels!(
565        flip_both_rect,
566        true,
567        true,
568        Rect {
569            min: Vec2::new(1.0, 1.0),
570            max: Vec2::new(3.0, 3.0)
571        },
572        [
573            // Row 3 flipped: Black, Gray
574            0, 0, 0, 255, // Black
575            128, 128, 128, 255, // Gray
576            // Row 2 flipped: Magenta, Cyan
577            255, 0, 255, 255, // Magenta
578            0, 255, 255, 255, // Cyan
579        ]
580    );
581
582    #[test]
583    fn test_transform_hotspot() {
584        fn test(hotspot: (u16, u16), flip_x: bool, flip_y: bool, rect: Rect, expected: (u16, u16)) {
585            let transformed = transform_hotspot(hotspot, flip_x, flip_y, rect);
586            assert_eq!(transformed, expected);
587
588            // Round-trip test: Applying the same transformation again should
589            // reverse it.
590            let transformed = transform_hotspot(transformed, flip_x, flip_y, rect);
591            assert_eq!(transformed, hotspot);
592        }
593
594        let rect = Rect {
595            min: Vec2::ZERO,
596            max: Vec2::new(100.0, 200.0),
597        };
598
599        test((10, 20), false, false, rect, (10, 20)); // no flip
600        test((10, 20), true, false, rect, (89, 20)); // flip X
601        test((10, 20), false, true, rect, (10, 179)); // flip Y
602        test((10, 20), true, true, rect, (89, 179)); // flip both
603        test((0, 0), true, true, rect, (99, 199)); // flip both (bounds check)
604    }
605}