Skip to main content

denise_ui/
cursor.rs

1//! The composite cursor sprite.
2//!
3//! Step 4 of the rendering pipeline, and the reason the project is named after a
4//! display chip. Denise the 8362 overlaid eight hardware sprites on the playfield
5//! after compositing it; this overlays one, in software, onto the finished scene.
6//!
7//! Keeping the cursor out of the scene graph is deliberate. It is not a widget: it
8//! never takes input, it must draw above every scene including modals, and it moves
9//! far more often than anything else on screen. As a sprite it costs two small
10//! damage rectangles per move — the pixels it left and the pixels it now covers —
11//! and nothing else in the tree has to know it exists.
12//!
13//! On DRM this is not the path taken: `denise-drm` implements
14//! [`CursorPlane`](denise::CursorPlane), and the display controller composites the
15//! sprite during scanout so a pointer move costs one ioctl instead of a repaint
16//! and a flip. Call [`Ui::show_cursor(false)`](crate::Ui::show_cursor) and drive
17//! the plane instead. What follows is the fallback for every backend without one,
18//! and [`CursorImage::rasterise`] is how the same sprite reaches the plane.
19
20use denise::Pen;
21use denise::{Color, Point, Rect, Role, Theme};
22
23/// A cursor bitmap: three levels, drawn in two theme colours.
24///
25/// `mask` is one ASCII byte per pixel in row-major order, which keeps the shape
26/// readable in the source instead of being a wall of hex:
27///
28/// - `.` transparent
29/// - `#` fill, painted in [`Role::BaseContent`]
30/// - `+` outline, painted in [`Role::Base100`]
31///
32/// Both colours come from the theme, so the pointer inverts with it and stays
33/// visible on a light panel and a dark one without a second asset.
34#[derive(Clone, Copy, Debug)]
35pub struct CursorImage {
36    /// Width in pixels.
37    pub width: i32,
38    /// Height in pixels.
39    pub height: i32,
40    /// The pixel that sits on the pointer position.
41    pub hotspot: Point,
42    /// `width * height` ASCII bytes.
43    pub mask: &'static [u8],
44}
45
46impl CursorImage {
47    /// Returns `true` if `mask` matches the declared geometry.
48    #[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    /// Writes the sprite into `out` as `0xAARRGGBB` words, for a hardware cursor
54    /// plane.
55    ///
56    /// Returns the number of words written, which is `width * height`. The two
57    /// colours are the theme's, exactly as the software composite uses them, so a
58    /// panel that switches to the plane does not also change appearance —
59    /// transparent pixels come out as a fully zero word rather than as black,
60    /// because a cursor plane composites during scanout and an opaque pad would
61    /// draw a rectangle around the pointer.
62    ///
63    /// Re-run this when the theme changes: the sprite is resolved to concrete
64    /// colours here, so the plane holds pixels rather than roles.
65    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    /// Bounds the sprite would occupy with its hotspot at `at`.
83    #[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
94/// The standard left-pointing arrow, 12×18, hotspot at the tip.
95pub 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
122/// A crosshair for touch calibration and precise pointing, 15×15, centred.
123pub 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/// Where the pointer is and what it looks like.
148#[derive(Clone, Copy, Debug)]
149pub struct Cursor {
150    /// The sprite to draw.
151    pub image: &'static CursorImage,
152    /// Hotspot position in surface pixels.
153    pub position: Point,
154    /// Whether the sprite is composited at all.
155    ///
156    /// Starts hidden. A panel driven only by touch should never show a pointer,
157    /// so the tree reveals it on the first pointer motion and hides it again when
158    /// a finger arrives.
159    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    /// Bounds the sprite currently occupies, empty when hidden.
174    #[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    /// Composites the sprite onto an already-finished scene.
184    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        // Runs of one value blit as a span, which matters because the per-pixel
209        // path measured fifteen times slower than the span path on a Pi 3.
210        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    /// The plane and the software composite must agree, or switching a panel to
234    /// the hardware cursor would also change how it looks.
235    #[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    /// A cursor plane composites during scanout, so any pixel that is not the
254    /// pointer has to be fully transparent — an opaque background would paint a
255    /// rectangle over whatever is under it.
256    #[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    /// The theme is baked in, so a theme switch has to re-upload. If both themes
272    /// produced the same pixels this would be silently fine and the test would be
273    /// worthless — so check they actually differ.
274    #[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}