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
8pub struct CssCursor {
10 value: Option<String>,
11}
12
13impl CssCursor {
14 pub const DEFAULT: &'static str = "default";
16
17 pub fn new(type_: StandardCursorType) -> Self {
19 Self { value: Some(Self::to_keyword(type_).to_string()) }
20 }
21
22 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 pub fn from_url(url: &str, fallback: StandardCursorType) -> Self {
36 Self { value: Some(format!("url('{url}'), {}", Self::to_keyword(fallback))) }
37 }
38
39 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 pub fn value(&self) -> Option<&str> {
46 self.value.as_deref()
47 }
48
49 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", 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#[derive(Default)]
98pub struct CssCursorFactory;
99
100fn 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 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}