use denise::{Color, Point, Rect, Role, Theme};
use denise_render::Canvas;
#[derive(Clone, Copy, Debug)]
pub struct CursorImage {
pub width: i32,
pub height: i32,
pub hotspot: Point,
pub mask: &'static [u8],
}
impl CursorImage {
#[inline]
pub const fn is_well_formed(&self) -> bool {
self.width > 0 && self.height > 0 && self.mask.len() == (self.width * self.height) as usize
}
pub fn rasterise(&self, theme: &Theme, out: &mut [u32]) -> usize {
let needed = (self.width.max(0) * self.height.max(0)) as usize;
if !self.is_well_formed() || out.len() < needed {
return 0;
}
let fill = theme.color(Role::BaseContent).to_argb8888();
let outline = theme.color(Role::Base100).to_argb8888();
for (pixel, &value) in out[..needed].iter_mut().zip(self.mask) {
*pixel = match value {
b'#' => fill,
b'+' => outline,
_ => 0,
};
}
needed
}
#[inline]
pub fn bounds_at(&self, at: Point) -> Rect {
Rect::new(
at.x - self.hotspot.x,
at.y - self.hotspot.y,
self.width,
self.height,
)
}
}
pub const ARROW: CursorImage = CursorImage {
width: 12,
height: 18,
hotspot: Point::new(0, 0),
mask: concat!(
"+...........",
"++..........",
"+#+.........",
"+##+........",
"+###+.......",
"+####+......",
"+#####+.....",
"+######+....",
"+#######+...",
"+########+..",
"+#####+++++.",
"+##+##+.....",
"+#+.+##+....",
"++..+##+....",
".....+##+...",
".....+##+...",
"......+#+...",
"......+++...",
)
.as_bytes(),
};
pub const CROSSHAIR: CursorImage = CursorImage {
width: 15,
height: 15,
hotspot: Point::new(7, 7),
mask: concat!(
"......+#+......",
"......+#+......",
"......+#+......",
"......+#+......",
"......+#+......",
"......+++......",
"+++++.....+++++",
"#####..#..#####",
"+++++.....+++++",
"......+++......",
"......+#+......",
"......+#+......",
"......+#+......",
"......+#+......",
"......+#+......",
)
.as_bytes(),
};
#[derive(Clone, Copy, Debug)]
pub struct Cursor {
pub image: &'static CursorImage,
pub position: Point,
pub visible: bool,
}
impl Default for Cursor {
fn default() -> Self {
Self {
image: &ARROW,
position: Point::ZERO,
visible: false,
}
}
}
impl Cursor {
#[inline]
pub fn bounds(&self) -> Rect {
if self.visible {
self.image.bounds_at(self.position)
} else {
Rect::ZERO
}
}
pub fn paint(&self, theme: &Theme, canvas: &mut Canvas<'_>) {
if !self.visible || !self.image.is_well_formed() {
return;
}
let origin = self.image.bounds_at(self.position);
if canvas.visible(origin).is_none() {
return;
}
let fill = theme.color(Role::BaseContent);
let outline = theme.color(Role::Base100);
paint_mask(self.image, origin, fill, outline, canvas);
}
}
fn paint_mask(
image: &CursorImage,
origin: Rect,
fill: Color,
outline: Color,
canvas: &mut Canvas<'_>,
) {
for row in 0..image.height {
let base = (row * image.width) as usize;
let y = origin.y + row;
let mut x = 0;
while x < image.width {
let value = image.mask[base + x as usize];
let mut end = x + 1;
while end < image.width && image.mask[base + end as usize] == value {
end += 1;
}
let color = match value {
b'#' => Some(fill),
b'+' => Some(outline),
_ => None,
};
if let Some(color) = color {
canvas.fill_rect(Rect::new(origin.x + x, y, end - x, 1), color);
}
x = end;
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn the_rasterised_sprite_uses_the_same_two_theme_colours() {
let theme = denise::theme::DARK;
let mut pixels = vec![0xDEAD_BEEFu32; (ARROW.width * ARROW.height) as usize];
let written = ARROW.rasterise(&theme, &mut pixels);
assert_eq!(written, pixels.len());
let fill = theme.color(Role::BaseContent).to_argb8888();
let outline = theme.color(Role::Base100).to_argb8888();
for (pixel, &value) in pixels.iter().zip(ARROW.mask) {
match value {
b'#' => assert_eq!(*pixel, fill),
b'+' => assert_eq!(*pixel, outline),
_ => assert_eq!(*pixel, 0, "transparent must be a zero word, not black"),
}
}
}
#[test]
fn every_transparent_pixel_has_zero_alpha() {
for image in [&ARROW, &CROSSHAIR] {
let mut pixels = vec![0u32; (image.width * image.height) as usize];
image.rasterise(&denise::theme::LIGHT, &mut pixels);
let transparent = pixels.iter().filter(|p| **p >> 24 == 0).count();
let expected = image.mask.iter().filter(|b| **b == b'.').count();
assert_eq!(transparent, expected);
assert!(
transparent > 0,
"a cursor with no transparency is a rectangle"
);
}
}
#[test]
fn a_theme_change_changes_the_pixels() {
let mut dark = vec![0u32; (ARROW.width * ARROW.height) as usize];
let mut light = dark.clone();
ARROW.rasterise(&denise::theme::DARK, &mut dark);
ARROW.rasterise(&denise::theme::LIGHT, &mut light);
assert_ne!(
dark, light,
"the plane must be re-uploaded on a theme change"
);
}
#[test]
fn a_buffer_too_small_writes_nothing() {
let mut pixels = vec![0u32; 4];
assert_eq!(ARROW.rasterise(&denise::theme::DARK, &mut pixels), 0);
assert!(pixels.iter().all(|&p| p == 0), "nothing partial is written");
}
use super::*;
use denise::{PixelFormat, Size, theme};
#[test]
fn built_in_sprites_match_their_declared_geometry() {
assert!(ARROW.is_well_formed(), "arrow mask is the wrong length");
assert!(
CROSSHAIR.is_well_formed(),
"crosshair mask is the wrong length"
);
}
#[test]
fn the_hotspot_pixel_is_opaque() {
for image in [&ARROW, &CROSSHAIR] {
let i = (image.hotspot.y * image.width + image.hotspot.x) as usize;
assert_ne!(
image.mask[i], b'.',
"the pixel under the pointer position must be drawn"
);
}
}
#[test]
fn a_hidden_cursor_paints_nothing() {
let mut pixels = [0u32; 64 * 64];
let mut canvas =
Canvas::from_pixels(&mut pixels, Size::new(64, 64), 64, PixelFormat::Xrgb8888)
.expect("canvas");
let cursor = Cursor::default();
cursor.paint(&theme::DARK, &mut canvas);
assert!(pixels.iter().all(|&p| p == 0));
}
#[test]
fn the_sprite_stays_inside_its_own_bounds() {
let mut pixels = [0u32; 64 * 64];
let cursor = Cursor {
image: &ARROW,
position: Point::new(20, 20),
visible: true,
};
{
let mut canvas =
Canvas::from_pixels(&mut pixels, Size::new(64, 64), 64, PixelFormat::Xrgb8888)
.expect("canvas");
cursor.paint(&theme::DARK, &mut canvas);
}
let bounds = cursor.bounds();
for y in 0..64i32 {
for x in 0..64i32 {
if !bounds.contains(Point::new(x, y)) {
assert_eq!(pixels[(y * 64 + x) as usize], 0, "wrote outside at {x},{y}");
}
}
}
assert_ne!(
pixels[(20 * 64 + 20) as usize],
0,
"the tip should be drawn"
);
}
}