1use crate::components::Sprite;
10use crate::ecs::CursorShape;
11use crate::render_types::{TextDrawCall, TextVertex};
12#[cfg(test)]
13use alloc::vec::Vec;
14use concinnity_core::gfx::overlay::OverlayTransform;
15
16const ARROW: [(f32, f32); 7] = [
22 (0.00, 0.00),
23 (0.00, 0.86),
24 (0.21, 0.65),
25 (0.35, 1.00),
26 (0.50, 0.93),
27 (0.35, 0.59),
28 (0.62, 0.59),
29];
30
31const ARROW_TRIS: [[u16; 3]; 5] = [[0, 1, 2], [0, 2, 5], [0, 5, 6], [2, 3, 4], [2, 4, 5]];
34
35const RESIZE_ARROW: [(f32, f32); 10] = [
41 (-0.50, 0.00),
42 (-0.24, -0.22),
43 (-0.24, 0.22),
44 (-0.24, -0.08),
45 (0.24, -0.08),
46 (0.24, 0.08),
47 (-0.24, 0.08),
48 (0.24, -0.22),
49 (0.24, 0.22),
50 (0.50, 0.00),
51];
52
53const RESIZE_TRIS: [[u16; 3]; 4] = [[0, 1, 2], [3, 4, 5], [3, 5, 6], [9, 7, 8]];
55
56const OUTLINE_OFFSETS: [(f32, f32); 8] = [
59 (1.0, 0.0),
60 (-1.0, 0.0),
61 (0.0, 1.0),
62 (0.0, -1.0),
63 (0.707, 0.707),
64 (-0.707, 0.707),
65 (0.707, -0.707),
66 (-0.707, -0.707),
67];
68
69const OUTLINE_RATIO: f32 = 0.085;
71const DEFAULT_CURSOR_PX: f32 = 22.0;
73const CURSOR_LAYER: i32 = i32::MAX;
77
78#[cfg(test)]
87pub(crate) fn build_cursor_calls(
88 sprites: &[Sprite],
89 pointer: (f32, f32),
90 shape: CursorShape,
91 default_atlas_slot: Option<usize>,
92 viewport: [f32; 2],
93) -> Vec<TextDrawCall> {
94 let mut out = crate::call_buffer::TextCallBuffer::default();
95 build_cursor_calls_into(
96 &mut out,
97 sprites,
98 pointer,
99 shape,
100 default_atlas_slot,
101 viewport,
102 );
103 out.take()
104}
105
106pub fn build_cursor_calls_into(
109 out: &mut crate::call_buffer::TextCallBuffer,
110 sprites: &[Sprite],
111 pointer: (f32, f32),
112 shape: CursorShape,
113 default_atlas_slot: Option<usize>,
114 viewport: [f32; 2],
115) {
116 let atlas_slot = match default_atlas_slot {
117 Some(s) => s,
118 None => return,
119 };
120 let overlay_scale = OverlayTransform::from_viewport(viewport).scale();
121 let sil = cursor_geometry(shape);
122 for s in sprites {
123 if !s.follow_cursor || !s.visible {
124 continue;
125 }
126 let alpha = s.tint[3];
127 if alpha <= 0.0 {
128 continue;
129 }
130 let size = if s.height > 0.0 {
131 s.height
132 } else {
133 DEFAULT_CURSOR_PX
134 } * overlay_scale;
135 let fill = [s.tint[0], s.tint[1], s.tint[2]];
136 let outline = outline_color(fill);
137 let outline_w = (size * OUTLINE_RATIO).max(1.0);
138
139 let (vertices, indices) = out.geometry();
140 let mut call = TextDrawCall {
141 vertices,
142 indices,
143 atlas_slot,
144 clip_rect: None,
146 layer: CURSOR_LAYER,
147 };
148 for (dx, dy) in OUTLINE_OFFSETS {
151 let o = (pointer.0 + dx * outline_w, pointer.1 + dy * outline_w);
152 push_shape(&mut call, o, size, outline, alpha, &sil);
153 }
154 push_shape(&mut call, pointer, size, fill, alpha, &sil);
155 out.calls.push(call);
156 }
157}
158
159struct Silhouette {
162 verts: &'static [(f32, f32)],
163 tris: &'static [[u16; 3]],
164 rot: (f32, f32),
165}
166
167fn cursor_geometry(shape: CursorShape) -> Silhouette {
171 const DIAG: f32 = core::f32::consts::FRAC_1_SQRT_2;
172 let arrow = || Silhouette {
173 verts: &ARROW[..],
174 tris: &ARROW_TRIS[..],
175 rot: (1.0, 0.0),
176 };
177 let resize = |rot| Silhouette {
178 verts: &RESIZE_ARROW[..],
179 tris: &RESIZE_TRIS[..],
180 rot,
181 };
182 match shape {
183 CursorShape::Default => arrow(),
184 CursorShape::ResizeEW => resize((1.0, 0.0)),
185 CursorShape::ResizeNS => resize((0.0, 1.0)),
186 CursorShape::ResizeNWSE => resize((DIAG, DIAG)),
187 CursorShape::ResizeNESW => resize((DIAG, -DIAG)),
188 }
189}
190
191fn push_shape(
194 call: &mut TextDrawCall,
195 origin: (f32, f32),
196 size: f32,
197 color: [f32; 3],
198 alpha: f32,
199 sil: &Silhouette,
200) {
201 let base = call.vertices.len() as u16;
202 let (c, s) = sil.rot;
203 for &(nx, ny) in sil.verts {
204 let rx = nx * c - ny * s;
205 let ry = nx * s + ny * c;
206 call.vertices.push(TextVertex {
207 pos: [origin.0 + rx * size, origin.1 + ry * size],
208 uv: [-1.0, alpha],
210 color,
211 mode: 0.0,
212 });
213 }
214 for tri in sil.tris {
215 call.indices.push(base + tri[0]);
216 call.indices.push(base + tri[1]);
217 call.indices.push(base + tri[2]);
218 }
219}
220
221fn outline_color(fill: [f32; 3]) -> [f32; 3] {
224 let luma = 0.299 * fill[0] + 0.587 * fill[1] + 0.114 * fill[2];
225 if luma > 0.5 {
226 [0.05, 0.05, 0.06]
227 } else {
228 [0.95, 0.95, 0.96]
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use crate::ecs::asset_id::AssetId;
236
237 fn cursor(tint: [f32; 4], height: f32) -> Sprite {
238 Sprite {
239 asset_id: AssetId::default(),
240 x: 0.0,
241 y: 0.0,
242 width: height,
243 height,
244 texture: None,
245 tint,
246 follow_cursor: true,
247 visible: true,
248 screen: None,
249 fit: crate::components::SpriteFit::Fit,
250 corner_radius: 0.0,
251 border_width: 0.0,
252 border_color: [0.0, 0.0, 0.0, 1.0],
253 }
254 }
255
256 #[test]
257 fn no_fonts_means_no_calls() {
258 let c = cursor([1.0, 1.0, 1.0, 1.0], 22.0);
259 assert!(
260 build_cursor_calls(
261 core::slice::from_ref(&c),
262 (10.0, 10.0),
263 CursorShape::Default,
264 None,
265 [0.0, 0.0]
266 )
267 .is_empty()
268 );
269 }
270
271 #[test]
272 fn builds_outline_then_fill_mesh() {
273 let c = cursor([1.0, 1.0, 1.0, 1.0], 22.0);
274 let calls = build_cursor_calls(
275 core::slice::from_ref(&c),
276 (100.0, 50.0),
277 CursorShape::Default,
278 Some(0),
279 [0.0, 0.0],
280 );
281 assert_eq!(calls.len(), 1);
282 assert_eq!(calls[0].vertices.len(), 9 * ARROW.len());
284 assert_eq!(calls[0].indices.len(), 9 * ARROW_TRIS.len() * 3);
285 let tip = calls[0].vertices[8 * ARROW.len()];
288 assert_eq!(tip.pos, [100.0, 50.0]);
289 assert_eq!(tip.color, [1.0, 1.0, 1.0]);
291 assert_ne!(calls[0].vertices[0].color, [1.0, 1.0, 1.0]);
292 for v in &calls[0].vertices {
294 assert!(v.uv[0] < 0.0);
295 assert!((v.uv[1] - 1.0).abs() < 1e-6);
296 }
297 }
298
299 #[test]
300 fn invisible_or_transparent_cursor_is_skipped() {
301 let mut hidden = cursor([1.0, 1.0, 1.0, 1.0], 22.0);
302 hidden.visible = false;
303 assert!(
304 build_cursor_calls(
305 core::slice::from_ref(&hidden),
306 (0.0, 0.0),
307 CursorShape::Default,
308 Some(0),
309 [0.0, 0.0]
310 )
311 .is_empty()
312 );
313 let clear = cursor([1.0, 1.0, 1.0, 0.0], 22.0);
314 assert!(
315 build_cursor_calls(
316 core::slice::from_ref(&clear),
317 (0.0, 0.0),
318 CursorShape::Default,
319 Some(0),
320 [0.0, 0.0]
321 )
322 .is_empty()
323 );
324 }
325
326 #[test]
327 fn outline_contrasts_the_fill() {
328 assert!(outline_color([1.0, 1.0, 1.0])[0] < 0.5);
330 assert!(outline_color([0.0, 0.0, 0.0])[0] > 0.5);
331 }
332
333 #[test]
334 fn unset_height_falls_back_to_default_size() {
335 let c = cursor([1.0, 1.0, 1.0, 1.0], 0.0);
336 let calls = build_cursor_calls(
337 core::slice::from_ref(&c),
338 (0.0, 0.0),
339 CursorShape::Default,
340 Some(0),
341 [0.0, 0.0],
342 );
343 let max_y = calls[0]
345 .vertices
346 .iter()
347 .map(|v| v.pos[1])
348 .fold(f32::MIN, f32::max);
349 assert!((max_y - DEFAULT_CURSOR_PX).abs() < OUTLINE_PX_TOLERANCE);
350 }
351
352 #[test]
353 fn arrow_scales_with_the_overlay() {
354 let c = cursor([1.0, 1.0, 1.0, 1.0], 22.0);
358 let calls = build_cursor_calls(
359 core::slice::from_ref(&c),
360 (0.0, 0.0),
361 CursorShape::Default,
362 Some(0),
363 [2560.0, 1440.0],
364 );
365 let fill = &calls[0].vertices[8 * ARROW.len()..];
366 let max_y = fill.iter().map(|v| v.pos[1]).fold(f32::MIN, f32::max);
367 assert!((max_y - 44.0).abs() < 1e-3, "max_y={max_y}");
369 }
370
371 #[test]
374 fn resize_shape_draws_a_centered_double_arrow() {
375 let c = cursor([1.0, 1.0, 1.0, 1.0], 20.0);
376 let calls = build_cursor_calls(
377 core::slice::from_ref(&c),
378 (100.0, 100.0),
379 CursorShape::ResizeEW,
380 Some(0),
381 [0.0, 0.0],
382 );
383 assert_eq!(calls.len(), 1);
384 assert_eq!(calls[0].vertices.len(), 9 * RESIZE_ARROW.len());
386 assert_eq!(calls[0].indices.len(), 9 * RESIZE_TRIS.len() * 3);
387 let fill = &calls[0].vertices[8 * RESIZE_ARROW.len()..];
389 let left = fill[0].pos;
390 let right = fill[9].pos;
391 assert!(
392 left[0] < 100.0 && right[0] > 100.0,
393 "tips straddle the pointer x"
394 );
395 assert!((left[1] - 100.0).abs() < 1e-4 && (right[1] - 100.0).abs() < 1e-4);
396 assert!(
397 ((100.0 - left[0]) - (right[0] - 100.0)).abs() < 1e-4,
398 "the pointer is centred between the tips"
399 );
400 }
401
402 #[test]
405 fn resize_ns_rotates_onto_the_vertical_axis() {
406 let c = cursor([1.0, 1.0, 1.0, 1.0], 20.0);
407 let calls = build_cursor_calls(
408 core::slice::from_ref(&c),
409 (100.0, 100.0),
410 CursorShape::ResizeNS,
411 Some(0),
412 [0.0, 0.0],
413 );
414 let fill = &calls[0].vertices[8 * RESIZE_ARROW.len()..];
415 let top = fill[0].pos;
416 let bottom = fill[9].pos;
417 assert!((top[0] - 100.0).abs() < 1e-4 && (bottom[0] - 100.0).abs() < 1e-4);
418 assert!(
419 top[1] < 100.0 && bottom[1] > 100.0,
420 "tips straddle the pointer y"
421 );
422 }
423
424 const OUTLINE_PX_TOLERANCE: f32 = 2.0;
427}