Skip to main content

shuyi_ui/grid/glyph/
cursor.rs

1//! How the glyph skin draws the cursor (game ADR-0024): thin corner marks
2//! around the tile, or a glow on it.
3
4use super::{CELL_PX, Layout, TILE_W_CELLS};
5use crate::frame::color::Rgb;
6use crate::frame::glyph_buffer::{GlyphBuffer, Layer, Overlay, Rect};
7use crate::grid::layout::px_rect;
8use crate::grid::scene::{CursorStyle, CursorView};
9
10/// How strongly the tile glow tints the tile at full brightness (`0` = not
11/// at all, `1` = solid `cursor` colour). *Tunable.*
12pub const GLOW_MAX: f32 = 0.3;
13
14/// How long each arm of the corner marks is, in pixels.
15pub const CORNER_ARM: i32 = 3;
16
17/// How long each arm of the large corner marks is, in pixels.
18pub const LARGE_CORNER_ARM: i32 = 4;
19
20/// How the cursor looks: its color, the color it fades toward as it
21/// pulses (the console's background, which a light theme makes light), and
22/// how many pixels at a tile's foot its lower arms keep clear of (a game's
23/// HP bar).
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct CursorInk {
26    /// At full brightness.
27    pub color: Rgb,
28    /// What it fades toward.
29    pub dark: Rgb,
30    /// The pixels left clear at the foot of the tile.
31    pub foot_px: i32,
32}
33
34/// Draws `cursor` in its style on the tile whose left cell is `(x, y)`,
35/// clipped to the tiles of `layout`.
36///
37/// Corner marks are 1 px `Over` overlays that stay clear of every letter
38/// (game ADR-0024): their vertical arms sit in the pixel column just left
39/// of the tile and in the tile's last pixel column, which the font never
40/// inks; their horizontal arms sit on the tile's top row and on the row
41/// just above the tile's foot ([`CursorInk::foot_px`]), outside the
42/// letters' rows. So a neighbour's letters always show.
43pub fn draw_cursor(
44    buf: &mut GlyphBuffer,
45    ink: CursorInk,
46    cursor: &CursorView,
47    layout: &Layout,
48    (x, y): (i32, i32),
49) {
50    let color = ink.color;
51    let view = layout.cells();
52    let arm = match cursor.style {
53        CursorStyle::Corners => CORNER_ARM,
54        CursorStyle::LargeCorners => LARGE_CORNER_ARM,
55        CursorStyle::TileGlow => {
56            let tile = Rect::new(x, y, TILE_W_CELLS, 1);
57            if let Some(tile) = tile.intersect(&view) {
58                buf.blend_bg(tile, color, GLOW_MAX * cursor.brightness);
59            }
60            return;
61        }
62    };
63    // Fades toward the console background, which a light theme makes light.
64    let fg = color.lerp(ink.dark, 1.0 - cursor.brightness);
65    let view = px_rect(view);
66    for r in corner_arms(x, y, arm, ink.foot_px) {
67        if let Some(r) = r.intersect(&view) {
68            buf.add_overlay(Overlay::new(r, fg, Layer::Over));
69        }
70    }
71}
72
73/// The eight 1 px arms of the corner marks around the tile whose left cell
74/// is `(x, y)`, each `arm` px long, in console pixels, the lower ones just
75/// above the `foot_px` pixels at the tile's foot.
76pub fn corner_arms(x: i32, y: i32, arm: i32, foot_px: i32) -> [Rect; 8] {
77    let (cw, ch) = CELL_PX;
78    // Left arms in the left neighbour's last (always blank) pixel column,
79    // right arms in this tile's last one; top arms on the tile's first row,
80    // bottom arms on the row above the tile's foot.
81    let left = x * cw - 1;
82    let right = (x + TILE_W_CELLS) * cw - 1;
83    let top = y * ch;
84    let bottom = top + ch - foot_px - 1;
85    let across = |x0, y0| Rect::new(x0, y0, arm, 1);
86    let down = |x0, y0| Rect::new(x0, y0, 1, arm);
87    [
88        across(left, top),
89        down(left, top),
90        across(right - arm + 1, top),
91        down(right, top),
92        across(left, bottom),
93        down(left, bottom - arm + 1),
94        across(right - arm + 1, bottom),
95        down(right, bottom - arm + 1),
96    ]
97}
98
99#[cfg(test)]
100mod tests {
101    use proptest::prelude::*;
102    use shuyi_core::Pos;
103
104    use super::*;
105    use crate::frame::glyph_buffer::Cell;
106    use crate::grid::cursor::Cursor;
107    use crate::grid::glyph::tests::{AREA, layout_at};
108    use crate::prelude::*;
109
110    /// The cursor's color, and what it fades toward.
111    const CURSOR: Rgb = Rgb::new(250, 240, 90);
112    const DARK: Rgb = Rgb::new(0, 0, 0);
113
114    /// Its look over a 2 px HP bar.
115    const INK: CursorInk = CursorInk {
116        color: CURSOR,
117        dark: DARK,
118        foot_px: 2,
119    };
120
121    /// Draws `cursor` in `style` on the tile whose left cell is `(x, y)` of
122    /// a 70 × 30-cell map area.
123    fn draw(buf: &mut GlyphBuffer, cursor: &Cursor, style: CursorStyle, x: i32, y: i32) {
124        let view = CursorView {
125            pos: cursor.pos,
126            brightness: cursor.brightness(),
127            style,
128        };
129        draw_cursor(buf, INK, &view, &layout_at(Pos::new(0, 0)), (x, y));
130    }
131
132    /// A 100 × 32 buffer of `.` on a dark background.
133    fn dots() -> GlyphBuffer {
134        GlyphBuffer::new(
135            100,
136            32,
137            Cell::new('.', Rgb::new(1, 1, 1), Rgb::new(9, 9, 9)),
138        )
139    }
140
141    fn rects(buf: &GlyphBuffer) -> Vec<Rect> {
142        buf.overlays().iter().map(|o| o.rect).collect()
143    }
144
145    #[test]
146    fn corner_marks_frame_the_tile_outside_the_letters() {
147        let c = Cursor::new(Pos::new(0, 0));
148        let mut buf = dots();
149        let before = buf.clone();
150        // Tile at cells (10, 4)-(11, 4): pixels x 80..96, y 64..80.
151        draw(&mut buf, &c, CursorStyle::Corners, 10, 4);
152        let r = Rect::new;
153        assert_eq!(
154            rects(&buf),
155            [
156                r(79, 64, 3, 1),
157                r(79, 64, 1, 3),
158                r(93, 64, 3, 1),
159                r(95, 64, 1, 3),
160                r(79, 77, 3, 1),
161                r(79, 75, 1, 3),
162                r(93, 77, 3, 1),
163                r(95, 75, 1, 3),
164            ]
165        );
166        for o in buf.overlays() {
167            assert_eq!((o.color, o.layer), (CURSOR, Layer::Over));
168        }
169        for (cx, cy) in (0..100).flat_map(|cx| (0..32).map(move |cy| (cx, cy))) {
170            assert_eq!(buf.get(cx, cy), before.get(cx, cy), "cell ({cx}, {cy})");
171        }
172    }
173
174    #[test]
175    fn the_lower_arms_sit_just_above_the_tiles_foot() {
176        // With nothing at the foot: on the tile's last row.
177        let arms = corner_arms(10, 4, 3, 0);
178        assert_eq!(arms[4], Rect::new(79, 79, 3, 1));
179        assert_eq!(arms[5], Rect::new(79, 77, 1, 3));
180        // Above a 4 px bar.
181        let arms = corner_arms(10, 4, 3, 4);
182        assert_eq!(arms[6], Rect::new(93, 75, 3, 1));
183        assert_eq!(arms[7], Rect::new(95, 73, 1, 3));
184        // The upper arms never move.
185        assert_eq!(corner_arms(10, 4, 3, 4)[..4], corner_arms(10, 4, 3, 0)[..4]);
186    }
187
188    #[test]
189    fn large_corners_have_longer_arms() {
190        let mut buf = dots();
191        draw(
192            &mut buf,
193            &Cursor::new(Pos::new(0, 0)),
194            CursorStyle::LargeCorners,
195            10,
196            4,
197        );
198        let r = Rect::new;
199        assert_eq!(
200            rects(&buf),
201            [
202                r(79, 64, 4, 1),
203                r(79, 64, 1, 4),
204                r(92, 64, 4, 1),
205                r(95, 64, 1, 4),
206                r(79, 77, 4, 1),
207                r(79, 74, 1, 4),
208                r(92, 77, 4, 1),
209                r(95, 74, 1, 4),
210            ]
211        );
212    }
213
214    #[test]
215    fn corner_marks_pulse_toward_the_background() {
216        let mut c = Cursor::new(Pos::new(0, 0));
217        c.tick(0.5);
218        let mut buf = dots();
219        draw(&mut buf, &c, CursorStyle::Corners, 10, 4);
220        let dim = CURSOR.lerp(DARK, 0.5);
221        assert!(buf.overlays().iter().all(|o| o.color == dim));
222        // Toward the background, whatever it is.
223        let light = CursorInk {
224            dark: Rgb::new(255, 255, 255),
225            ..INK
226        };
227        let view = CursorView {
228            pos: c.pos,
229            brightness: c.brightness(),
230            style: CursorStyle::Corners,
231        };
232        let mut buf = dots();
233        draw_cursor(&mut buf, light, &view, &layout_at(Pos::new(0, 0)), (10, 4));
234        let pale = CURSOR.lerp(Rgb::new(255, 255, 255), 0.5);
235        assert!(buf.overlays().iter().all(|o| o.color == pale));
236    }
237
238    #[test]
239    fn tile_glow_tints_only_the_tile_and_pulses() {
240        let mut c = Cursor::new(Pos::new(0, 0));
241        let mut buf = dots();
242        let bg = Rgb::new(9, 9, 9);
243        draw(&mut buf, &c, CursorStyle::TileGlow, 10, 4);
244        let bright = bg.lerp(CURSOR, GLOW_MAX);
245        let cell = |b: &GlyphBuffer, x| *b.get(x, 4).unwrap();
246        assert_eq!(cell(&buf, 10), Cell::new('.', Rgb::new(1, 1, 1), bright));
247        assert_eq!(cell(&buf, 11), Cell::new('.', Rgb::new(1, 1, 1), bright));
248        assert_eq!(cell(&buf, 9).bg, bg);
249        assert_eq!(cell(&buf, 12).bg, bg);
250        let found = buf.overlays();
251        assert!(found.is_empty(), "{found:?}");
252        c.tick(0.5);
253        let mut dim = dots();
254        draw(&mut dim, &c, CursorStyle::TileGlow, 10, 4);
255        assert_eq!(cell(&dim, 10).bg, bg.lerp(CURSOR, GLOW_MAX * 0.5));
256    }
257
258    #[test]
259    fn nothing_is_drawn_outside_the_map_area() {
260        let c = Cursor::new(Pos::new(0, 0));
261        let view = px_rect(AREA);
262        for style in [CursorStyle::Corners, CursorStyle::LargeCorners] {
263            for (x, y) in [(0, 0), (68, 29), (0, 29), (68, 0)] {
264                let mut buf = dots();
265                draw(&mut buf, &c, style, x, y);
266                let found = buf.overlays();
267                assert!(!found.is_empty(), "{found:?}");
268                for r in rects(&buf) {
269                    assert_eq!(
270                        r.intersect(&view),
271                        Some(r),
272                        "{style:?} at ({x}, {y}): {r:?}"
273                    );
274                }
275            }
276        }
277        // At the left edge the left arms' column is off the area: the
278        // vertical ones go, the horizontal ones lose a pixel.
279        let mut buf = dots();
280        draw(&mut buf, &c, CursorStyle::Corners, 0, 0);
281        assert_eq!(
282            rects(&buf)[..2],
283            [Rect::new(0, 0, 2, 1), Rect::new(13, 0, 3, 1)]
284        );
285        // Off the area entirely (rows below it): nothing at all.
286        for style in [CursorStyle::Corners, CursorStyle::TileGlow] {
287            let mut buf = dots();
288            let before = buf.clone();
289            draw(&mut buf, &c, style, 10, 30);
290            assert_eq!(buf, before);
291        }
292    }
293
294    proptest! {
295        /// Whatever the style, pulse and position, the cursor changes no
296        /// glyph or glyph color, and its overlays stay out of the letters:
297        /// in the tile's rows 2..=11 (where letters are inked) only the
298        /// pixel column left of the tile and the tile's last one are used.
299        #[test]
300        fn never_covers_letters(
301            style in prop::sample::select(vec![
302                CursorStyle::Corners,
303                CursorStyle::LargeCorners,
304                CursorStyle::TileGlow,
305            ]),
306            t in 0.0f32..1.0,
307            tx in 0i32..35,
308            ty in 0i32..30,
309        ) {
310            let mut c = Cursor::new(Pos::new(0, 0));
311            c.tick(t);
312            let mut buf = dots();
313            let before = buf.clone();
314            let (x, y) = (tx * TILE_W_CELLS, ty);
315            draw(&mut buf, &c, style, x, y);
316            for cy in 0..32 {
317                for cx in 0..100 {
318                    let (a, b) = (before.get(cx, cy).unwrap(), buf.get(cx, cy).unwrap());
319                    prop_assert_eq!((a.glyph, a.fg), (b.glyph, b.fg));
320                }
321            }
322            let (cw, ch) = CELL_PX;
323            for r in rects(&buf) {
324                for py in r.y..r.y + r.h {
325                    for px in r.x..r.x + r.w {
326                        let (rx, ry) = (px - x * cw, py - y * ch);
327                        prop_assert!((-1..=15).contains(&rx) && (0..=13).contains(&ry));
328                        prop_assert!(
329                            !(2..=11).contains(&ry) || rx == -1 || rx == 15,
330                            "pixel ({}, {}) of the tile", rx, ry
331                        );
332                    }
333                }
334            }
335        }
336    }
337}