Skip to main content

shuyi_ui/screens/layout_picker/
glyph.rs

1//! The glyph look of the layout picker: paints a [`LayoutPickerView`] as
2//! one panel per layout, each with a small keyboard (the keys' places are
3//! this skin's) and a legend. Everything about the look lives here; nothing
4//! here decides what the screen shows. Another look is another `paint` of
5//! the same view.
6
7use shuyi_assets::keymap::Key;
8
9use super::view::{KeyRole, LayoutPickerView, LayoutView};
10use crate::ctx::{Ctx, GameData};
11use crate::frame::color::EngineColor;
12use crate::frame::draw::PanelLook;
13use crate::frame::glyph_buffer::{BoxStyle, Cell, GlyphBuffer, Rect};
14use crate::prelude::*;
15use crate::screens::print_centred;
16
17/// Row of the title.
18const TITLE_ROW: i32 = 1;
19/// Top row of the first panel.
20const FIRST_PANEL_ROW: i32 = 3;
21/// Panel size in cells, border included.
22const PANEL_W: i32 = 76;
23const PANEL_H: i32 = 13;
24/// Blank rows between panels.
25const PANEL_GAP: i32 = 1;
26
27/// Offsets inside a panel: the keyboard's left edge, its three rows, and
28/// the legend's column and first row.
29const KEYS_X: i32 = 3;
30const TOP_ROW_Y: i32 = 4;
31const HOME_ROW_Y: i32 = 5;
32const SPACE_ROW_Y: i32 = 7;
33const LEGEND_X: i32 = 50;
34const LEGEND_Y: i32 = 2;
35/// Width of the key column in the legend.
36const LEGEND_KEY_W: usize = 12;
37
38/// Width of one key cap, `[w]`.
39const CAP_W: i32 = 3;
40/// The two letter rows drawn, left to right as on a QWERTY keyboard. What
41/// each key does comes from the view ([`LayoutView::role`]), not from here.
42// check-keys: keyboard picture
43const TOP_ROW: [Key; 10] = [
44    Key::Q,
45    Key::W,
46    Key::E,
47    Key::R,
48    Key::T,
49    Key::Y,
50    Key::U,
51    Key::I,
52    Key::O,
53    Key::P,
54];
55// check-keys: keyboard picture
56const HOME_ROW: [Key; 10] = [
57    Key::A,
58    Key::S,
59    Key::D,
60    Key::F,
61    Key::G,
62    Key::H,
63    Key::J,
64    Key::K,
65    Key::L,
66    Key::Semicolon,
67];
68/// The arrow-key cluster's caps: the top one, then the bottom row.
69// check-keys: keyboard picture
70const UP_CAP: (Key, &str) = (Key::Up, "↑");
71// check-keys: keyboard picture
72const LOWER_ARROW_CAPS: [(Key, &str); 3] = [(Key::Left, "←"), (Key::Down, "↓"), (Key::Right, "→")];
73/// The space bar's key.
74// check-keys: keyboard picture
75const SPACE_BAR: Key = Key::Space;
76/// Left edge of the arrow-key cluster, right of the letter rows.
77const ARROWS_X: i32 = KEYS_X + 10 * CAP_W + 4;
78/// The space bar: left edge (under `d`) and width.
79const SPACE_X: i32 = KEYS_X + 1 + 2 * CAP_W;
80const SPACE_W: usize = 19;
81
82/// The colour a key cap's label gets for `role`.
83fn role_colour(role: KeyRole) -> EngineColor {
84    match role {
85        KeyRole::Movement => EngineColor::Accent,
86        KeyRole::Bound => EngineColor::Highlight,
87        KeyRole::Unbound => EngineColor::TextDim,
88    }
89}
90
91/// Paints `view` over the whole of `buf`.
92pub fn paint<G: GameData>(ctx: &Ctx<G>, view: &LayoutPickerView, buf: &mut GlyphBuffer) {
93    let c = |e| ctx.palette.engine(e);
94    let black = c(EngineColor::Background);
95    buf.fill_rect(buf.bounds(), Cell::new(' ', c(EngineColor::Text), black));
96    print_centred(
97        buf,
98        TITLE_ROW,
99        &view.title,
100        c(EngineColor::Highlight),
101        black,
102    );
103    let x = (i32::from(buf.width()) - PANEL_W) / 2;
104    for (i, layout) in (0..).zip(&view.layouts) {
105        let y = FIRST_PANEL_ROW + i * (PANEL_H + PANEL_GAP);
106        paint_panel(ctx, buf, layout, x, y);
107    }
108    let bottom = i32::from(buf.height()) - 1;
109    print_centred(buf, bottom, &view.help, c(EngineColor::TextDim), black);
110}
111
112/// Paints `layout`'s panel with its top-left corner at `(x, y)`: a double
113/// border and an arrow for the focused one.
114fn paint_panel<G: GameData>(
115    ctx: &Ctx<G>,
116    buf: &mut GlyphBuffer,
117    layout: &LayoutView,
118    x: i32,
119    y: i32,
120) {
121    let c = |e| ctx.palette.engine(e);
122    let bg = c(EngineColor::Panel);
123    let rect = Rect::new(x, y, PANEL_W, PANEL_H);
124    let (style, border, title, title_fg) = if layout.focused {
125        (
126            BoxStyle::Double,
127            c(EngineColor::Focus),
128            // check-text: not a data name (the view's own)
129            format!("► {}", layout.name),
130            c(EngineColor::Highlight),
131        )
132    } else {
133        (
134            BoxStyle::Single,
135            c(EngineColor::Border),
136            // check-text: not a data name (the view's own)
137            layout.name.clone(),
138            c(EngineColor::Text),
139        )
140    };
141    let look = PanelLook::new(style, c(EngineColor::Text), bg)
142        .border(border)
143        .title(title_fg);
144    buf.panel(rect, &look, Some(&title));
145
146    let cap_fg = |key| c(role_colour(layout.role(key)));
147    let dim = c(EngineColor::TextDim);
148    let mut cap = |cx: i32, cy: i32, key: Key, glyph: &str| {
149        buf.print(cx, cy, "[", dim, bg);
150        buf.print(cx + 1, cy, glyph, cap_fg(key), bg);
151        buf.print(cx + 2, cy, "]", dim, bg);
152    };
153    for (col, key) in (0..).zip(TOP_ROW) {
154        cap(x + KEYS_X + col * CAP_W, y + TOP_ROW_Y, key, key.name());
155    }
156    for (col, key) in (0..).zip(HOME_ROW) {
157        cap(
158            x + KEYS_X + 1 + col * CAP_W,
159            y + HOME_ROW_Y,
160            key,
161            key.name(),
162        );
163    }
164    let (up, up_glyph) = UP_CAP;
165    cap(x + ARROWS_X + CAP_W, y + TOP_ROW_Y, up, up_glyph);
166    for (col, (key, glyph)) in (0..).zip(LOWER_ARROW_CAPS) {
167        cap(x + ARROWS_X + col * CAP_W, y + HOME_ROW_Y, key, glyph);
168    }
169    let space = format!("[{:^w$}]", SPACE_BAR.name(), w = SPACE_W - 2);
170    buf.print(x + SPACE_X, y + SPACE_ROW_Y, &space, dim, bg);
171    let name_x = x + SPACE_X + (i32::try_from(SPACE_W).unwrap_or(0) - 5) / 2;
172    buf.print(
173        name_x,
174        y + SPACE_ROW_Y,
175        SPACE_BAR.name(),
176        cap_fg(SPACE_BAR),
177        bg,
178    );
179
180    for (row, line) in (y + LEGEND_Y..).zip(&layout.legend) {
181        let fg = if line.movement {
182            c(EngineColor::Accent)
183        } else {
184            c(EngineColor::Highlight)
185        };
186        buf.print(x + LEGEND_X, row, &line.keys, fg, bg);
187        let what_x = x + LEGEND_X + i32::try_from(LEGEND_KEY_W).unwrap_or(0);
188        buf.print(what_x, row, &line.what, c(EngineColor::Text), bg);
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::frame::console::ConsoleSpec;
196    use crate::screens::layout_picker::LayoutPickerScreen;
197    use crate::screens::layout_picker::view::{KeyCapView, LegendRow};
198    use crate::toy::{self, ToyCtx, ctx};
199
200    /// The console the skin is laid out for.
201    const PC: ConsoleSpec = ConsoleSpec::text(100, 32);
202
203    fn painted(c: &ToyCtx, view: &LayoutPickerView) -> GlyphBuffer {
204        let stale = Cell::new(
205            'x',
206            c.palette.engine(EngineColor::Warning),
207            c.palette.engine(EngineColor::Warning),
208        );
209        let mut buf = GlyphBuffer::with_spec(PC, stale);
210        paint(c, view, &mut buf);
211        buf
212    }
213
214    fn text(buf: &GlyphBuffer, y: i32) -> String {
215        (0..i32::from(buf.width()))
216            .map(|x| buf.get(x, y).map_or(' ', |c| c.glyph))
217            .collect()
218    }
219
220    /// The column `word` starts at in `row` (in cells, not bytes).
221    fn col(row: &str, word: &str) -> usize {
222        row[..row.find(word).unwrap()].chars().count()
223    }
224
225    /// A one-key legend and two layouts, the second focused.
226    fn view(role_of_q: KeyRole, role_of_f: KeyRole) -> LayoutPickerView {
227        let layout = |name: &str, focused| LayoutView {
228            layout: toy::RIGHT_HANDED,
229            name: name.to_owned(),
230            focused,
231            legend: vec![
232                LegendRow {
233                    keys: "arrows".to_owned(),
234                    what: "move".to_owned(),
235                    movement: true,
236                },
237                LegendRow {
238                    keys: "f".to_owned(),
239                    what: "select".to_owned(),
240                    movement: false,
241                },
242            ],
243            keys: vec![
244                KeyCapView {
245                    key: Key::Q,
246                    role: role_of_q,
247                },
248                KeyCapView {
249                    key: Key::F,
250                    role: role_of_f,
251                },
252            ],
253        };
254        LayoutPickerView {
255            title: "Pick".to_owned(),
256            layouts: vec![layout("First", false), layout("Second", true)],
257            help: "help".to_owned(),
258        }
259    }
260
261    const LEFT: i32 = (PC.cols as i32 - PANEL_W) / 2;
262    const SECOND: i32 = FIRST_PANEL_ROW + PANEL_H + PANEL_GAP;
263
264    #[test]
265    fn two_panels_stack_under_the_centred_title_with_help_at_the_bottom() {
266        let c = ctx();
267        let buf = painted(&c, &view(KeyRole::Bound, KeyRole::Bound));
268        assert_eq!(text(&buf, TITLE_ROW).trim(), "Pick");
269        let first = text(&buf, FIRST_PANEL_ROW);
270        let second = text(&buf, SECOND);
271        assert_eq!(first.find('┌'), Some(usize::try_from(LEFT).unwrap()));
272        assert!(first.contains("┌─ First ─"), "{first}");
273        // The focused panel: double border and an arrow before its name.
274        assert!(second.contains("╔═ ► Second ═"), "{second}");
275        assert!(text(&buf, FIRST_PANEL_ROW + PANEL_H - 1).contains('└'));
276        assert!(text(&buf, SECOND + PANEL_H - 1).contains('╚'));
277        assert_eq!(text(&buf, i32::from(PC.rows) - 1).trim(), "help");
278        // The gap between the panels is blank.
279        let found = text(&buf, FIRST_PANEL_ROW + PANEL_H);
280        assert!(found.trim().is_empty(), "{found:?}");
281    }
282
283    #[test]
284    fn the_caps_sit_where_the_keyboard_has_them_and_take_their_role_colour() {
285        let c = ctx();
286        let buf = painted(&c, &view(KeyRole::Movement, KeyRole::Unbound));
287        let at = |x, y| *buf.get(x, y).unwrap();
288        // `q` is the first cap of the top row, `f` the fourth of the home
289        // row (indented half a cap).
290        let (qx, qy) = (LEFT + KEYS_X, FIRST_PANEL_ROW + TOP_ROW_Y);
291        let q_row = text(&buf, qy);
292        assert_eq!(col(&q_row, "[q]"), usize::try_from(qx).unwrap());
293        assert_eq!(at(qx + 1, qy).fg, c.palette.engine(EngineColor::Accent));
294        let (fx, fy) = (LEFT + KEYS_X + 1 + 3 * CAP_W, FIRST_PANEL_ROW + HOME_ROW_Y);
295        assert_eq!(at(fx + 1, fy).glyph, 'f');
296        assert_eq!(at(fx + 1, fy).fg, c.palette.engine(EngineColor::TextDim));
297        // The brackets are dim whatever the key does.
298        assert_eq!(at(qx, qy).fg, c.palette.engine(EngineColor::TextDim));
299        // A key the view doesn't list is unbound.
300        let (wx, wy) = (LEFT + KEYS_X + CAP_W, FIRST_PANEL_ROW + TOP_ROW_Y);
301        assert_eq!(at(wx + 1, wy).glyph, 'w');
302        assert_eq!(at(wx + 1, wy).fg, c.palette.engine(EngineColor::TextDim));
303        // The arrow cluster's caps and the space bar.
304        let arrows = text(&buf, FIRST_PANEL_ROW + HOME_ROW_Y);
305        assert!(arrows.contains("[←][↓][→]"), "{arrows}");
306        assert!(text(&buf, FIRST_PANEL_ROW + TOP_ROW_Y).contains("[↑]"));
307        assert!(text(&buf, FIRST_PANEL_ROW + SPACE_ROW_Y).contains("[      Space      ]"));
308    }
309
310    #[test]
311    fn the_legend_is_two_columns_and_the_movement_keys_are_set_apart() {
312        let c = ctx();
313        let buf = painted(&c, &view(KeyRole::Bound, KeyRole::Bound));
314        let (x, y) = (LEFT + LEGEND_X, FIRST_PANEL_ROW + LEGEND_Y);
315        let first = text(&buf, y);
316        assert_eq!(col(&first, "arrows"), usize::try_from(x).unwrap());
317        assert_eq!(
318            col(&first, "move"),
319            usize::try_from(x).unwrap() + LEGEND_KEY_W
320        );
321        let at = |x, y| buf.get(x, y).unwrap().fg;
322        assert_eq!(at(x, y), c.palette.engine(EngineColor::Accent));
323        assert_eq!(at(x, y + 1), c.palette.engine(EngineColor::Highlight));
324        let what_x = x + i32::try_from(LEGEND_KEY_W).unwrap();
325        assert_eq!(at(what_x, y), c.palette.engine(EngineColor::Text));
326    }
327
328    #[test]
329    fn it_paints_the_real_screen_over_the_whole_buffer() {
330        let c = ctx();
331        let screen = LayoutPickerScreen::new(&c, &toy::LEGEND);
332        let stale = Cell::new(
333            'x',
334            c.palette.engine(EngineColor::Warning),
335            c.palette.engine(EngineColor::Warning),
336        );
337        let buf = painted(&c, &screen.view(&c));
338        let left = (0..i32::from(PC.rows))
339            .flat_map(|y| (0..i32::from(PC.cols)).map(move |x| (x, y)))
340            .filter(|&(x, y)| buf.get(x, y) == Some(&stale))
341            .count();
342        assert_eq!(left, 0);
343    }
344
345    #[test]
346    fn roles_have_their_colours() {
347        assert_eq!(role_colour(KeyRole::Movement), EngineColor::Accent);
348        assert_eq!(role_colour(KeyRole::Bound), EngineColor::Highlight);
349        assert_eq!(role_colour(KeyRole::Unbound), EngineColor::TextDim);
350    }
351}