Skip to main content

shuyi_ui/frame/
gizmo.rs

1//! The debug view's shapes as the frame holds them (ADR-0085): a
2//! [`GizmoItem`] is [`Item::Gizmo`](super::Item::Gizmo), drawn over
3//! everything else, in a colour the debug skin picked from its
4//! [`GizmoRole`]. Screens never make these: they hand
5//! [`Gizmo`](crate::debug::gizmo::Gizmo)s in, and
6//! [`paint_gizmos`](crate::debug::glyph::paint_gizmos) turns them into
7//! items. Each item's pixels come from [`shape`](super::shape)'s integer
8//! rules, as runs ([`GizmoItem::for_each_run`]), so every renderer draws
9//! the same ones.
10
11use core::fmt;
12
13use super::color::{EngineColor, Rgb};
14use super::glyph_buffer::{PxRect, Rect};
15use super::shape::{circle_outline_runs, clip_run, line_runs};
16
17/// What a gizmo shows, which picks its colour: the debug skin's
18/// ([`color`](Self::color)), never the game's. More roles may come with
19/// later channels.
20#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
21#[non_exhaustive]
22pub enum GizmoRole {
23    /// Where something is: a cell, a map's edge, a box round a sprite.
24    Bounds,
25    /// What blocks movement.
26    Solid,
27    /// What reacts when something enters it.
28    Trigger,
29    /// A way something goes.
30    Path,
31    /// A bone of a skeleton.
32    Bone,
33    /// Anything else worth pointing out: a cursor, an index, a reading.
34    Note,
35}
36
37impl GizmoRole {
38    /// Every role.
39    pub const ALL: &[GizmoRole] = &[
40        GizmoRole::Bounds,
41        GizmoRole::Solid,
42        GizmoRole::Trigger,
43        GizmoRole::Path,
44        GizmoRole::Bone,
45        GizmoRole::Note,
46    ];
47
48    /// Its name, as in snapshots.
49    pub const fn name(self) -> &'static str {
50        match self {
51            GizmoRole::Bounds => "bounds",
52            GizmoRole::Solid => "solid",
53            GizmoRole::Trigger => "trigger",
54            GizmoRole::Path => "path",
55            GizmoRole::Bone => "bone",
56            GizmoRole::Note => "note",
57        }
58    }
59
60    /// The engine colour the debug skin draws it in.
61    pub const fn color(self) -> EngineColor {
62        match self {
63            GizmoRole::Bounds => EngineColor::TextDim,
64            GizmoRole::Solid => EngineColor::Warning,
65            GizmoRole::Trigger => EngineColor::Accent,
66            GizmoRole::Path => EngineColor::Focus,
67            GizmoRole::Bone => EngineColor::Bright,
68            GizmoRole::Note => EngineColor::Highlight,
69        }
70    }
71}
72
73/// The most characters a [`LabelText`] keeps.
74pub const LABEL_MAX: usize = 24;
75
76/// A gizmo label's text as the frame keeps it: printable ASCII, at most
77/// [`LABEL_MAX`] characters (longer text is cut, and anything else is
78/// `?`). A fixed array, so an [`Item`](super::Item) stays `Copy`.
79#[derive(Copy, Clone, PartialEq, Eq, Hash)]
80pub struct LabelText {
81    bytes: [u8; LABEL_MAX],
82    len: u8,
83}
84
85impl LabelText {
86    /// `text`, cut and made ASCII.
87    pub fn new(text: &str) -> Self {
88        let mut bytes = [0; LABEL_MAX];
89        let mut len = 0;
90        for (slot, c) in bytes.iter_mut().zip(text.chars()) {
91            *slot = if c.is_ascii_graphic() || c == ' ' {
92                // Printable ASCII is one byte.
93                u8::try_from(c).unwrap_or(b'?')
94            } else {
95                b'?'
96            };
97            len += 1;
98        }
99        Self { bytes, len }
100    }
101
102    /// The text.
103    pub fn as_str(&self) -> &str {
104        let len = usize::from(self.len).min(LABEL_MAX);
105        // Only ASCII is ever stored.
106        core::str::from_utf8(&self.bytes[..len]).unwrap_or_default()
107    }
108}
109
110impl fmt::Debug for LabelText {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        fmt::Debug::fmt(self.as_str(), f)
113    }
114}
115
116/// A gizmo's shape as the frame keeps it, in console pixels.
117#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash)]
118#[non_exhaustive]
119pub enum GizmoShape {
120    /// The outline of a rectangle, one pixel wide, inside it.
121    Rect {
122        /// The rectangle.
123        rect: PxRect,
124    },
125    /// A line one pixel wide, both ends drawn.
126    Line {
127        /// Its first pixel.
128        from: (i32, i32),
129        /// Its last pixel.
130        to: (i32, i32),
131    },
132    /// A plus: `size` pixels each way from its centre.
133    Cross {
134        /// Its centre.
135        at: (i32, i32),
136        /// How far each arm reaches.
137        size: u8,
138    },
139    /// The outline of a circle, `2 × radius + 1` pixels across.
140    Circle {
141        /// Its centre.
142        center: (i32, i32),
143        /// Its radius.
144        radius: u16,
145    },
146    /// Text in the glyph cells from the one holding `at`: its glyphs are
147    /// in the cells, and the item draws no pixels.
148    Label {
149        /// A pixel of the first cell.
150        at: (i32, i32),
151        /// The text.
152        text: LabelText,
153    },
154}
155
156/// How far a shape's box may reach, each way (up to `REACH - 1` past
157/// 0): its width and height then fit in `i32`, and no buffer reaches that
158/// far.
159const REACH: i64 = 1 << 30;
160
161impl GizmoShape {
162    /// A box holding every pixel it can cover (a label's: its one pixel
163    /// `at`), clamped to `-2^30..2^30` on each axis.
164    pub fn bounds(&self) -> PxRect {
165        let wide = |(x, y): (i32, i32)| (i64::from(x), i64::from(y));
166        let (left, top, right, bottom) = match *self {
167            GizmoShape::Rect { rect } => {
168                let (x, y) = wide((rect.x, rect.y));
169                let (w, h) = (i64::from(rect.w.max(0)), i64::from(rect.h.max(0)));
170                (x, y, x + w, y + h)
171            }
172            GizmoShape::Line { from, to } => {
173                let ((x0, y0), (x1, y1)) = (wide(from), wide(to));
174                (x0.min(x1), y0.min(y1), x0.max(x1) + 1, y0.max(y1) + 1)
175            }
176            GizmoShape::Cross { at, size } => {
177                let ((x, y), s) = (wide(at), i64::from(size));
178                (x - s, y - s, x + s + 1, y + s + 1)
179            }
180            GizmoShape::Circle { center, radius } => {
181                let ((x, y), r) = (wide(center), i64::from(radius));
182                (x - r, y - r, x + r + 1, y + r + 1)
183            }
184            GizmoShape::Label { at, .. } => {
185                let (x, y) = wide(at);
186                (x, y, x + 1, y + 1)
187            }
188        };
189        let clamp = |v: i64| v.clamp(-REACH, REACH - 1);
190        let (left, top, right, bottom) = (clamp(left), clamp(top), clamp(right), clamp(bottom));
191        let fit = |v: i64| i32::try_from(v).unwrap_or(0);
192        Rect::new(fit(left), fit(top), fit(right - left), fit(bottom - top))
193    }
194
195    /// The same shape moved by `(dx, dy)`, or `None` if a point no longer
196    /// fits in `i32`.
197    pub(crate) fn moved(self, dx: i64, dy: i64) -> Option<Self> {
198        let point = |(x, y): (i32, i32)| {
199            Some((
200                i32::try_from(i64::from(x) + dx).ok()?,
201                i32::try_from(i64::from(y) + dy).ok()?,
202            ))
203        };
204        Some(match self {
205            GizmoShape::Rect { rect } => {
206                let (x, y) = point((rect.x, rect.y))?;
207                GizmoShape::Rect {
208                    rect: Rect::new(x, y, rect.w, rect.h),
209                }
210            }
211            GizmoShape::Line { from, to } => GizmoShape::Line {
212                from: point(from)?,
213                to: point(to)?,
214            },
215            GizmoShape::Cross { at, size } => GizmoShape::Cross {
216                at: point(at)?,
217                size,
218            },
219            GizmoShape::Circle { center, radius } => GizmoShape::Circle {
220                center: point(center)?,
221                radius,
222            },
223            GizmoShape::Label { at, text } => GizmoShape::Label {
224                at: point(at)?,
225                text,
226            },
227        })
228    }
229}
230
231/// A gizmo in the frame: its channel's id and role (for the snapshot), its
232/// shape, the colour the debug skin gave its role, and the part of the
233/// frame it is drawn in.
234#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash)]
235pub struct GizmoItem {
236    /// The id of its channel (`grid`).
237    pub channel: &'static str,
238    /// What it shows.
239    pub role: GizmoRole,
240    /// Its shape.
241    pub shape: GizmoShape,
242    /// Its colour.
243    pub color: Rgb,
244    /// The pixels it may draw in: its shape's [`bounds`](GizmoShape::bounds)
245    /// to start with (a label's: the cells its text was printed in),
246    /// clipped to the buffer when added.
247    pub clip: PxRect,
248}
249
250impl GizmoItem {
251    /// `shape` in `color`, of `channel` and `role`, drawn wherever it
252    /// reaches.
253    pub fn new(channel: &'static str, role: GizmoRole, shape: GizmoShape, color: Rgb) -> Self {
254        Self {
255            channel,
256            role,
257            shape,
258            color,
259            clip: shape.bounds(),
260        }
261    }
262
263    /// Calls `f(y, x0, x1)` for each run of its pixels inside its
264    /// `clip`: row `y`, pixels `x0..x1`. Every renderer fills these, in
265    /// whole console pixels, in its colour. A label gives none (its glyphs
266    /// are in the cells).
267    pub fn for_each_run(&self, mut f: impl FnMut(i32, i32, i32)) {
268        let clip = self.clip;
269        match self.shape {
270            GizmoShape::Rect { rect } => rect_runs(rect, clip, &mut f),
271            GizmoShape::Line { from, to } => line_runs(from, to, clip, f),
272            GizmoShape::Cross { at, size } => {
273                let (x, y, s) = (i64::from(at.0), i64::from(at.1), i64::from(size));
274                clip_run(clip, y, x - s, x + s + 1, &mut f);
275                let rows = (y - s).max(i64::from(clip.y))..(y + s + 1).min(clip_bottom(clip));
276                for row in rows.filter(|&row| row != y) {
277                    clip_run(clip, row, x, x + 1, &mut f);
278                }
279            }
280            GizmoShape::Circle { center, radius } => circle_outline_runs(center, radius, clip, f),
281            GizmoShape::Label { .. } => {}
282        }
283    }
284}
285
286/// The row past `clip`'s last.
287fn clip_bottom(clip: PxRect) -> i64 {
288    i64::from(clip.y) + i64::from(clip.h.max(0))
289}
290
291/// The runs of `rect`'s outline, one pixel wide, inside `clip`: its top and
292/// bottom rows whole, and its two sides between them, taking only the rows
293/// inside `clip`.
294fn rect_runs(rect: PxRect, clip: PxRect, f: &mut impl FnMut(i32, i32, i32)) {
295    if rect.is_empty() {
296        return;
297    }
298    let (x, y) = (i64::from(rect.x), i64::from(rect.y));
299    let (right, bottom) = (x + i64::from(rect.w), y + i64::from(rect.h));
300    clip_run(clip, y, x, right, f);
301    if bottom - 1 > y {
302        clip_run(clip, bottom - 1, x, right, f);
303    }
304    for row in (y + 1).max(i64::from(clip.y))..(bottom - 1).min(clip_bottom(clip)) {
305        clip_run(clip, row, x, x + 1, f);
306        if right - 1 > x {
307            clip_run(clip, row, right - 1, right, f);
308        }
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use alloc::collections::BTreeSet;
315
316    use super::*;
317    use crate::prelude::*;
318
319    const INK: Rgb = Rgb::new(1, 2, 3);
320
321    fn pixels(item: &GizmoItem) -> BTreeSet<(i32, i32)> {
322        let mut out = BTreeSet::new();
323        item.for_each_run(|y, x0, x1| out.extend((x0..x1).map(|x| (x, y))));
324        out
325    }
326
327    fn item(shape: GizmoShape) -> GizmoItem {
328        GizmoItem::new("test", GizmoRole::Note, shape, INK)
329    }
330
331    #[test]
332    fn a_rect_is_its_outline_one_pixel_wide() {
333        let rect = item(GizmoShape::Rect {
334            rect: Rect::new(2, 3, 4, 3),
335        });
336        let want: BTreeSet<_> = [
337            (2, 3),
338            (3, 3),
339            (4, 3),
340            (5, 3),
341            (2, 4),
342            (5, 4),
343            (2, 5),
344            (3, 5),
345            (4, 5),
346            (5, 5),
347        ]
348        .into();
349        assert_eq!(pixels(&rect), want);
350        assert_eq!(rect.clip, Rect::new(2, 3, 4, 3));
351        // One pixel high or wide, and empty.
352        let flat = item(GizmoShape::Rect {
353            rect: Rect::new(0, 0, 3, 1),
354        });
355        assert_eq!(pixels(&flat).len(), 3);
356        let thin = item(GizmoShape::Rect {
357            rect: Rect::new(0, 0, 1, 3),
358        });
359        assert_eq!(pixels(&thin).len(), 3);
360        let none = item(GizmoShape::Rect {
361            rect: Rect::new(0, 0, 0, 3),
362        });
363        assert!(pixels(&none).is_empty());
364    }
365
366    #[test]
367    fn a_cross_reaches_its_size_each_way_and_a_label_draws_nothing() {
368        let cross = item(GizmoShape::Cross {
369            at: (5, 5),
370            size: 2,
371        });
372        assert_eq!(cross.clip, Rect::new(3, 3, 5, 5));
373        let got = pixels(&cross);
374        assert_eq!(got.len(), 9);
375        assert!(got.contains(&(3, 5)) && got.contains(&(5, 7)) && !got.contains(&(4, 4)));
376        let label = item(GizmoShape::Label {
377            at: (8, 16),
378            text: LabelText::new("0"),
379        });
380        assert!(pixels(&label).is_empty());
381        assert_eq!(label.clip, Rect::new(8, 16, 1, 1));
382    }
383
384    /// The runs of `shape` drawn in a clip far wider than it, in order.
385    fn runs(shape: GizmoShape) -> Vec<(i32, i32, i32)> {
386        let wide = GizmoItem {
387            clip: Rect::new(-100, -100, 200, 200),
388            ..item(shape)
389        };
390        let mut out = Vec::new();
391        wide.for_each_run(|y, x0, x1| out.push((y, x0, x1)));
392        out
393    }
394
395    #[test]
396    fn each_shape_gives_its_own_runs_and_no_more() {
397        let rect = |x, y, w, h| GizmoShape::Rect {
398            rect: Rect::new(x, y, w, h),
399        };
400        // Top, bottom, then each side's pixel on the rows between.
401        assert_eq!(
402            runs(rect(2, 3, 4, 4)),
403            [
404                (3, 2, 6),
405                (6, 2, 6),
406                (4, 2, 3),
407                (4, 5, 6),
408                (5, 2, 3),
409                (5, 5, 6)
410            ]
411        );
412        // One row high: one run; one column wide: a pixel a row.
413        assert_eq!(runs(rect(-2, 5, 3, 1)), [(5, -2, 1)]);
414        assert_eq!(runs(rect(4, -1, 1, 3)), [(-1, 4, 5), (1, 4, 5), (0, 4, 5)]);
415        // A cross: its bar, then its post above and below the bar.
416        assert_eq!(
417            runs(GizmoShape::Cross {
418                at: (5, 5),
419                size: 2
420            }),
421            [(5, 3, 8), (3, 5, 6), (4, 5, 6), (6, 5, 6), (7, 5, 6)]
422        );
423        assert_eq!(
424            runs(GizmoShape::Cross {
425                at: (-3, 4),
426                size: 1
427            }),
428            [(4, -4, -1), (3, -3, -2), (5, -3, -2)]
429        );
430    }
431
432    #[test]
433    fn a_clip_cuts_every_shape() {
434        let clip = Rect::new(0, 0, 4, 4);
435        for shape in [
436            GizmoShape::Rect {
437                rect: Rect::new(-3, -3, 6, 6),
438            },
439            GizmoShape::Line {
440                from: (-10, 2),
441                to: (10, 2),
442            },
443            GizmoShape::Cross {
444                at: (2, 2),
445                size: 9,
446            },
447            GizmoShape::Circle {
448                center: (2, 2),
449                radius: 2,
450            },
451        ] {
452            let cut = GizmoItem {
453                clip,
454                ..item(shape)
455            };
456            let got = pixels(&cut);
457            assert!(!got.is_empty(), "{shape:?}");
458            assert!(got.iter().all(|&(x, y)| clip.contains(x, y)), "{shape:?}");
459        }
460    }
461
462    #[test]
463    fn bounds_hold_every_pixel_and_stay_in_reach() {
464        let circle = GizmoShape::Circle {
465            center: (10, 10),
466            radius: 3,
467        };
468        assert_eq!(circle.bounds(), Rect::new(7, 7, 7, 7));
469        let line = GizmoShape::Line {
470            from: (5, 1),
471            to: (-2, 4),
472        };
473        assert_eq!(line.bounds(), Rect::new(-2, 1, 8, 4));
474        let far = GizmoShape::Line {
475            from: (i32::MIN, i32::MIN),
476            to: (i32::MAX, i32::MAX),
477        };
478        let b = far.bounds();
479        assert_eq!((b.x, b.y), (-(1 << 30), -(1 << 30)));
480        assert_eq!((b.w, b.h), (i32::MAX, i32::MAX));
481    }
482
483    #[test]
484    fn a_shape_moves_unless_it_leaves_i32() {
485        let cross = GizmoShape::Cross {
486            at: (1, 2),
487            size: 1,
488        };
489        assert_eq!(
490            cross.moved(10, -20),
491            Some(GizmoShape::Cross {
492                at: (11, -18),
493                size: 1
494            })
495        );
496        assert_eq!(cross.moved(i64::from(i32::MAX), 0), None);
497    }
498
499    #[test]
500    fn a_label_keeps_printable_ascii_and_is_cut() {
501        assert_eq!(LabelText::new("unit 3").as_str(), "unit 3");
502        assert_eq!(LabelText::new("é\n").as_str(), "??");
503        let long = "x".repeat(40);
504        assert_eq!(LabelText::new(&long).as_str().len(), LABEL_MAX);
505        assert_eq!(format!("{:?}", LabelText::new("a")), "\"a\"");
506    }
507
508    #[test]
509    fn each_gizmo_has_its_snapshot_line() {
510        use crate::frame::snapshot::gizmo_line;
511        let line = |role, shape| gizmo_line(&GizmoItem::new("grid", role, shape, INK));
512        let rect = GizmoShape::Rect {
513            rect: Rect::new(0, 16, 8, 8),
514        };
515        assert_eq!(
516            line(GizmoRole::Bounds, rect),
517            "gizmo grid bounds rect 0,16 8x8"
518        );
519        let to = GizmoShape::Line {
520            from: (1, 2),
521            to: (-3, 4),
522        };
523        assert_eq!(line(GizmoRole::Path, to), "gizmo grid path line 1,2 -3,4");
524        let cross = GizmoShape::Cross {
525            at: (5, 6),
526            size: 3,
527        };
528        assert_eq!(
529            line(GizmoRole::Trigger, cross),
530            "gizmo grid trigger cross 5,6 3"
531        );
532        let circle = GizmoShape::Circle {
533            center: (7, 8),
534            radius: 9,
535        };
536        assert_eq!(
537            line(GizmoRole::Solid, circle),
538            "gizmo grid solid circle 7,8 9"
539        );
540        let label = GizmoShape::Label {
541            at: (8, 16),
542            text: LabelText::new("unit \"2\""),
543        };
544        assert_eq!(
545            line(GizmoRole::Note, label),
546            r#"gizmo grid note label 8,16 "unit \"2\"""#
547        );
548        let bone = GizmoShape::Cross {
549            at: (0, 0),
550            size: 0,
551        };
552        assert_eq!(line(GizmoRole::Bone, bone), "gizmo grid bone cross 0,0 0");
553        // Cut by its clip, it says so; a label never does.
554        let cut = GizmoItem {
555            clip: Rect::new(0, 16, 4, 8),
556            ..GizmoItem::new("grid", GizmoRole::Bounds, rect, INK)
557        };
558        assert_eq!(
559            gizmo_line(&cut),
560            "gizmo grid bounds rect 0,16 8x8 clip=0,16 4x8"
561        );
562        let placed = GizmoItem {
563            clip: Rect::new(8, 16, 8, 16),
564            ..GizmoItem::new("grid", GizmoRole::Note, label, INK)
565        };
566        assert!(!gizmo_line(&placed).contains("clip"));
567    }
568
569    #[test]
570    fn each_role_has_a_name_and_an_engine_colour() {
571        let names: BTreeSet<_> = GizmoRole::ALL.iter().map(|r| r.name()).collect();
572        assert_eq!(names.len(), GizmoRole::ALL.len());
573        assert_eq!(GizmoRole::Bounds.color(), EngineColor::TextDim);
574        assert_eq!(GizmoRole::Note.color(), EngineColor::Highlight);
575    }
576}