diff options
Diffstat (limited to 'crates/wayland/src/cursor.rs')
| -rw-r--r-- | crates/wayland/src/cursor.rs | 473 |
1 files changed, 458 insertions, 15 deletions
diff --git a/crates/wayland/src/cursor.rs b/crates/wayland/src/cursor.rs index b489c03..d9bc3be 100644 --- a/crates/wayland/src/cursor.rs +++ b/crates/wayland/src/cursor.rs @@ -17,9 +17,10 @@ //! we have no art for. //! //! `CursorImageStatus::Hidden` is honoured, so a client that hides the -//! pointer still gets its way. Named shapes (`CursorIcon::Text` etc.) fall -//! back to the arrow rather than being drawn as the requested shape - most -//! toolkits set a surface rather than a name, so this is rarely visible. +//! pointer still gets its way. Named shapes we have dedicated art for +//! (text entry, the four resize directions, crosshair, move, and the +//! pointing-hand link-hover shape) render as that shape; anything else +//! falls back to the arrow. //! //! The built-in arrow is deliberate rather than loading an XCursor theme: //! theme loading pulls in a dependency, needs a theme to actually be @@ -41,6 +42,17 @@ pub(crate) const CURSOR_SIZE: i32 = 24; /// Encoded as a small bitmap rather than drawn with geometry so the shape is /// obvious and reviewable: `.` transparent, `#` black outline, `*` white /// fill. 24 rows of 24 columns. +/// +/// The tail (below the triangular head, past the horizontal shelf at row +/// 15) used to fork into two separate legs of visibly different widths -- +/// the left one tapering down to a point like the rest of the shape, the +/// right one a constant-width block that never tapered at all, ending in +/// an abrupt flat stop. It rendered fine at a glance in a screenshot but +/// reads as lopsided/broken up close, exactly as reported live ("one side +/// is bigger than the other"). Replaced with a single triangular foot, +/// straight left edge continuing the head's, right edge tapering linearly +/// inward row by row down to a point - the same shape language the head +/// itself already uses, just mirrored. const ARROW: [&str; CURSOR_SIZE as usize] = [ "#.......................", "##......................", @@ -57,13 +69,13 @@ const ARROW: [&str; CURSOR_SIZE as usize] = [ "#***********#...........", "#************#..........", "#*************#.........", - "#******####### .........", - "#***#**#................", - "#**#.#**#...............", - "#*#..#**#...............", - "##....#**#..............", - "#.....#**#..............", - ".......###..............", + "#******#######..........", + "#**********#............", + "#********#..............", + "#******#................", + "#****#..................", + "#**#....................", + "#.......................", "........................", "........................", ]; @@ -90,9 +102,291 @@ pub(crate) fn arrow_bitmap() -> Vec<u8> { buf[i + 3] = a; } } + add_white_halo(&mut buf); buf } +/// Adds a 1px opaque-white ring around every opaque pixel, on whichever +/// neighbouring pixels are still fully transparent. +/// +/// Without this, the black outline drawn by the code above becomes +/// invisible over a dark background: confirmed live from a screenshot of +/// this exact arrow over a black terminal, where the outline had merged +/// completely into the background, leaving only a stark, edgeless white +/// silhouette. The resize/text shapes (drawn as plain opaque black lines, +/// no fill - see `set_px`) have the same problem more severely: solid +/// black on a dark window is close to invisible outright. A halo keeps +/// every shape readable against both light and dark content underneath it, +/// the same trick real cursor themes use - it is the faint white fringe +/// visible around an ordinary system arrow cursor. +/// +/// Two-pass by construction: halo positions are collected against the +/// buffer's original opacity before any of them are written, so the ring +/// stays exactly 1px thick instead of dilating outward on itself. +fn add_white_halo(buf: &mut [u8]) { + let is_opaque = |buf: &[u8], x: i32, y: i32| -> bool { + if x < 0 || y < 0 || x >= CURSOR_SIZE || y >= CURSOR_SIZE { + return false; + } + buf[((y * CURSOR_SIZE + x) * 4 + 3) as usize] != 0 + }; + let mut halo = Vec::new(); + for y in 0..CURSOR_SIZE { + for x in 0..CURSOR_SIZE { + if is_opaque(buf, x, y) { + continue; + } + let touches_shape = [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1), (x - 1, y - 1), (x + 1, y - 1), (x - 1, y + 1), (x + 1, y + 1)] + .into_iter() + .any(|(nx, ny)| is_opaque(buf, nx, ny)); + if touches_shape { + halo.push((x, y)); + } + } + } + for (x, y) in halo { + let i = ((y * CURSOR_SIZE + x) * 4) as usize; + buf[i] = 0xff; + buf[i + 1] = 0xff; + buf[i + 2] = 0xff; + buf[i + 3] = 0xff; + } +} + + +/// A small set of built-in cursor bitmaps beyond the default arrow, for the +/// named shapes a client requests most often: text entry, the four resize +/// directions, crosshair, move, and the pointing-hand hyperlink-hover +/// shape. Everything else (grab, wait, help, ...) still falls back to the +/// arrow - one arrow beats zero effort spent on a dozen rarely-seen icons, +/// but "I-beam over a text field", "double arrow at a window edge", and +/// "hand over a link" are common and immediately noticeable when wrong, +/// which is what made the cursor "always look the same regardless of +/// what's under it" worth fixing at all. +/// +/// Built once at startup (`make_buffers`), same as the arrow. +#[derive(Clone)] +pub(crate) struct CursorBuffers { + pub(crate) arrow: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) text: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) ns_resize: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) ew_resize: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) nesw_resize: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) nwse_resize: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) crosshair: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) move_icon: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) pointer: smithay::backend::renderer::element::memory::MemoryRenderBuffer, +} + +/// Sets one pixel to opaque white-on-black-outline isn't needed here (these +/// shapes are drawn solid black, unlike the arrow's outline+fill) -- +/// straight opaque black, since these are thin enough that an outline +/// would just eat the whole shape. +fn set_px(buf: &mut [u8], x: i32, y: i32) { + if x < 0 || y < 0 || x >= CURSOR_SIZE || y >= CURSOR_SIZE { + return; + } + let i = ((y * CURSOR_SIZE + x) * 4) as usize; + buf[i] = 0x00; + buf[i + 1] = 0x00; + buf[i + 2] = 0x00; + buf[i + 3] = 0xff; +} + +/// Bresenham line, thickened by `width` (drawn as `width` parallel lines +/// offset perpendicular to travel) since a single-pixel line is nearly +/// invisible at this size. +fn draw_line(buf: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32, width: i32) { + let (dx, dy) = (x1 - x0, y1 - y0); + let len = ((dx * dx + dy * dy) as f32).sqrt().max(1.0); + // Perpendicular unit direction, scaled for the offsets below. + let (px, py) = (-(dy as f32) / len, (dx as f32) / len); + for w in 0..width { + let offset = w - width / 2; + let ox = (px * offset as f32).round() as i32; + let oy = (py * offset as f32).round() as i32; + draw_thin_line(buf, x0 + ox, y0 + oy, x1 + ox, y1 + oy); + } +} + +fn draw_thin_line(buf: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32) { + let (mut x0, mut y0) = (x0, y0); + let dx = (x1 - x0).abs(); + let dy = -(y1 - y0).abs(); + let sx = if x0 < x1 { 1 } else { -1 }; + let sy = if y0 < y1 { 1 } else { -1 }; + let mut err = dx + dy; + loop { + set_px(buf, x0, y0); + if x0 == x1 && y0 == y1 { + break; + } + let e2 = 2 * err; + if e2 >= dy { + err += dy; + x0 += sx; + } + if e2 <= dx { + err += dx; + y0 += sy; + } + } +} + +/// I-beam: a vertical stem with top/bottom serifs, centered in the bitmap +/// (unlike the arrow, whose hotspot is its tip at (0,0) - an I-beam's +/// hotspot is its center, where the text caret actually is). +fn text_bitmap() -> Vec<u8> { + let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize]; + let mid = CURSOR_SIZE / 2; + draw_line(&mut buf, mid, 3, mid, CURSOR_SIZE - 4, 2); + draw_line(&mut buf, mid - 4, 3, mid + 4, 3, 2); + draw_line(&mut buf, mid - 4, CURSOR_SIZE - 4, mid + 4, CURSOR_SIZE - 4, 2); + add_white_halo(&mut buf); + buf +} + +/// Double-headed arrow along one axis (horizontal if `horizontal`, +/// vertical otherwise), hotspot at center - the standard edge/side resize +/// cursor shape. +fn straight_resize_bitmap(horizontal: bool) -> Vec<u8> { + let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize]; + let mid = CURSOR_SIZE / 2; + let (lo, hi) = (3, CURSOR_SIZE - 4); + if horizontal { + draw_line(&mut buf, lo, mid, hi, mid, 2); + draw_line(&mut buf, lo, mid, lo + 5, mid - 5, 2); + draw_line(&mut buf, lo, mid, lo + 5, mid + 5, 2); + draw_line(&mut buf, hi, mid, hi - 5, mid - 5, 2); + draw_line(&mut buf, hi, mid, hi - 5, mid + 5, 2); + } else { + draw_line(&mut buf, mid, lo, mid, hi, 2); + draw_line(&mut buf, mid, lo, mid - 5, lo + 5, 2); + draw_line(&mut buf, mid, lo, mid + 5, lo + 5, 2); + draw_line(&mut buf, mid, hi, mid - 5, hi - 5, 2); + draw_line(&mut buf, mid, hi, mid + 5, hi - 5, 2); + } + add_white_halo(&mut buf); + buf +} + +/// Double-headed arrow along a diagonal: NW-SE if `nwse`, NE-SW otherwise. +/// Hotspot at center, same as the straight resize shapes. +fn diagonal_resize_bitmap(nwse: bool) -> Vec<u8> { + let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize]; + let (lo, hi) = (3, CURSOR_SIZE - 4); + let (x0, y0, x1, y1) = if nwse { (lo, lo, hi, hi) } else { (lo, hi, hi, lo) }; + draw_line(&mut buf, x0, y0, x1, y1, 2); + // Arrowheads: two short strokes angled off each end, perpendicular-ish + // to the main diagonal so they read as a `<` / `>`-style head. + let head = |buf: &mut [u8], hx: i32, hy: i32, ax1: i32, ay1: i32, ax2: i32, ay2: i32| { + draw_line(buf, hx, hy, ax1, ay1, 2); + draw_line(buf, hx, hy, ax2, ay2, 2); + }; + if nwse { + head(&mut buf, x0, y0, x0 + 7, y0, x0, y0 + 7); + head(&mut buf, x1, y1, x1 - 7, y1, x1, y1 - 7); + } else { + head(&mut buf, x0, y0, x0 + 7, y0, x0, y0 - 7); + head(&mut buf, x1, y1, x1 - 7, y1, x1, y1 + 7); + } + add_white_halo(&mut buf); + buf +} + +/// Solid-fills a rectangle, clamped to the canvas - used by [`pointer_bitmap`] +/// instead of [`draw_line`]'s thin strokes, since a hand cursor reads better +/// as a few blocky filled shapes than as an outline at this resolution. +fn fill_rect(buf: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32) { + for y in y0.max(0)..=y1.min(CURSOR_SIZE - 1) { + for x in x0.max(0)..=x1.min(CURSOR_SIZE - 1) { + set_px(buf, x, y); + } + } +} + +/// A crosshair: full-height vertical line through full-width horizontal +/// line, hotspot dead center where the two cross - `zwp_pointer_constraints` +/// clients (games, precise pixel-editors) and any `cursor: crosshair` CSS +/// both expect this exact shape. +fn crosshair_bitmap() -> Vec<u8> { + let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize]; + let mid = CURSOR_SIZE / 2; + draw_line(&mut buf, mid, 1, mid, CURSOR_SIZE - 2, 1); + draw_line(&mut buf, 1, mid, CURSOR_SIZE - 2, mid, 1); + add_white_halo(&mut buf); + buf +} + +/// Four-way move arrow: one line from center to each edge, with an +/// arrowhead at every tip - `cursor: move` (draggable panels, reordering +/// lists), built the same way [`diagonal_resize_bitmap`] builds its two +/// arrowheads, just aimed at all four cardinal directions instead of one +/// diagonal. +fn move_bitmap() -> Vec<u8> { + let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize]; + let mid = CURSOR_SIZE / 2; + let (lo, hi) = (2, CURSOR_SIZE - 3); + draw_line(&mut buf, mid, lo, mid, hi, 2); + draw_line(&mut buf, lo, mid, hi, mid, 2); + let head = |buf: &mut [u8], hx: i32, hy: i32, ax1: i32, ay1: i32, ax2: i32, ay2: i32| { + draw_line(buf, hx, hy, ax1, ay1, 2); + draw_line(buf, hx, hy, ax2, ay2, 2); + }; + head(&mut buf, mid, lo, mid - 4, lo + 5, mid + 4, lo + 5); + head(&mut buf, mid, hi, mid - 4, hi - 5, mid + 4, hi - 5); + head(&mut buf, lo, mid, lo + 5, mid - 4, lo + 5, mid + 4); + head(&mut buf, hi, mid, hi - 5, mid - 4, hi - 5, mid + 4); + add_white_halo(&mut buf); + buf +} + +/// A blocky pointing hand: an upright index finger with the hotspot at its +/// tip, above a wider palm block - `CursorIcon::Pointer`, the single most +/// common named shape after the default arrow (every hyperlink, every +/// clickable non-form control). Filled rectangles rather than an outline, +/// since a recognisable hand silhouette needs more coverage than a few thin +/// strokes can give at 24px. +fn pointer_bitmap() -> Vec<u8> { + let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize]; + fill_rect(&mut buf, 8, 1, 11, 11); + fill_rect(&mut buf, 4, 10, 19, 20); + add_white_halo(&mut buf); + buf +} + +fn upload(data: Vec<u8>) -> smithay::backend::renderer::element::memory::MemoryRenderBuffer { + use smithay::backend::allocator::Fourcc; + use smithay::backend::renderer::element::memory::MemoryRenderBuffer; + use smithay::utils::Transform; + MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (CURSOR_SIZE, CURSOR_SIZE), 1, Transform::Normal, None) +} + +/// The centered shapes' hotspot: dead center of the bitmap, unlike the +/// arrow's tip-at-origin. Shared by every shape built here except the +/// arrow itself. +pub(crate) const CENTERED_HOTSPOT: (i32, i32) = (CURSOR_SIZE / 2, CURSOR_SIZE / 2); + +pub(crate) fn make_buffers() -> CursorBuffers { + CursorBuffers { + arrow: make_buffer(), + text: upload(text_bitmap()), + ns_resize: upload(straight_resize_bitmap(false)), + ew_resize: upload(straight_resize_bitmap(true)), + nesw_resize: upload(diagonal_resize_bitmap(false)), + nwse_resize: upload(diagonal_resize_bitmap(true)), + crosshair: upload(crosshair_bitmap()), + move_icon: upload(move_bitmap()), + pointer: upload(pointer_bitmap()), + } +} + +/// [`pointer_bitmap`]'s hotspot: the fingertip, near the top of the canvas +/// - unlike the centered resize/crosshair/move shapes, a pointing hand's +/// "active point" for click purposes is where the finger tip actually is, +/// the same reasoning the built-in arrow's tip-at-origin hotspot already +/// uses. +const POINTER_HOTSPOT: (i32, i32) = (9, 1); /// One cursor render element, whatever the source. /// @@ -107,7 +401,7 @@ pub(crate) fn arrow_bitmap() -> Vec<u8> { /// over this output - otherwise every monitor would draw its own copy. pub(crate) fn render_elements<R>( status: &smithay::input::pointer::CursorImageStatus, - buffer: &smithay::backend::renderer::element::memory::MemoryRenderBuffer, + buffers: &CursorBuffers, renderer: &mut R, pos: Point<f64, Logical>, origin: Point<i32, Logical>, @@ -121,7 +415,7 @@ where { use smithay::backend::renderer::element::surface::render_elements_from_surface_tree; use smithay::backend::renderer::element::Kind; - use smithay::input::pointer::{CursorImageStatus, CursorImageSurfaceData}; + use smithay::input::pointer::{CursorIcon, CursorImageStatus, CursorImageSurfaceData}; use smithay::wayland::compositor::with_states; if matches!(status, CursorImageStatus::Hidden) { @@ -148,9 +442,36 @@ where return render_elements_from_surface_tree(renderer, surface, at, 1.0, 1.0, Kind::Cursor); } - // No client image (or a named shape we don't have art for): the - // built-in arrow, whose hotspot is its tip, so no offset. - let at = (local.0 as f64, local.1 as f64); + // No client image. A named shape we have dedicated art for gets it + // (centered on the pointer - these are all symmetric shapes, unlike + // the arrow); anything else (Default, or one of the many shapes we + // don't draw, e.g. Grab/Pointer/Crosshair) falls back to the arrow, + // whose hotspot is its tip instead, at the origin. + let (buffer, at) = match status { + CursorImageStatus::Named(icon) => match icon { + CursorIcon::Text | CursorIcon::VerticalText => { + (&buffers.text, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)) + } + CursorIcon::EResize | CursorIcon::WResize | CursorIcon::EwResize | CursorIcon::ColResize => { + (&buffers.ew_resize, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)) + } + CursorIcon::NResize | CursorIcon::SResize | CursorIcon::NsResize | CursorIcon::RowResize => { + (&buffers.ns_resize, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)) + } + CursorIcon::NeResize | CursorIcon::SwResize | CursorIcon::NeswResize => { + (&buffers.nesw_resize, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)) + } + CursorIcon::NwResize | CursorIcon::SeResize | CursorIcon::NwseResize | CursorIcon::AllResize => { + (&buffers.nwse_resize, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)) + } + CursorIcon::Crosshair => (&buffers.crosshair, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)), + CursorIcon::Move => (&buffers.move_icon, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)), + CursorIcon::Pointer => (&buffers.pointer, (local.0 - POINTER_HOTSPOT.0, local.1 - POINTER_HOTSPOT.1)), + _ => (&buffers.arrow, (local.0, local.1)), + }, + _ => (&buffers.arrow, (local.0, local.1)), + }; + let at = (at.0 as f64, at.1 as f64); match MemoryRenderBufferRenderElement::from_buffer(renderer, at, buffer, None, None, None, Kind::Cursor) { Ok(e) => vec![OverlayElement::Memory(e)], Err(e) => { @@ -201,4 +522,126 @@ mod tests { assert!(black > 20, "expected a black outline, got {black} px"); assert!(white > 40, "expected a white fill, got {white} px"); } + + #[test] + fn arrow_tail_is_a_single_tapering_shape_not_a_lopsided_fork() { + // Regression test: the tail below the arrowhead used to split into + // two separate legs of visibly different widths (one tapering to a + // point, the other a constant-width block that never tapered) -- + // reported live as "one side is bigger than the other, not + // conventional at all". Each row of the tail must now be a single + // contiguous opaque run starting at column 0 (no gap splitting it + // into two pieces), and its width must never *grow* from the row + // above - a monotonic taper, not a fork. + let buf = arrow_bitmap(); + let opaque_at = |x: usize, y: usize| buf[(y * CURSOR_SIZE as usize + x) * 4 + 3] != 0; + let mut prev_width: Option<usize> = None; + for y in 15..CURSOR_SIZE as usize { + let width = (0..CURSOR_SIZE as usize).take_while(|&x| opaque_at(x, y)).count(); + if width == 0 { + continue; + } + assert!(opaque_at(0, y), "row {y}: tail must start flush at column 0"); + for x in width..CURSOR_SIZE as usize { + assert!(!opaque_at(x, y), "row {y}: found opaque pixel at x={x} past a gap - tail has forked into two pieces"); + } + if let Some(prev) = prev_width { + assert!(width <= prev, "row {y}: tail width grew from {prev} to {width} - not a monotonic taper"); + } + prev_width = Some(width); + } + } + + fn opaque_px_count(buf: &[u8]) -> usize { + buf.chunks_exact(4).filter(|px| px[3] != 0).count() + } + + #[test] + fn text_bitmap_is_the_expected_size_and_draws_something() { + let buf = text_bitmap(); + assert_eq!(buf.len(), (CURSOR_SIZE * CURSOR_SIZE * 4) as usize); + assert!(opaque_px_count(&buf) > 10, "expected a visible I-beam"); + } + + #[test] + fn straight_resize_bitmaps_are_distinguishable_from_each_other() { + let horizontal = straight_resize_bitmap(true); + let vertical = straight_resize_bitmap(false); + assert!(opaque_px_count(&horizontal) > 10); + assert!(opaque_px_count(&vertical) > 10); + // A horizontal double-arrow and a vertical one should not paint the + // exact same pixels - if they did, `render_elements` would be + // silently showing the same shape for both directions. + assert_ne!(horizontal, vertical); + } + + #[test] + fn diagonal_resize_bitmaps_are_distinguishable_from_each_other() { + let nwse = diagonal_resize_bitmap(true); + let nesw = diagonal_resize_bitmap(false); + assert!(opaque_px_count(&nwse) > 10); + assert!(opaque_px_count(&nesw) > 10); + assert_ne!(nwse, nesw); + } + + fn has_white_px(buf: &[u8]) -> bool { + buf.chunks_exact(4).any(|px| px[3] != 0 && px[0] == 0xff && px[1] == 0xff && px[2] == 0xff) + } + + /// The resize/text shapes are drawn as plain opaque black lines (see + /// `set_px`'s doc comment) - with no halo they would be solid black + /// with zero white pixels, i.e. nearly invisible over a dark window. + /// Confirmed live: a screenshot of the resize cursor over a black + /// terminal before this fix showed no visible shape at all. This test + /// would fail against that code. + #[test] + fn resize_and_text_shapes_get_a_visible_halo() { + assert!(has_white_px(&text_bitmap()), "I-beam has no white halo"); + assert!(has_white_px(&straight_resize_bitmap(true)), "ew-resize has no white halo"); + assert!(has_white_px(&straight_resize_bitmap(false)), "ns-resize has no white halo"); + assert!(has_white_px(&diagonal_resize_bitmap(true)), "nwse-resize has no white halo"); + assert!(has_white_px(&diagonal_resize_bitmap(false)), "nesw-resize has no white halo"); + } + + #[test] + fn crosshair_is_the_expected_size_and_draws_something() { + let buf = crosshair_bitmap(); + assert_eq!(buf.len(), (CURSOR_SIZE * CURSOR_SIZE * 4) as usize); + assert!(opaque_px_count(&buf) > 10, "expected a visible crosshair"); + assert!(has_white_px(&buf), "crosshair has no white halo"); + } + + #[test] + fn move_icon_is_the_expected_size_and_draws_something() { + let buf = move_bitmap(); + assert_eq!(buf.len(), (CURSOR_SIZE * CURSOR_SIZE * 4) as usize); + assert!(opaque_px_count(&buf) > 10, "expected a visible move icon"); + assert!(has_white_px(&buf), "move icon has no white halo"); + } + + #[test] + fn pointer_hand_is_the_expected_size_and_draws_something() { + let buf = pointer_bitmap(); + assert_eq!(buf.len(), (CURSOR_SIZE * CURSOR_SIZE * 4) as usize); + assert!(opaque_px_count(&buf) > 10, "expected a visible pointer hand"); + assert!(has_white_px(&buf), "pointer hand has no white halo"); + // The fingertip (the hotspot) must actually be opaque, same + // requirement the arrow's tip-pixel test already checks - otherwise + // the cursor would appear offset from where clicks land. + let (hx, hy) = POINTER_HOTSPOT; + let i = ((hy * CURSOR_SIZE + hx) * 4 + 3) as usize; + assert_eq!(buf[i], 0xff, "fingertip hotspot pixel must be opaque"); + } + + #[test] + fn crosshair_move_and_pointer_are_distinguishable_from_each_other_and_from_existing_shapes() { + let shapes = [crosshair_bitmap(), move_bitmap(), pointer_bitmap(), text_bitmap(), straight_resize_bitmap(true), diagonal_resize_bitmap(true)]; + for (i, a) in shapes.iter().enumerate() { + for (j, b) in shapes.iter().enumerate() { + if i != j { + assert_ne!(a, b, "shapes {i} and {j} render identically"); + } + } + } + } } |