Skip to main content

ferroui_browser/
cursor.rs

1use ferroui_base::input::StandardCursorType;
2use ferroui_base::media::imaging::{Bitmap, BitmapEncoderOptions, PngBitmapEncoderOptions};
3use ferroui_base::platform::{ICursorFactory, ICursorImpl};
4use ferroui_base::PixelPoint;
5use std::any::Any;
6use std::rc::Rc;
7
8/// A cursor as the value of the CSS `cursor` property.
9pub struct CssCursor {
10    value: Option<String>,
11}
12
13impl CssCursor {
14    /// The value that stands for the cursor of the page.
15    pub const DEFAULT: &'static str = "default";
16
17    /// The CSS keyword of a standard cursor.
18    pub fn new(type_: StandardCursorType) -> Self {
19        Self { value: Some(Self::to_keyword(type_).to_string()) }
20    }
21
22    /// Create a cursor from base64 image
23    pub fn from_base64(base64: &str, format: &str, hotspot: PixelPoint, fallback: StandardCursorType) -> Self {
24        Self {
25            value: Some(format!(
26                "url(\"data:image/{format};base64,{base64}\") {} {}, {}",
27                hotspot.x,
28                hotspot.y,
29                Self::to_keyword(fallback)
30            )),
31        }
32    }
33
34    /// Create a cursor from url to *.cur file.
35    pub fn from_url(url: &str, fallback: StandardCursorType) -> Self {
36        Self { value: Some(format!("url('{url}'), {}", Self::to_keyword(fallback))) }
37    }
38
39    /// Create a cursor from png/svg and hotspot position
40    pub fn from_url_with_hot_spot(url: &str, hot_spot: PixelPoint, fallback: StandardCursorType) -> Self {
41        Self { value: Some(format!("url('{url}') {} {}, {}", hot_spot.x, hot_spot.y, Self::to_keyword(fallback))) }
42    }
43
44    /// The value of the CSS property.
45    pub fn value(&self) -> Option<&str> {
46        self.value.as_deref()
47    }
48
49    /// Sets the value of the CSS property.
50    pub fn set_value(&mut self, value: Option<String>) {
51        self.value = value;
52    }
53
54    fn to_keyword(type_: StandardCursorType) -> &'static str {
55        match type_ {
56            StandardCursorType::Hand => "pointer",
57            StandardCursorType::Cross => "crosshair",
58            StandardCursorType::Help => "help",
59            StandardCursorType::Ibeam => "text",
60            StandardCursorType::No => "not-allowed",
61            StandardCursorType::None => "none",
62            StandardCursorType::Wait => "progress",
63            StandardCursorType::AppStarting => "wait",
64
65            StandardCursorType::DragMove => "move",
66            StandardCursorType::DragCopy => "copy",
67            StandardCursorType::DragLink => "alias",
68
69            StandardCursorType::UpArrow => "default", /*not found matching one*/
70            StandardCursorType::SizeWestEast => "ew-resize",
71            StandardCursorType::SizeNorthSouth => "ns-resize",
72            StandardCursorType::SizeAll => "move",
73
74            StandardCursorType::TopSide => "n-resize",
75            StandardCursorType::BottomSide => "s-resize",
76            StandardCursorType::LeftSide => "w-resize",
77            StandardCursorType::RightSide => "e-resize",
78            StandardCursorType::TopLeftCorner => "nw-resize",
79            StandardCursorType::TopRightCorner => "ne-resize",
80            StandardCursorType::BottomLeftCorner => "sw-resize",
81            StandardCursorType::BottomRightCorner => "se-resize",
82
83            _ => Self::DEFAULT,
84        }
85    }
86}
87
88impl ICursorImpl for CssCursor {
89    fn dispose(&self) {}
90
91    fn as_any(&self) -> &dyn Any {
92        self
93    }
94}
95
96/// Creates CSS cursors.
97#[derive(Default)]
98pub struct CssCursorFactory;
99
100/// Base64 with the standard alphabet and padding.
101fn to_base64(bytes: &[u8]) -> String {
102    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
103    let mut text = String::with_capacity(bytes.len().div_ceil(3) * 4);
104    for chunk in bytes.chunks(3) {
105        let b = [chunk[0], chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
106        text.push(ALPHABET[(b[0] >> 2) as usize] as char);
107        text.push(ALPHABET[(((b[0] & 0x03) << 4) | (b[1] >> 4)) as usize] as char);
108        text.push(if chunk.len() > 1 { ALPHABET[(((b[1] & 0x0F) << 2) | (b[2] >> 6)) as usize] as char } else { '=' });
109        text.push(if chunk.len() > 2 { ALPHABET[(b[2] & 0x3F) as usize] as char } else { '=' });
110    }
111    text
112}
113
114impl ICursorFactory for CssCursorFactory {
115    fn get_cursor(&self, cursor_type: StandardCursorType) -> Rc<dyn ICursorImpl> {
116        Rc::new(CssCursor::new(cursor_type))
117    }
118
119    /// # Panics
120    /// Panics when the bitmap cannot be encoded.
121    fn create_cursor(&self, cursor: &Bitmap, hot_spot: PixelPoint) -> Rc<dyn ICursorImpl> {
122        let mut image_stream = Vec::new();
123        if let Err(error) = cursor.save(&mut image_stream, &BitmapEncoderOptions::Png(PngBitmapEncoderOptions::DEFAULT)) {
124            panic!("Unable to save the cursor bitmap: {error}");
125        }
126
127        let base64_string = to_base64(&image_stream);
128        Rc::new(CssCursor::from_base64(&base64_string, "png", hot_spot, StandardCursorType::Arrow))
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn standard_cursors_map_to_css_keywords() {
138        let expected = [
139            (StandardCursorType::Arrow, "default"),
140            (StandardCursorType::Ibeam, "text"),
141            (StandardCursorType::Wait, "progress"),
142            (StandardCursorType::Cross, "crosshair"),
143            (StandardCursorType::UpArrow, "default"),
144            (StandardCursorType::SizeWestEast, "ew-resize"),
145            (StandardCursorType::SizeNorthSouth, "ns-resize"),
146            (StandardCursorType::SizeAll, "move"),
147            (StandardCursorType::No, "not-allowed"),
148            (StandardCursorType::Hand, "pointer"),
149            (StandardCursorType::AppStarting, "wait"),
150            (StandardCursorType::Help, "help"),
151            (StandardCursorType::TopSide, "n-resize"),
152            (StandardCursorType::BottomSide, "s-resize"),
153            (StandardCursorType::LeftSide, "w-resize"),
154            (StandardCursorType::RightSide, "e-resize"),
155            (StandardCursorType::TopLeftCorner, "nw-resize"),
156            (StandardCursorType::TopRightCorner, "ne-resize"),
157            (StandardCursorType::BottomLeftCorner, "sw-resize"),
158            (StandardCursorType::BottomRightCorner, "se-resize"),
159            (StandardCursorType::DragMove, "move"),
160            (StandardCursorType::DragCopy, "copy"),
161            (StandardCursorType::DragLink, "alias"),
162            (StandardCursorType::None, "none"),
163        ];
164
165        for (type_, keyword) in expected {
166            assert_eq!(Some(keyword), CssCursor::new(type_).value(), "{type_:?}");
167        }
168    }
169
170    #[test]
171    fn the_factory_creates_keyword_cursors() {
172        let cursor = CssCursorFactory.get_cursor(StandardCursorType::Hand);
173
174        assert_eq!(Some("pointer"), cursor.as_any().downcast_ref::<CssCursor>().unwrap().value());
175    }
176
177    #[test]
178    fn an_embedded_image_carries_its_hot_spot_and_a_fallback() {
179        let cursor = CssCursor::from_base64("QUJD", "png", PixelPoint::new(3, 7), StandardCursorType::Arrow);
180
181        assert_eq!(Some("url(\"data:image/png;base64,QUJD\") 3 7, default"), cursor.value());
182    }
183
184    #[test]
185    fn a_cursor_file_is_referenced_by_its_url() {
186        let cursor = CssCursor::from_url("cursors/busy.cur", StandardCursorType::Wait);
187
188        assert_eq!(Some("url('cursors/busy.cur'), progress"), cursor.value());
189    }
190
191    #[test]
192    fn an_image_url_carries_its_hot_spot() {
193        let cursor = CssCursor::from_url_with_hot_spot("pen.svg", PixelPoint::new(1, 15), StandardCursorType::Cross);
194
195        assert_eq!(Some("url('pen.svg') 1 15, crosshair"), cursor.value());
196    }
197
198    #[test]
199    fn base64_pads_incomplete_groups() {
200        assert_eq!("", to_base64(b""));
201        assert_eq!("Zg==", to_base64(b"f"));
202        assert_eq!("Zm8=", to_base64(b"fo"));
203        assert_eq!("Zm9v", to_base64(b"foo"));
204        assert_eq!("Zm9vYg==", to_base64(b"foob"));
205        assert_eq!("Zm9vYmE=", to_base64(b"fooba"));
206        assert_eq!("Zm9vYmFy", to_base64(b"foobar"));
207        assert_eq!("+/8=", to_base64(&[0xFB, 0xFF]));
208    }
209}