1use denise::Pen;
21use denise::{Color, Point, Rect, Role, Theme};
22
23#[derive(Clone, Copy, Debug)]
35pub struct CursorImage {
36 pub width: i32,
38 pub height: i32,
40 pub hotspot: Point,
42 pub mask: &'static [u8],
44}
45
46impl CursorImage {
47 #[inline]
49 pub const fn is_well_formed(&self) -> bool {
50 self.width > 0 && self.height > 0 && self.mask.len() == (self.width * self.height) as usize
51 }
52
53 pub fn rasterise(&self, theme: &Theme, out: &mut [u32]) -> usize {
66 let needed = (self.width.max(0) * self.height.max(0)) as usize;
67 if !self.is_well_formed() || out.len() < needed {
68 return 0;
69 }
70 let fill = theme.color(Role::BaseContent).to_argb8888();
71 let outline = theme.color(Role::Base100).to_argb8888();
72 for (pixel, &value) in out[..needed].iter_mut().zip(self.mask) {
73 *pixel = match value {
74 b'#' => fill,
75 b'+' => outline,
76 _ => 0,
77 };
78 }
79 needed
80 }
81
82 #[inline]
84 pub fn bounds_at(&self, at: Point) -> Rect {
85 Rect::new(
86 at.x - self.hotspot.x,
87 at.y - self.hotspot.y,
88 self.width,
89 self.height,
90 )
91 }
92}
93
94pub const ARROW: CursorImage = CursorImage {
96 width: 12,
97 height: 18,
98 hotspot: Point::new(0, 0),
99 mask: concat!(
100 "+...........",
101 "++..........",
102 "+#+.........",
103 "+##+........",
104 "+###+.......",
105 "+####+......",
106 "+#####+.....",
107 "+######+....",
108 "+#######+...",
109 "+########+..",
110 "+#####+++++.",
111 "+##+##+.....",
112 "+#+.+##+....",
113 "++..+##+....",
114 ".....+##+...",
115 ".....+##+...",
116 "......+#+...",
117 "......+++...",
118 )
119 .as_bytes(),
120};
121
122pub const CROSSHAIR: CursorImage = CursorImage {
124 width: 15,
125 height: 15,
126 hotspot: Point::new(7, 7),
127 mask: concat!(
128 "......+#+......",
129 "......+#+......",
130 "......+#+......",
131 "......+#+......",
132 "......+#+......",
133 "......+++......",
134 "+++++.....+++++",
135 "#####..#..#####",
136 "+++++.....+++++",
137 "......+++......",
138 "......+#+......",
139 "......+#+......",
140 "......+#+......",
141 "......+#+......",
142 "......+#+......",
143 )
144 .as_bytes(),
145};
146
147#[derive(Clone, Copy, Debug)]
149pub struct Cursor {
150 pub image: &'static CursorImage,
152 pub position: Point,
154 pub visible: bool,
160}
161
162impl Default for Cursor {
163 fn default() -> Self {
164 Self {
165 image: &ARROW,
166 position: Point::ZERO,
167 visible: false,
168 }
169 }
170}
171
172impl Cursor {
173 #[inline]
175 pub fn bounds(&self) -> Rect {
176 if self.visible {
177 self.image.bounds_at(self.position)
178 } else {
179 Rect::ZERO
180 }
181 }
182
183 pub fn paint(&self, theme: &Theme, canvas: &mut Pen<'_>) {
185 if !self.visible || !self.image.is_well_formed() {
186 return;
187 }
188 let origin = self.image.bounds_at(self.position);
189 if canvas.visible(origin).is_none() {
190 return;
191 }
192 let fill = theme.color(Role::BaseContent);
193 let outline = theme.color(Role::Base100);
194 paint_mask(self.image, origin, fill, outline, canvas);
195 }
196}
197
198fn paint_mask(
199 image: &CursorImage,
200 origin: Rect,
201 fill: Color,
202 outline: Color,
203 canvas: &mut Pen<'_>,
204) {
205 for row in 0..image.height {
206 let base = (row * image.width) as usize;
207 let y = origin.y + row;
208 let mut x = 0;
211 while x < image.width {
212 let value = image.mask[base + x as usize];
213 let mut end = x + 1;
214 while end < image.width && image.mask[base + end as usize] == value {
215 end += 1;
216 }
217 let color = match value {
218 b'#' => Some(fill),
219 b'+' => Some(outline),
220 _ => None,
221 };
222 if let Some(color) = color {
223 canvas.fill_rect(Rect::new(origin.x + x, y, end - x, 1), color);
224 }
225 x = end;
226 }
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use denise_render::Canvas;
233 #[test]
236 fn the_rasterised_sprite_uses_the_same_two_theme_colours() {
237 let theme = denise::theme::DARK;
238 let mut pixels = vec![0xDEAD_BEEFu32; (ARROW.width * ARROW.height) as usize];
239 let written = ARROW.rasterise(&theme, &mut pixels);
240 assert_eq!(written, pixels.len());
241
242 let fill = theme.color(Role::BaseContent).to_argb8888();
243 let outline = theme.color(Role::Base100).to_argb8888();
244 for (pixel, &value) in pixels.iter().zip(ARROW.mask) {
245 match value {
246 b'#' => assert_eq!(*pixel, fill),
247 b'+' => assert_eq!(*pixel, outline),
248 _ => assert_eq!(*pixel, 0, "transparent must be a zero word, not black"),
249 }
250 }
251 }
252
253 #[test]
257 fn every_transparent_pixel_has_zero_alpha() {
258 for image in [&ARROW, &CROSSHAIR] {
259 let mut pixels = vec![0u32; (image.width * image.height) as usize];
260 image.rasterise(&denise::theme::LIGHT, &mut pixels);
261 let transparent = pixels.iter().filter(|p| **p >> 24 == 0).count();
262 let expected = image.mask.iter().filter(|b| **b == b'.').count();
263 assert_eq!(transparent, expected);
264 assert!(
265 transparent > 0,
266 "a cursor with no transparency is a rectangle"
267 );
268 }
269 }
270
271 #[test]
275 fn a_theme_change_changes_the_pixels() {
276 let mut dark = vec![0u32; (ARROW.width * ARROW.height) as usize];
277 let mut light = dark.clone();
278 ARROW.rasterise(&denise::theme::DARK, &mut dark);
279 ARROW.rasterise(&denise::theme::LIGHT, &mut light);
280 assert_ne!(
281 dark, light,
282 "the plane must be re-uploaded on a theme change"
283 );
284 }
285
286 #[test]
287 fn a_buffer_too_small_writes_nothing() {
288 let mut pixels = vec![0u32; 4];
289 assert_eq!(ARROW.rasterise(&denise::theme::DARK, &mut pixels), 0);
290 assert!(pixels.iter().all(|&p| p == 0), "nothing partial is written");
291 }
292
293 use super::*;
294 use denise::{PixelFormat, Size, theme};
295
296 #[test]
297 fn built_in_sprites_match_their_declared_geometry() {
298 assert!(ARROW.is_well_formed(), "arrow mask is the wrong length");
299 assert!(
300 CROSSHAIR.is_well_formed(),
301 "crosshair mask is the wrong length"
302 );
303 }
304
305 #[test]
306 fn the_hotspot_pixel_is_opaque() {
307 for image in [&ARROW, &CROSSHAIR] {
308 let i = (image.hotspot.y * image.width + image.hotspot.x) as usize;
309 assert_ne!(
310 image.mask[i], b'.',
311 "the pixel under the pointer position must be drawn"
312 );
313 }
314 }
315
316 #[test]
317 fn a_hidden_cursor_paints_nothing() {
318 let mut pixels = [0u32; 64 * 64];
319 let mut canvas =
320 Canvas::from_pixels(&mut pixels, Size::new(64, 64), 64, PixelFormat::Xrgb8888)
321 .expect("canvas");
322 let cursor = Cursor::default();
323 cursor.paint(&theme::DARK, &mut canvas.pen());
324 assert!(pixels.iter().all(|&p| p == 0));
325 }
326
327 #[test]
328 fn the_sprite_stays_inside_its_own_bounds() {
329 let mut pixels = [0u32; 64 * 64];
330 let cursor = Cursor {
331 image: &ARROW,
332 position: Point::new(20, 20),
333 visible: true,
334 };
335 {
336 let mut canvas =
337 Canvas::from_pixels(&mut pixels, Size::new(64, 64), 64, PixelFormat::Xrgb8888)
338 .expect("canvas");
339 cursor.paint(&theme::DARK, &mut canvas.pen());
340 }
341 let bounds = cursor.bounds();
342 for y in 0..64i32 {
343 for x in 0..64i32 {
344 if !bounds.contains(Point::new(x, y)) {
345 assert_eq!(pixels[(y * 64 + x) as usize], 0, "wrote outside at {x},{y}");
346 }
347 }
348 }
349 assert_ne!(
350 pixels[(20 * 64 + 20) as usize],
351 0,
352 "the tip should be drawn"
353 );
354 }
355}