Skip to main content

shuyi_ui/frame/raster/
card.rs

1//! The render card (ADR-0052 §6, ticket 2392): one fixed frame with every
2//! kind of thing a frame draws, so a backend's window is held to the
3//! raster for all of them, with no game behind it. `shuyi-app`'s
4//! `render_card` example draws it in a window and compares the window
5//! with [`RenderCard::painter`]'s picture, in CI's `render` job.
6//!
7//! The card holds:
8//!
9//! - cells: backgrounds in several colours, see-through cells, glyphs in
10//!   several colours, and a glyph the atlas lacks (the fallback, in
11//!   [`MISSING_COLOR`](super::MISSING_COLOR)); wide glyphs among narrow
12//!   ones, each over its two cells, and one without its continuation
13//!   (the fallback too);
14//! - rectangles and sprites in both layers; sprites in every [`Paint`], at
15//!   opacity 255 and 128, at brightness 255 and 102, mirrored, cut by
16//!   their `clip`, and one whose image is missing;
17//! - painted pictures (ADR-0078) of every kind, RGBA (some pixels clear
18//!   or half clear), indexed and one-bit (with a mask and without), in
19//!   both layers, at brightness 102, cut by a `clip`, part of one, and one
20//!   the frame's table lacks;
21//! - a backdrop at zoom 2, its scene offset by half a scene pixel, with
22//!   a painted picture of its own;
23//! - the debug view's gizmos (ADR-0085) over everything: lines into all
24//!   eight octants, rectangles' outlines (one cut by a `clip`), crosses,
25//!   circles' outlines (one of radius 0) and labels in the cells.
26//!
27//! Its pictures are made in code: the [test card](super::test_card) and a
28//! small font atlas of boxes and bars, and a wide glyph, and the painted
29//! pictures' pixels. A new `Item`, `Paint`, `Layer` or `PaintedKind`
30//! variant goes on the card in the change that adds it: the tests don't
31//! compile until it does.
32
33use alloc::collections::BTreeMap;
34use alloc::format;
35use alloc::rc::Rc;
36
37use shuyi_assets::font::{CellWidths, FALLBACK_GLYPH, FontAtlasDef};
38use shuyi_assets::image::{ImageId, ImageInfo, ImageTable};
39use shuyi_assets::lighting::Light;
40
41use super::{Image, Painter, test_card};
42use crate::frame::color::Rgb;
43use crate::frame::gizmo::{GizmoItem, GizmoRole, GizmoShape, LabelText};
44use crate::frame::glyph_buffer::{Cell, GlyphBuffer, Layer, Overlay, Paint, Rect, Sprite};
45use crate::frame::painted::{Painted, PaintedId, PaintedSprite};
46
47/// The card's size in cells (8 × 16 pixels each): 256 × 192 pixels.
48pub const COLUMNS: u16 = 32;
49/// See [`COLUMNS`].
50pub const ROWS: u16 = 12;
51
52/// The test card's path in [`RenderCard::table`].
53pub const TEST_CARD_PATH: &str = "card/test_card.png";
54/// The path of the picture that is missing: in [`RenderCard::table`], not
55/// in [`RenderCard::images`].
56pub const MISSING_PATH: &str = "card/missing.png";
57
58/// The console's background.
59const CLEAR: Rgb = Rgb::new(20, 18, 28);
60/// Cell backgrounds.
61const BACKGROUNDS: [Rgb; 4] = [
62    Rgb::new(40, 60, 120),
63    Rgb::new(120, 40, 50),
64    Rgb::new(30, 100, 60),
65    Rgb::new(150, 140, 40),
66];
67/// Glyph colours.
68const FOREGROUNDS: [Rgb; 5] = [
69    Rgb::new(255, 255, 255),
70    Rgb::new(250, 200, 60),
71    Rgb::new(90, 220, 240),
72    Rgb::new(240, 90, 200),
73    Rgb::new(10, 10, 10),
74];
75/// The glyphs the atlas has, but the space.
76const GLYPHS: [char; 4] = ['#', 'o', '-', '|'];
77/// A glyph the atlas lacks.
78const MISSING_GLYPH: char = 'Z';
79/// The atlas's wide glyph: a box with a bar down left of its middle, so
80/// its two halves differ.
81const WIDE_GLYPH: char = '田';
82/// The sprites' gallery: two background colours, a cell each, in turn.
83const STRIPES: [Rgb; 2] = [Rgb::new(70, 70, 80), Rgb::new(200, 190, 170)];
84
85/// The painted RGBA picture.
86pub const PAINTED_RGBA: PaintedId = PaintedId::new("card/rgba");
87/// The painted indexed picture.
88pub const PAINTED_INDEXED: PaintedId = PaintedId::new("card/indexed");
89/// The painted one-bit picture with a mask.
90pub const PAINTED_MASKED: PaintedId = PaintedId::new("card/masked");
91/// The painted one-bit picture without a mask.
92pub const PAINTED_ONE_BIT: PaintedId = PaintedId::new("card/one-bit");
93/// The backdrop scene's painted picture.
94pub const PAINTED_SCENE: PaintedId = PaintedId::new("card/scene");
95/// A painted picture the frame's table lacks.
96pub const PAINTED_MISSING: PaintedId = PaintedId::new("card/painted-missing");
97
98/// One fixed frame with every kind of thing a frame draws, and what
99/// paints it.
100#[derive(Debug, Clone, PartialEq)]
101pub struct RenderCard {
102    /// The frame.
103    pub buffer: GlyphBuffer,
104    /// The font atlas: boxes and bars, the fallback glyph and a wide one.
105    pub atlas: FontAtlasDef,
106    /// The atlas's picture.
107    pub atlas_image: Image,
108    /// The pictures' table: the test card and the missing picture.
109    pub table: ImageTable,
110    /// The pictures there are: the test card alone.
111    pub images: BTreeMap<ImageId, Image>,
112    /// The console's background.
113    pub clear: Rgb,
114}
115
116impl RenderCard {
117    /// The raster's painter of the card's frame.
118    pub fn painter(&self) -> Painter {
119        Painter::new(
120            self.atlas.clone(),
121            self.atlas_image.clone(),
122            self.images.clone(),
123            self.clear,
124        )
125    }
126}
127
128/// The render card.
129pub fn build() -> RenderCard {
130    let table = ImageTable::new(BTreeMap::from([
131        (
132            TEST_CARD_PATH,
133            ImageInfo::new(test_card::SIZE, test_card::SIZE),
134        ),
135        (
136            MISSING_PATH,
137            ImageInfo::new(test_card::SIZE, test_card::SIZE),
138        ),
139    ]));
140    let ids = table.id(TEST_CARD_PATH).zip(table.id(MISSING_PATH));
141    let Some((card, missing)) = ids else {
142        unreachable!("the table names both pictures");
143    };
144    let (atlas, atlas_image) = atlas();
145    let card_image = Image {
146        width: test_card::SIZE,
147        height: test_card::SIZE,
148        rgba: test_card::pixels(),
149    };
150    RenderCard {
151        buffer: frame(card, missing),
152        atlas,
153        atlas_image,
154        images: BTreeMap::from([(card, card_image)]),
155        table,
156        clear: CLEAR,
157    }
158}
159
160/// A 64 × 16 atlas of eight 8 × 16 cells: the space, a box (`#`), a box's
161/// outline (`o`), a bar across (`-`), a bar down (`|`), the fallback
162/// glyph, a checkerboard, and the wide glyph over the last two. Glyph
163/// pixels are white, the rest see-through.
164fn atlas() -> (FontAtlasDef, Image) {
165    const CELL: (u32, u32) = (8, 16);
166    let order = [' ', '#', 'o', '-', '|', FALLBACK_GLYPH];
167    let glyphs: BTreeMap<char, u32> = order.iter().copied().zip(0..).collect();
168    let narrow = u32::try_from(order.len()).unwrap_or(0);
169    let columns = narrow + 2;
170    let (width, height) = (CELL.0 * columns, CELL.1);
171    // Whether pixel `(x, y)` of glyph `glyph`'s cell (both cells of the
172    // wide one) is set.
173    let set = |glyph: char, x: u32, y: u32| match glyph {
174        '#' => (1..7).contains(&x) && (2..14).contains(&y),
175        'o' => {
176            let (across, down) = ((1..7).contains(&x), (3..13).contains(&y));
177            across && down && (x == 1 || x == 6 || y == 3 || y == 12)
178        }
179        '-' => (7..9).contains(&y),
180        '|' => (3..5).contains(&x),
181        FALLBACK_GLYPH => (1..7).contains(&x) && (2..14).contains(&y) && (x + y).is_multiple_of(2),
182        WIDE_GLYPH => {
183            let (across, down) = ((1..15).contains(&x), (2..14).contains(&y));
184            across && down && (x == 1 || x == 14 || x == 5 || y == 2 || y == 13 || y == 7)
185        }
186        _ => false,
187    };
188    let rgba = (0..height)
189        .flat_map(|y| (0..width).map(move |x| (x, y)))
190        .flat_map(|(x, y)| {
191            let cell = x / CELL.0;
192            let on = match order.get(cell as usize) {
193                Some(&glyph) => set(glyph, x % CELL.0, y),
194                None => set(WIDE_GLYPH, x - narrow * CELL.0, y),
195            };
196            [if on { 255 } else { 0 }; 4]
197        })
198        .collect();
199    let def = FontAtlasDef {
200        cell_w: CELL.0,
201        cell_h: CELL.1,
202        columns,
203        glyphs,
204        wide: BTreeMap::from([(WIDE_GLYPH, narrow)]),
205        wide_sets: BTreeMap::new(),
206    };
207    (
208        def,
209        Image {
210            width,
211            height,
212            rgba,
213        },
214    )
215}
216
217/// The card's frame, its sprites showing `card`, but one showing
218/// `missing`.
219fn frame(card: ImageId, missing: ImageId) -> GlyphBuffer {
220    let mut buf = GlyphBuffer::new(COLUMNS, ROWS, Cell::new(' ', FOREGROUNDS[0], CLEAR))
221        .with_widths(CellWidths::wide([WIDE_GLYPH]));
222    cells(&mut buf);
223    rects(&mut buf);
224    sprites(&mut buf, card, missing);
225    painted(&mut buf);
226    backdrop(&mut buf, card);
227    gizmos(&mut buf);
228    buf
229}
230
231/// The cells: a band of backgrounds and glyphs in every colour (row 0),
232/// glyphs on the clear colour and the glyph the atlas lacks (row 1), the
233/// sprites' gallery on stripes (rows 2 to 10, columns 0 to 19), glyphs
234/// over the `Under` sprites (row 6), see-through cells where there is no
235/// backdrop (row 11, columns 0 to 8), and wide glyphs among narrow ones
236/// and one wide glyph without its continuation (row 11, from column 10).
237fn cells(buf: &mut GlyphBuffer) {
238    for x in 0..i32::from(COLUMNS) {
239        let i = nth(x);
240        let glyph = GLYPHS[i % GLYPHS.len()];
241        let fg = FOREGROUNDS[i % FOREGROUNDS.len()];
242        buf.set(x, 0, Cell::new(glyph, fg, BACKGROUNDS[i / 4 % 4]));
243        let glyph = if i.is_multiple_of(3) {
244            MISSING_GLYPH
245        } else {
246            glyph
247        };
248        buf.set(x, 1, Cell::new(glyph, fg, CLEAR));
249    }
250    for y in 2..11 {
251        for x in 0..20 {
252            let stripe = STRIPES[nth(x + y) % 2];
253            buf.set(x, y, Cell::new(' ', FOREGROUNDS[0], stripe));
254        }
255    }
256    for x in 0..8 {
257        let i = nth(x);
258        let stripe = STRIPES[nth(x + 6) % 2];
259        buf.set(x, 6, Cell::new(GLYPHS[i % 4], FOREGROUNDS[i % 5], stripe));
260    }
261    for x in 0..8 {
262        let glyph = if x % 2 == 0 { 'o' } else { ' ' };
263        buf.set(x, 11, Cell::see_through(glyph, FOREGROUNDS[2]));
264    }
265    buf.set(8, 11, Cell::see_through(MISSING_GLYPH, FOREGROUNDS[1]));
266    let wide = format!("{WIDE_GLYPH}#{WIDE_GLYPH}{WIDE_GLYPH}o");
267    buf.print(10, 11, &wide, FOREGROUNDS[1], CLEAR);
268    buf.print(
269        18,
270        11,
271        &format!("{WIDE_GLYPH}|"),
272        FOREGROUNDS[3],
273        BACKGROUNDS[0],
274    );
275    buf.set(22, 11, Cell::new(WIDE_GLYPH, FOREGROUNDS[2], CLEAR));
276}
277
278/// The rectangles: an `Under` bar through row 1's glyphs, `Over` squares
279/// on row 0's, a one-pixel `Over` line, and an `Over` square on a sprite.
280fn rects(buf: &mut GlyphBuffer) {
281    let red = Rgb::new(230, 40, 40);
282    buf.add_overlay(Overlay::new(Rect::new(0, 23, 256, 3), red, Layer::Under));
283    for (i, color) in BACKGROUNDS.iter().enumerate() {
284        let x = i32::try_from(i).unwrap_or(0) * 32 + 5;
285        buf.add_overlay(Overlay::new(Rect::new(x, 6, 5, 3), *color, Layer::Over));
286    }
287    let white = Rgb::new(255, 255, 255);
288    buf.add_overlay(Overlay::new(Rect::new(161, 33, 1, 140), white, Layer::Over));
289    let green = Rgb::new(40, 220, 90);
290    buf.add_overlay(Overlay::new(Rect::new(30, 40, 4, 7), green, Layer::Over));
291}
292
293/// The sprites' gallery, the test card each time:
294///
295/// - y 36: once each in the image's colours `Under` and `Over`, `Solid`,
296///   `Dimmed`, `Shaded`, mirrored, and the missing picture;
297/// - y 60: the same, at opacity 128 or brightness 102, or both;
298/// - y 98: twice the size, `Under` row 6's glyphs, then cut by a `clip`
299///   on odd pixels, mirrored and cut, and `Solid` and cut.
300fn sprites(buf: &mut GlyphBuffer, card: ImageId, missing: ImageId) {
301    let full = Rect::new(0, 0, 16, 16);
302    let dusk = Paint::Shaded(Light::new(0.4, [0.7, 0.75, 1.0]));
303    let gold = Paint::Solid(Rgb::new(250, 200, 60));
304    let at = |x: i32, y: i32, size: i32| Rect::new(x, y, size, size);
305    // (picture, paint, layer, flip_x, opacity, brightness)
306    let small = [
307        (card, Paint::Image, Layer::Under, false, 255, 255),
308        (card, Paint::Image, Layer::Over, false, 255, 255),
309        (card, gold, Layer::Over, false, 255, 255),
310        (card, Paint::Dimmed, Layer::Over, false, 255, 255),
311        (card, dusk, Layer::Over, false, 255, 255),
312        (card, Paint::Image, Layer::Over, true, 255, 255),
313        (missing, Paint::Image, Layer::Over, false, 255, 255),
314    ];
315    let faded = [
316        (card, Paint::Image, Layer::Over, false, 128, 255),
317        (card, gold, Layer::Over, false, 128, 255),
318        (card, Paint::Image, Layer::Over, false, 255, 102),
319        (card, gold, Layer::Over, false, 128, 102),
320        (card, Paint::Dimmed, Layer::Over, false, 255, 102),
321        (card, Paint::Image, Layer::Under, true, 128, 255),
322        (card, dusk, Layer::Over, false, 128, 102),
323    ];
324    for (y, row) in [(36, small), (60, faded)] {
325        for (x, (image, paint, layer, flip_x, opacity, brightness)) in (4..).step_by(22).zip(row) {
326            let mut sprite = Sprite::new(image, full, at(x, y, 16), layer).painted(paint);
327            sprite.flip_x = flip_x;
328            sprite.opacity = opacity;
329            sprite.brightness = brightness;
330            buf.add_sprite(sprite);
331        }
332    }
333    // Twice the size, from y 98: the glyphs of row 6 (y 96 to 112) over
334    // the first, and the others cut on odd pixels.
335    let big = |x: i32, layer| Sprite::new(card, full, at(x, 98, 32), layer);
336    buf.add_sprite(big(4, Layer::Under));
337    let mut cut = big(42, Layer::Over);
338    cut.clip = Rect::new(47, 101, 21, 23);
339    buf.add_sprite(cut);
340    let mut cut = big(80, Layer::Over);
341    cut.clip = Rect::new(85, 101, 21, 23);
342    cut.flip_x = true;
343    buf.add_sprite(cut);
344    let mut cut = big(118, Layer::Under).painted(Paint::Solid(Rgb::new(90, 220, 240)));
345    cut.clip = Rect::new(121, 105, 25, 15);
346    buf.add_sprite(cut);
347}
348
349/// An 8 × 8 RGBA picture: red across, green down, a blue checker, the
350/// top-left pixels clear and the bottom row half clear.
351fn rgba_picture() -> Painted {
352    let mut picture = Painted::rgba(8, 8);
353    for (i, pixel) in picture
354        .pixels_mut()
355        .as_chunks_mut::<4>()
356        .0
357        .iter_mut()
358        .enumerate()
359    {
360        let (x, y) = (i % 8, i / 8);
361        let alpha = match (x, y) {
362            (0..2, 0..2) => 0,
363            (_, 7) => 128,
364            _ => 255,
365        };
366        let level = |v: usize| u8::try_from(v * 36).unwrap_or(u8::MAX);
367        let blue = if (x + y) % 2 == 0 { 200 } else { 40 };
368        pixel.copy_from_slice(&[level(x), level(y), blue, alpha]);
369    }
370    picture
371}
372
373/// An 8 × 8 indexed picture of three of its four colours in diagonal
374/// bands, a pixel wide.
375fn indexed_picture() -> Painted {
376    let palette = [
377        BACKGROUNDS[1],
378        FOREGROUNDS[1],
379        FOREGROUNDS[2],
380        BACKGROUNDS[2],
381    ];
382    let mut picture = Painted::indexed(8, 8, &palette);
383    for (i, pixel) in picture.pixels_mut().iter_mut().enumerate() {
384        *pixel = u8::try_from((i % 8 + i / 8) % 3).unwrap_or(0);
385    }
386    picture
387}
388
389/// A one-bit picture `width` × 8: a white frame round a black one, a
390/// white cross in it; with a mask, its corners and its middle column
391/// left out.
392fn one_bit_picture(width: u16, masked: bool) -> Painted {
393    let mut picture = Painted::one_bit(width, 8);
394    if masked {
395        picture = picture.with_mask();
396    }
397    let stride = picture.stride();
398    let w = usize::from(width);
399    let white =
400        |x: usize, y: usize| x == 0 || y == 0 || x == w - 1 || y == 7 || x == y || x + y == w - 1;
401    let pixels = picture.pixels_mut();
402    for y in 0..8 {
403        for x in 0..w {
404            if white(x, y) {
405                pixels[y * stride + x / 8] |= 0x80 >> (x % 8);
406            }
407        }
408    }
409    if let Some(mask) = picture.mask_mut() {
410        for y in 0..8 {
411            for x in 0..w {
412                let corner = (x == 0 || x == w - 1) && (y == 0 || y == 7);
413                if corner || x == w / 2 {
414                    mask[y * stride + x / 8] &= !(0x80 >> (x % 8));
415                }
416            }
417        }
418    }
419    picture
420}
421
422/// The painted pictures, from y 140 below the sprites' gallery, each
423/// twice its size: the RGBA one `Under` and `Over` at brightness 102, the
424/// indexed one whole and cut by a `clip` on odd pixels, the masked one,
425/// the left half of the unmasked one, and one the table lacks, twice
426/// (the second from y 158).
427fn painted(buf: &mut GlyphBuffer) {
428    let pictures = buf.pictures_mut();
429    pictures.insert(PAINTED_RGBA, rgba_picture());
430    pictures.insert(PAINTED_INDEXED, indexed_picture());
431    pictures.insert(PAINTED_MASKED, one_bit_picture(8, true));
432    pictures.insert(PAINTED_ONE_BIT, one_bit_picture(16, false));
433    let eight = Rect::new(0, 0, 8, 8);
434    let at = |x: i32| Rect::new(x, 140, 16, 16);
435    let place = |id, x, layer| PaintedSprite::new(id, eight, at(x), layer);
436    buf.add_painted(place(PAINTED_RGBA, 4, Layer::Under));
437    let mut dim = place(PAINTED_RGBA, 26, Layer::Over);
438    dim.brightness = 102;
439    buf.add_painted(dim);
440    buf.add_painted(place(PAINTED_INDEXED, 48, Layer::Over));
441    let mut cut = place(PAINTED_INDEXED, 70, Layer::Under);
442    cut.clip = Rect::new(73, 143, 9, 11);
443    buf.add_painted(cut);
444    buf.add_painted(place(PAINTED_MASKED, 92, Layer::Over));
445    buf.add_painted(place(PAINTED_ONE_BIT, 114, Layer::Under));
446    buf.add_painted(place(PAINTED_MISSING, 136, Layer::Over));
447    let mut missing = place(PAINTED_MISSING, 4, Layer::Under);
448    missing.dest.y = 158;
449    missing.clip = missing.dest;
450    buf.add_painted(missing);
451}
452
453/// The backdrop: a scene of 6 × 5 cells, zoom 2, behind cells 21 to 30
454/// of rows 2 to 10, its scene pixel (0.5, 0.5) at the window's top-left.
455/// The cells there are see-through, some with glyphs, but a few solid
456/// ones: one in the clear colour, which hides the scene too.
457fn backdrop(buf: &mut GlyphBuffer, card: ImageId) {
458    let mut scene = GlyphBuffer::new(6, 5, Cell::new(' ', FOREGROUNDS[0], CLEAR));
459    for y in 0..5 {
460        for x in 0..6 {
461            let i = nth(x + y * 6);
462            if i % 5 == 4 {
463                continue; // The clear colour.
464            }
465            let glyph = if i.is_multiple_of(2) {
466                GLYPHS[i % 4]
467            } else {
468                ' '
469            };
470            let cell = Cell::new(glyph, FOREGROUNDS[i % 5], BACKGROUNDS[i % 4]);
471            scene.set(x, y, cell);
472        }
473    }
474    let full = Rect::new(0, 0, 16, 16);
475    scene.add_sprite(Sprite::new(
476        card,
477        full,
478        Rect::new(12, 20, 16, 16),
479        Layer::Over,
480    ));
481    let line = Overlay::new(Rect::new(3, 45, 30, 2), Rgb::new(230, 40, 40), Layer::Under);
482    scene.add_overlay(line);
483    // Its own picture, which `set_backdrop` copies into the frame's table.
484    scene
485        .pictures_mut()
486        .insert(PAINTED_SCENE, one_bit_picture(16, true));
487    let whole = Rect::new(0, 0, 16, 8);
488    let painted = PaintedSprite::new(PAINTED_SCENE, whole, Rect::new(30, 4, 16, 8), Layer::Over);
489    scene.add_painted(painted);
490    let clip = Rect::new(21, 2, 10, 9);
491    for y in clip.y..clip.y + clip.h {
492        for x in clip.x..clip.x + clip.w {
493            let i = nth(x + y);
494            let glyph = if i.is_multiple_of(3) {
495                GLYPHS[i % 4]
496            } else {
497                ' '
498            };
499            buf.set(x, y, Cell::see_through(glyph, FOREGROUNDS[i % 4]));
500        }
501    }
502    buf.set(23, 4, Cell::new('o', FOREGROUNDS[1], BACKGROUNDS[1]));
503    buf.set(24, 4, Cell::new('#', FOREGROUNDS[2], CLEAR));
504    buf.set(27, 8, Cell::new(' ', FOREGROUNDS[0], BACKGROUNDS[3]));
505    buf.set_backdrop(Rc::new(scene), clip, (0.5, 0.5), 2);
506}
507
508/// The gizmos, last, over everything: eight lines from (208, 96) into
509/// each octant, over the backdrop; two rectangles' outlines, the second
510/// cut by a `clip`; two crosses and three circles' outlines below and
511/// above it, one of radius 0; and two labels in row 11's cells.
512fn gizmos(buf: &mut GlyphBuffer) {
513    let ends = [
514        (238, 104),
515        (216, 126),
516        (200, 126),
517        (178, 104),
518        (178, 88),
519        (200, 66),
520        (216, 66),
521        (238, 88),
522    ];
523    let mut add = |role, shape, color| buf.add_gizmo(GizmoItem::new("card", role, shape, color));
524    for (i, to) in ends.into_iter().enumerate() {
525        let line = GizmoShape::Line {
526            from: (208, 96),
527            to,
528        };
529        add(GizmoRole::Path, line, FOREGROUNDS[i % FOREGROUNDS.len()]);
530    }
531    let rect = |x, y, w, h| GizmoShape::Rect {
532        rect: Rect::new(x, y, w, h),
533    };
534    add(GizmoRole::Bounds, rect(170, 36, 24, 14), FOREGROUNDS[0]);
535    let circle = |center, radius| GizmoShape::Circle { center, radius };
536    add(GizmoRole::Trigger, circle((226, 150), 10), FOREGROUNDS[2]);
537    add(GizmoRole::Bone, circle((250, 20), 3), FOREGROUNDS[3]);
538    add(GizmoRole::Bone, circle((164, 186), 0), FOREGROUNDS[4]);
539    let cross = |at, size| GizmoShape::Cross { at, size };
540    add(GizmoRole::Note, cross((186, 150), 4), FOREGROUNDS[1]);
541    add(GizmoRole::Note, cross((200, 150), 1), FOREGROUNDS[0]);
542    let mut cut = GizmoItem::new(
543        "card",
544        GizmoRole::Solid,
545        rect(226, 40, 18, 10),
546        FOREGROUNDS[1],
547    );
548    cut.clip = Rect::new(230, 38, 20, 6);
549    buf.add_gizmo(cut);
550    // A label's glyphs are in the cells, and its item draws nothing.
551    for (column, text) in [(24, "#o"), (29, "-|")] {
552        buf.print_fg(column, 11, text, FOREGROUNDS[2]);
553        let label = GizmoShape::Label {
554            at: (column * 8, 180),
555            text: LabelText::new(text),
556        };
557        let mut item = GizmoItem::new("card", GizmoRole::Note, label, FOREGROUNDS[2]);
558        item.clip = Rect::new(column * 8, 176, 16, 16);
559        buf.add_gizmo(item);
560    }
561}
562
563/// `v` as an index into a list of colours or glyphs (0 if negative).
564fn nth(v: i32) -> usize {
565    usize::try_from(v).unwrap_or(0)
566}
567
568#[cfg(test)]
569mod tests;