use crate::components::Sprite;
use crate::ecs::CursorShape;
use crate::render_types::{TextDrawCall, TextVertex};
#[cfg(test)]
use alloc::vec::Vec;
use concinnity_core::gfx::overlay::OverlayTransform;
const ARROW: [(f32, f32); 7] = [
(0.00, 0.00),
(0.00, 0.86),
(0.21, 0.65),
(0.35, 1.00),
(0.50, 0.93),
(0.35, 0.59),
(0.62, 0.59),
];
const ARROW_TRIS: [[u16; 3]; 5] = [[0, 1, 2], [0, 2, 5], [0, 5, 6], [2, 3, 4], [2, 4, 5]];
const RESIZE_ARROW: [(f32, f32); 10] = [
(-0.50, 0.00),
(-0.24, -0.22),
(-0.24, 0.22),
(-0.24, -0.08),
(0.24, -0.08),
(0.24, 0.08),
(-0.24, 0.08),
(0.24, -0.22),
(0.24, 0.22),
(0.50, 0.00),
];
const RESIZE_TRIS: [[u16; 3]; 4] = [[0, 1, 2], [3, 4, 5], [3, 5, 6], [9, 7, 8]];
const OUTLINE_OFFSETS: [(f32, f32); 8] = [
(1.0, 0.0),
(-1.0, 0.0),
(0.0, 1.0),
(0.0, -1.0),
(0.707, 0.707),
(-0.707, 0.707),
(0.707, -0.707),
(-0.707, -0.707),
];
const OUTLINE_RATIO: f32 = 0.085;
const DEFAULT_CURSOR_PX: f32 = 22.0;
const CURSOR_LAYER: i32 = i32::MAX;
#[cfg(test)]
pub(crate) fn build_cursor_calls(
sprites: &[Sprite],
pointer: (f32, f32),
shape: CursorShape,
default_atlas_slot: Option<usize>,
viewport: [f32; 2],
) -> Vec<TextDrawCall> {
let mut out = crate::call_buffer::TextCallBuffer::default();
build_cursor_calls_into(
&mut out,
sprites,
pointer,
shape,
default_atlas_slot,
viewport,
);
out.take()
}
pub fn build_cursor_calls_into(
out: &mut crate::call_buffer::TextCallBuffer,
sprites: &[Sprite],
pointer: (f32, f32),
shape: CursorShape,
default_atlas_slot: Option<usize>,
viewport: [f32; 2],
) {
let atlas_slot = match default_atlas_slot {
Some(s) => s,
None => return,
};
let overlay_scale = OverlayTransform::from_viewport(viewport).scale();
let sil = cursor_geometry(shape);
for s in sprites {
if !s.follow_cursor || !s.visible {
continue;
}
let alpha = s.tint[3];
if alpha <= 0.0 {
continue;
}
let size = if s.height > 0.0 {
s.height
} else {
DEFAULT_CURSOR_PX
} * overlay_scale;
let fill = [s.tint[0], s.tint[1], s.tint[2]];
let outline = outline_color(fill);
let outline_w = (size * OUTLINE_RATIO).max(1.0);
let (vertices, indices) = out.geometry();
let mut call = TextDrawCall {
vertices,
indices,
atlas_slot,
clip_rect: None,
layer: CURSOR_LAYER,
};
for (dx, dy) in OUTLINE_OFFSETS {
let o = (pointer.0 + dx * outline_w, pointer.1 + dy * outline_w);
push_shape(&mut call, o, size, outline, alpha, &sil);
}
push_shape(&mut call, pointer, size, fill, alpha, &sil);
out.calls.push(call);
}
}
struct Silhouette {
verts: &'static [(f32, f32)],
tris: &'static [[u16; 3]],
rot: (f32, f32),
}
fn cursor_geometry(shape: CursorShape) -> Silhouette {
const DIAG: f32 = core::f32::consts::FRAC_1_SQRT_2;
let arrow = || Silhouette {
verts: &ARROW[..],
tris: &ARROW_TRIS[..],
rot: (1.0, 0.0),
};
let resize = |rot| Silhouette {
verts: &RESIZE_ARROW[..],
tris: &RESIZE_TRIS[..],
rot,
};
match shape {
CursorShape::Default => arrow(),
CursorShape::ResizeEW => resize((1.0, 0.0)),
CursorShape::ResizeNS => resize((0.0, 1.0)),
CursorShape::ResizeNWSE => resize((DIAG, DIAG)),
CursorShape::ResizeNESW => resize((DIAG, -DIAG)),
}
}
fn push_shape(
call: &mut TextDrawCall,
origin: (f32, f32),
size: f32,
color: [f32; 3],
alpha: f32,
sil: &Silhouette,
) {
let base = call.vertices.len() as u16;
let (c, s) = sil.rot;
for &(nx, ny) in sil.verts {
let rx = nx * c - ny * s;
let ry = nx * s + ny * c;
call.vertices.push(TextVertex {
pos: [origin.0 + rx * size, origin.1 + ry * size],
uv: [-1.0, alpha],
color,
mode: 0.0,
});
}
for tri in sil.tris {
call.indices.push(base + tri[0]);
call.indices.push(base + tri[1]);
call.indices.push(base + tri[2]);
}
}
fn outline_color(fill: [f32; 3]) -> [f32; 3] {
let luma = 0.299 * fill[0] + 0.587 * fill[1] + 0.114 * fill[2];
if luma > 0.5 {
[0.05, 0.05, 0.06]
} else {
[0.95, 0.95, 0.96]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ecs::asset_id::AssetId;
fn cursor(tint: [f32; 4], height: f32) -> Sprite {
Sprite {
asset_id: AssetId::default(),
x: 0.0,
y: 0.0,
width: height,
height,
texture: None,
tint,
follow_cursor: true,
visible: true,
screen: None,
fit: crate::components::SpriteFit::Fit,
corner_radius: 0.0,
border_width: 0.0,
border_color: [0.0, 0.0, 0.0, 1.0],
}
}
#[test]
fn no_fonts_means_no_calls() {
let c = cursor([1.0, 1.0, 1.0, 1.0], 22.0);
assert!(
build_cursor_calls(
core::slice::from_ref(&c),
(10.0, 10.0),
CursorShape::Default,
None,
[0.0, 0.0]
)
.is_empty()
);
}
#[test]
fn builds_outline_then_fill_mesh() {
let c = cursor([1.0, 1.0, 1.0, 1.0], 22.0);
let calls = build_cursor_calls(
core::slice::from_ref(&c),
(100.0, 50.0),
CursorShape::Default,
Some(0),
[0.0, 0.0],
);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].vertices.len(), 9 * ARROW.len());
assert_eq!(calls[0].indices.len(), 9 * ARROW_TRIS.len() * 3);
let tip = calls[0].vertices[8 * ARROW.len()];
assert_eq!(tip.pos, [100.0, 50.0]);
assert_eq!(tip.color, [1.0, 1.0, 1.0]);
assert_ne!(calls[0].vertices[0].color, [1.0, 1.0, 1.0]);
for v in &calls[0].vertices {
assert!(v.uv[0] < 0.0);
assert!((v.uv[1] - 1.0).abs() < 1e-6);
}
}
#[test]
fn invisible_or_transparent_cursor_is_skipped() {
let mut hidden = cursor([1.0, 1.0, 1.0, 1.0], 22.0);
hidden.visible = false;
assert!(
build_cursor_calls(
core::slice::from_ref(&hidden),
(0.0, 0.0),
CursorShape::Default,
Some(0),
[0.0, 0.0]
)
.is_empty()
);
let clear = cursor([1.0, 1.0, 1.0, 0.0], 22.0);
assert!(
build_cursor_calls(
core::slice::from_ref(&clear),
(0.0, 0.0),
CursorShape::Default,
Some(0),
[0.0, 0.0]
)
.is_empty()
);
}
#[test]
fn outline_contrasts_the_fill() {
assert!(outline_color([1.0, 1.0, 1.0])[0] < 0.5);
assert!(outline_color([0.0, 0.0, 0.0])[0] > 0.5);
}
#[test]
fn unset_height_falls_back_to_default_size() {
let c = cursor([1.0, 1.0, 1.0, 1.0], 0.0);
let calls = build_cursor_calls(
core::slice::from_ref(&c),
(0.0, 0.0),
CursorShape::Default,
Some(0),
[0.0, 0.0],
);
let max_y = calls[0]
.vertices
.iter()
.map(|v| v.pos[1])
.fold(f32::MIN, f32::max);
assert!((max_y - DEFAULT_CURSOR_PX).abs() < OUTLINE_PX_TOLERANCE);
}
#[test]
fn arrow_scales_with_the_overlay() {
let c = cursor([1.0, 1.0, 1.0, 1.0], 22.0);
let calls = build_cursor_calls(
core::slice::from_ref(&c),
(0.0, 0.0),
CursorShape::Default,
Some(0),
[2560.0, 1440.0],
);
let fill = &calls[0].vertices[8 * ARROW.len()..];
let max_y = fill.iter().map(|v| v.pos[1]).fold(f32::MIN, f32::max);
assert!((max_y - 44.0).abs() < 1e-3, "max_y={max_y}");
}
#[test]
fn resize_shape_draws_a_centered_double_arrow() {
let c = cursor([1.0, 1.0, 1.0, 1.0], 20.0);
let calls = build_cursor_calls(
core::slice::from_ref(&c),
(100.0, 100.0),
CursorShape::ResizeEW,
Some(0),
[0.0, 0.0],
);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].vertices.len(), 9 * RESIZE_ARROW.len());
assert_eq!(calls[0].indices.len(), 9 * RESIZE_TRIS.len() * 3);
let fill = &calls[0].vertices[8 * RESIZE_ARROW.len()..];
let left = fill[0].pos;
let right = fill[9].pos;
assert!(
left[0] < 100.0 && right[0] > 100.0,
"tips straddle the pointer x"
);
assert!((left[1] - 100.0).abs() < 1e-4 && (right[1] - 100.0).abs() < 1e-4);
assert!(
((100.0 - left[0]) - (right[0] - 100.0)).abs() < 1e-4,
"the pointer is centred between the tips"
);
}
#[test]
fn resize_ns_rotates_onto_the_vertical_axis() {
let c = cursor([1.0, 1.0, 1.0, 1.0], 20.0);
let calls = build_cursor_calls(
core::slice::from_ref(&c),
(100.0, 100.0),
CursorShape::ResizeNS,
Some(0),
[0.0, 0.0],
);
let fill = &calls[0].vertices[8 * RESIZE_ARROW.len()..];
let top = fill[0].pos;
let bottom = fill[9].pos;
assert!((top[0] - 100.0).abs() < 1e-4 && (bottom[0] - 100.0).abs() < 1e-4);
assert!(
top[1] < 100.0 && bottom[1] > 100.0,
"tips straddle the pointer y"
);
}
const OUTLINE_PX_TOLERANCE: f32 = 2.0;
}