Skip to main content

cranpose_ui_graphics/
pointer_icon.rs

1//! The mouse pointer's appearance: the shape a platform draws under the
2//! pointing device while it hovers a region of the UI.
3//!
4//! A [`PointerIcon`] is either one of the standard shapes every windowing
5//! system and browser already knows ([`CursorIcon`], the CSS cursor
6//! vocabulary) or a [`CustomPointerIcon`] the application draws itself from an
7//! [`ImageBitmap`] and a hotspot. Applications attach one to a region with
8//! `Modifier::pointer_icon`; the shell resolves the topmost hovered region's
9//! icon and the platform layer applies it to the window (winit on desktop, the
10//! canvas's CSS `cursor` on the web). Platforms with no pointing device —
11//! Android and iOS — ignore it.
12
13#[doc(inline)]
14pub use cursor_icon::CursorIcon;
15
16use crate::ImageBitmap;
17
18/// The largest custom pointer icon a platform is asked to draw, in pixels.
19///
20/// Windowing systems reject or silently drop oversized cursors (browsers
21/// commonly cap at 128x128), so a bitmap wider or taller than this is refused
22/// when the icon is built rather than at the point where it would fail to
23/// appear.
24pub const MAX_POINTER_ICON_SIZE: u32 = 128;
25
26/// Errors returned while constructing a [`CustomPointerIcon`].
27#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
28pub enum PointerIconError {
29    /// The bitmap is larger than [`MAX_POINTER_ICON_SIZE`] in one dimension.
30    #[error(
31        "pointer icon is {width}x{height}, larger than the {MAX_POINTER_ICON_SIZE}px platform limit"
32    )]
33    TooLarge {
34        /// The rejected bitmap's width in pixels.
35        width: u32,
36        /// The rejected bitmap's height in pixels.
37        height: u32,
38    },
39    /// The hotspot lies outside the bitmap.
40    #[error("pointer icon hotspot ({x},{y}) lies outside its {width}x{height} bitmap")]
41    HotspotOutsideBitmap {
42        /// The rejected hotspot's x coordinate.
43        x: u32,
44        /// The rejected hotspot's y coordinate.
45        y: u32,
46        /// The bitmap's width in pixels.
47        width: u32,
48        /// The bitmap's height in pixels.
49        height: u32,
50    },
51}
52
53/// An application-drawn pointer shape: RGBA pixels plus the hotspot, the pixel
54/// inside the image that sits exactly on the pointer's position.
55///
56/// The alpha channel is **not** premultiplied, which is what both winit and the
57/// browser expect of cursor images.
58#[derive(Clone, Debug, PartialEq, Eq, Hash)]
59pub struct CustomPointerIcon {
60    image: ImageBitmap,
61    hotspot_x: u32,
62    hotspot_y: u32,
63}
64
65impl CustomPointerIcon {
66    /// Builds a custom pointer icon from `image`, with its hotspot at
67    /// (`hotspot_x`, `hotspot_y`) pixels from the image's top-left corner.
68    pub fn new(
69        image: ImageBitmap,
70        hotspot_x: u32,
71        hotspot_y: u32,
72    ) -> Result<Self, PointerIconError> {
73        let (width, height) = (image.width(), image.height());
74        if width > MAX_POINTER_ICON_SIZE || height > MAX_POINTER_ICON_SIZE {
75            return Err(PointerIconError::TooLarge { width, height });
76        }
77        if hotspot_x >= width || hotspot_y >= height {
78            return Err(PointerIconError::HotspotOutsideBitmap {
79                x: hotspot_x,
80                y: hotspot_y,
81                width,
82                height,
83            });
84        }
85        Ok(Self {
86            image,
87            hotspot_x,
88            hotspot_y,
89        })
90    }
91
92    /// The icon's pixels, tightly packed RGBA8 with straight (not
93    /// premultiplied) alpha.
94    pub fn image(&self) -> &ImageBitmap {
95        &self.image
96    }
97
98    /// The hotspot's x offset from the image's left edge, in pixels.
99    pub fn hotspot_x(&self) -> u32 {
100        self.hotspot_x
101    }
102
103    /// The hotspot's y offset from the image's top edge, in pixels.
104    pub fn hotspot_y(&self) -> u32 {
105        self.hotspot_y
106    }
107
108    /// A stable identity derived from the pixels and the hotspot. Platform
109    /// backends key their per-window cursor caches on it so an icon that
110    /// reappears across frames is uploaded to the windowing system once.
111    pub fn id(&self) -> u64 {
112        self.image.id().rotate_left(17) ^ ((self.hotspot_x as u64) << 32 | self.hotspot_y as u64)
113    }
114}
115
116/// The pointer's appearance over a region of the UI.
117#[derive(Clone, Debug, PartialEq, Eq, Hash)]
118pub enum PointerIcon {
119    /// One of the standard shapes the platform already draws, named by the CSS
120    /// cursor vocabulary.
121    System(CursorIcon),
122    /// A shape the application draws itself.
123    Custom(CustomPointerIcon),
124}
125
126impl PointerIcon {
127    /// The platform's default pointer, usually an arrow.
128    pub const DEFAULT: Self = Self::System(CursorIcon::Default);
129
130    /// The hand shown over something that can be clicked.
131    pub const POINTER: Self = Self::System(CursorIcon::Pointer);
132
133    /// The I-beam shown over selectable text.
134    pub const TEXT: Self = Self::System(CursorIcon::Text);
135
136    /// Builds a custom icon from `image` with its hotspot at (`hotspot_x`,
137    /// `hotspot_y`).
138    pub fn custom(
139        image: ImageBitmap,
140        hotspot_x: u32,
141        hotspot_y: u32,
142    ) -> Result<Self, PointerIconError> {
143        CustomPointerIcon::new(image, hotspot_x, hotspot_y).map(Self::Custom)
144    }
145
146    /// The CSS `cursor` keyword for a standard shape, or `None` for a custom
147    /// one, which a browser names with a `url()` instead.
148    pub fn css_keyword(&self) -> Option<&'static str> {
149        match self {
150            Self::System(icon) => Some(icon.name()),
151            Self::Custom(_) => None,
152        }
153    }
154}
155
156impl Default for PointerIcon {
157    fn default() -> Self {
158        Self::DEFAULT
159    }
160}
161
162impl From<CursorIcon> for PointerIcon {
163    fn from(icon: CursorIcon) -> Self {
164        Self::System(icon)
165    }
166}
167
168impl From<CustomPointerIcon> for PointerIcon {
169    fn from(icon: CustomPointerIcon) -> Self {
170        Self::Custom(icon)
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn bitmap(width: u32, height: u32) -> ImageBitmap {
179        ImageBitmap::from_rgba8(width, height, vec![255; (width * height * 4) as usize])
180            .expect("test bitmap")
181    }
182
183    #[test]
184    fn custom_icon_keeps_image_and_hotspot() {
185        let icon = CustomPointerIcon::new(bitmap(8, 8), 3, 5).expect("icon");
186        assert_eq!(icon.image().width(), 8);
187        assert_eq!(icon.hotspot_x(), 3);
188        assert_eq!(icon.hotspot_y(), 5);
189    }
190
191    #[test]
192    fn custom_icon_rejects_oversized_bitmaps() {
193        let oversized = MAX_POINTER_ICON_SIZE + 1;
194        assert_eq!(
195            CustomPointerIcon::new(bitmap(oversized, 1), 0, 0),
196            Err(PointerIconError::TooLarge {
197                width: oversized,
198                height: 1,
199            })
200        );
201    }
202
203    #[test]
204    fn custom_icon_rejects_a_hotspot_outside_the_bitmap() {
205        assert_eq!(
206            CustomPointerIcon::new(bitmap(4, 4), 4, 0),
207            Err(PointerIconError::HotspotOutsideBitmap {
208                x: 4,
209                y: 0,
210                width: 4,
211                height: 4,
212            })
213        );
214    }
215
216    #[test]
217    fn custom_icon_accepts_the_bitmap_size_limit_and_its_last_pixel() {
218        let size = MAX_POINTER_ICON_SIZE;
219        CustomPointerIcon::new(bitmap(size, size), size - 1, size - 1)
220            .expect("the limit itself is allowed");
221    }
222
223    #[test]
224    fn ids_separate_pixels_and_hotspots() {
225        let a = CustomPointerIcon::new(bitmap(8, 8), 0, 0).expect("icon");
226        let same = CustomPointerIcon::new(bitmap(8, 8), 0, 0).expect("icon");
227        let moved_hotspot = CustomPointerIcon::new(bitmap(8, 8), 1, 0).expect("icon");
228        let other_pixels = CustomPointerIcon::new(bitmap(4, 4), 0, 0).expect("icon");
229
230        assert_eq!(a.id(), same.id());
231        assert_ne!(a.id(), moved_hotspot.id());
232        assert_ne!(a.id(), other_pixels.id());
233    }
234
235    #[test]
236    fn system_icons_name_their_css_keyword() {
237        assert_eq!(PointerIcon::DEFAULT.css_keyword(), Some("default"));
238        assert_eq!(PointerIcon::POINTER.css_keyword(), Some("pointer"));
239        assert_eq!(
240            PointerIcon::System(CursorIcon::NotAllowed).css_keyword(),
241            Some("not-allowed")
242        );
243    }
244
245    #[test]
246    fn custom_icons_have_no_css_keyword() {
247        let icon = PointerIcon::custom(bitmap(8, 8), 0, 0).expect("icon");
248        assert_eq!(icon.css_keyword(), None);
249    }
250
251    #[test]
252    fn the_default_icon_is_the_platform_arrow() {
253        assert_eq!(PointerIcon::default(), PointerIcon::DEFAULT);
254        assert_eq!(PointerIcon::from(CursorIcon::Default), PointerIcon::DEFAULT);
255    }
256}