Skip to main content

concinnity_render/
cursor.rs

1//! In-engine mouse cursor geometry. A `follow_cursor` Sprite is drawn as a
2//! classic arrow pointer rather than a plain quad: a filled polygon with a
3//! contrasting outline so it stays legible over any scene. Like the rest of the
4//! UI overlay, it rides the text pass's sentinel-UV solid-fill path (u < 0), so
5//! it needs no new pipeline and renders on every backend. The arrow's diagonal
6//! edges are real geometry, not a stair-stepped stack of quads.
7
8// `build_cursor_calls` below is test-only, and it is the only Vec here.
9use 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
16// Arrow silhouette in a normalised space: tip (the hotspot) at the origin,
17// pointing down-right, height 1.0 and width ~0.62. Vertices run clockwise
18// around the boundary in screen space (y grows downward):
19//   V0 tip, V1 left-edge foot, V2 inner notch, V3 tail tip,
20//   V4 tail heel, V5 barb root, V6 right barb.
21const 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
31// Triangulation of the arrow: a fan over the head (tip to the two barbs) plus
32// the small tail quad. Indices reference ARROW.
33const ARROW_TRIS: [[u16; 3]; 5] = [[0, 1, 2], [0, 2, 5], [0, 5, 6], [2, 3, 4], [2, 4, 5]];
34
35// A double-headed resize arrow centred on the hotspot, pointing east/west; the
36// other resize axes are this same silhouette rotated (see `cursor_geometry`). A
37// shaft rectangle capped by a triangular head at each end. y grows downward.
38//   V0 left tip, V1/V2 left head top/bottom, V3..V6 shaft corners,
39//   V7/V8 right head top/bottom, V9 right tip.
40const 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
53// The two head triangles and the two shaft triangles. Indices reference RESIZE_ARROW.
54const RESIZE_TRIS: [[u16; 3]; 4] = [[0, 1, 2], [3, 4, 5], [3, 5, 6], [9, 7, 8]];
55
56// Eight unit directions used to stamp the outline around the fill, giving an
57// even border ring of one outline-width radius.
58const 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
69// Outline width as a fraction of the cursor height, floored at one pixel.
70const OUTLINE_RATIO: f32 = 0.085;
71// Arrow height in pixels when a cursor sprite leaves its size unset.
72const DEFAULT_CURSOR_PX: f32 = 22.0;
73// The cursor sorts above every other overlay layer: a screen stack or the
74// editor lifts its elements above 0, and at layer 0 the arrow would sort
75// beneath the menu it points at.
76const CURSOR_LAYER: i32 = i32::MAX;
77
78// Build the cursor draw calls (one mesh per visible `follow_cursor` sprite) at
79// the pointer, drawing `shape`'s silhouette (the arrow, or a resize double-arrow
80// over a `cn editor` panel edge). Each sprite's tint is the fill colour and its
81// `height` the cursor height; `width` is ignored so the silhouette keeps its
82// aspect ratio. The height is authored in the reference canvas, so it is scaled
83// by the overlay factor for `viewport` to stay proportional with the menu it
84// belongs to; the pointer stays at the live cursor position. Returns empty when
85// no font atlas is loaded (the text pipeline is inactive then).
86#[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
106/// `build_cursor_calls`, appending onto an existing draw list. Sprites without
107/// `follow_cursor` are skipped, so the caller can pass its whole sprite slice.
108pub 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            // The cursor is never clipped: it draws on top of everything.
145            clip_rect: None,
146            layer: CURSOR_LAYER,
147        };
148        // Outline first so the fill, appended after, composites on top of it
149        // (the overlay draws indexed triangles in order, with no depth test).
150        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
159// A cursor silhouette: its boundary vertices, triangulation, and the rotation
160// (cos, sin) applied to place it on its axis.
161struct Silhouette {
162    verts: &'static [(f32, f32)],
163    tris: &'static [[u16; 3]],
164    rot: (f32, f32),
165}
166
167// The silhouette for `shape`. The arrow is unrotated (hotspot at its tip); each
168// resize cursor is the shared horizontal double-arrow rotated onto its axis
169// (hotspot at its centre).
170fn 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
191// Append one silhouette (rotated and scaled by `size`, hotspot at `origin`) to a
192// draw call.
193fn 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            // sentinel u < 0 -> solid-fill path; v carries alpha
209            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
221// Pick an outline that contrasts the fill: a near-black border under a light
222// cursor, a near-white border under a dark one. Keeps any tint legible.
223fn 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        // Eight outline stamps plus one fill, seven vertices each.
283        assert_eq!(calls[0].vertices.len(), 9 * ARROW.len());
284        assert_eq!(calls[0].indices.len(), 9 * ARROW_TRIS.len() * 3);
285        // The tip of the fill arrow (last stamp's first vertex) sits exactly on
286        // the pointer; the outline stamps are offset off it.
287        let tip = calls[0].vertices[8 * ARROW.len()];
288        assert_eq!(tip.pos, [100.0, 50.0]);
289        // Fill keeps the sprite tint; outline does not.
290        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        // Every vertex uses the solid-fill sentinel and carries the alpha.
293        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        // Light fill -> dark outline, dark fill -> light outline.
329        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        // The lowest vertex (tail tip, ny = 1.0) reaches the default height.
344        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        // At twice the reference size the overlay scale is 2.0, so the arrow
355        // height doubles while the tip stays on the pointer. Measure the fill
356        // arrow (the last stamp) so the outline ring's extra width is excluded.
357        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        // Tail tip (ny = 1.0) at pointer y = 0 reaches the doubled height.
368        assert!((max_y - 44.0).abs() < 1e-3, "max_y={max_y}");
369    }
370
371    // A resize shape draws the double-arrow silhouette centred on the pointer
372    // (both tips equidistant), unlike the arrow whose hotspot is its tip.
373    #[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        // Eight outline stamps plus one fill, ten vertices each.
385        assert_eq!(calls[0].vertices.len(), 9 * RESIZE_ARROW.len());
386        assert_eq!(calls[0].indices.len(), 9 * RESIZE_TRIS.len() * 3);
387        // The fill's two tips (V0 left, V9 right) sit either side of the pointer.
388        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    // The vertical resize cursor is the horizontal one rotated onto the y axis:
403    // its tips straddle the pointer in y, not x.
404    #[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    // The outline stamp pushes the tail a fraction of a pixel past the fill
425    // height, so allow a small tolerance in the size check.
426    const OUTLINE_PX_TOLERANCE: f32 = 2.0;
427}