Skip to main content

shuyi_ui/screens/options/
glyph.rs

1//! The glyph look of the Options screen: paints an [`OptionsView`] as a
2//! panel of text rows, in the style of the Key bindings screen. Everything
3//! about the look lives here (where things go, the box characters, the
4//! slider's blocks, the colours); nothing here decides what the screen
5//! shows or does. Another look is another `paint` of the same view.
6
7use super::view::{OptionsView, RowView, ValueView};
8use crate::ctx::{Ctx, GameData};
9use crate::frame::color::EngineColor;
10use crate::frame::draw::PanelLook;
11use crate::frame::glyph_buffer::{BoxStyle, Cell, GlyphBuffer, Rect};
12use crate::prelude::*;
13use crate::screens::print_centred;
14use crate::widgets::ask;
15
16/// The panel, in cells, at its widest and smallest, on a 100-cell
17/// console: on another it is centred on the console's width, and as wide
18/// as the console if that is narrower; it grows down to fit its rows
19/// ([`panel`]).
20const PANEL: Rect = Rect::new(20, 3, 60, 20);
21/// Column of the row labels, from the panel's left edge.
22const LABEL_DX: i32 = 4;
23/// Column of the row values, from the panel's left edge.
24const VALUE_DX: i32 = 30;
25/// The highest the panel's top border rises when its rows need the room
26/// ([`panel`]).
27const TOP_MIN: i32 = 1;
28/// Rows under the panel at the least: a blank one, the message's and the
29/// help line's.
30const BELOW_PANEL: i32 = 3;
31/// Panel rows that aren't the screen's rows: the top border, a blank row
32/// under it and the bottom border.
33const PANEL_FRAME_H: i32 = 3;
34/// Cells in a volume's bar.
35const VOLUME_BAR: u8 = 10;
36/// Cells of the number box, inside its highlight.
37const BOX_W: usize = 4;
38
39/// A volume as a bar of ten cells (a half-filled cell for half
40/// a cell's worth or more) and its number.
41pub fn volume_text(level: u8, max: u8) -> String {
42    let level = level.min(max);
43    let per_cell = (max / VOLUME_BAR).max(1);
44    let full = usize::from(level / per_cell).min(usize::from(VOLUME_BAR));
45    // (A cell worth 1 has no half: its remainder is always 0.)
46    let half = usize::from(level % per_cell >= per_cell.div_ceil(2));
47    let empty = usize::from(VOLUME_BAR).saturating_sub(full + half);
48    let bar = ["█".repeat(full), "▒".repeat(half), "░".repeat(empty)].concat();
49    format!("{bar} {level:>3}")
50}
51
52/// The panel for `view` on `buf`: [`PANEL`] centred on the buffer's
53/// width and no wider than it; no taller than leaves room for the message
54/// under it, unless its rows and the blank rows between their groups need
55/// more. A panel taller than that rises into the rows above [`PANEL`]'s
56/// top (to [`TOP_MIN`] at the highest), so the message still fits.
57fn panel(view: &OptionsView, buf: &GlyphBuffer) -> Rect {
58    let width = i32::from(buf.width());
59    let w = PANEL.w.min(width);
60    let room = i32::from(buf.height()) - PANEL.y - BELOW_PANEL - 1;
61    let gaps = view.rows.iter().filter(|r| r.starts_group).count();
62    let lines = i32::try_from(view.rows.len() + gaps).unwrap_or(i32::MAX);
63    let h = PANEL.h.min(room).max(lines.saturating_add(PANEL_FRAME_H));
64    let y = PANEL.y - (h - room).clamp(0, PANEL.y - TOP_MIN);
65    Rect::new((width - w) / 2, y, w, h)
66}
67
68/// Paints `view` over the whole of `buf`.
69pub fn paint<G: GameData>(ctx: &Ctx<G>, view: &OptionsView, buf: &mut GlyphBuffer) {
70    let c = |e| ctx.palette.engine(e);
71    let (black, bg) = (c(EngineColor::Background), c(EngineColor::Panel));
72    let (text, dim) = (c(EngineColor::Text), c(EngineColor::TextDim));
73    buf.fill_rect(buf.bounds(), Cell::new(' ', text, black));
74    let panel = panel(view, buf);
75    let look = PanelLook::new(BoxStyle::Single, text, bg)
76        .border(c(EngineColor::Border))
77        .title(c(EngineColor::Highlight));
78    buf.panel(panel, &look, Some(&view.title));
79
80    let mut y = panel.y + 2;
81    for row in &view.rows {
82        if row.starts_group {
83            y += 1;
84        }
85        paint_row(ctx, buf, row, panel, y);
86        y += 1;
87    }
88
89    if let Some(message) = &view.message {
90        // The row under the panel's.
91        let row = panel.y + panel.h + 1;
92        print_centred(buf, row, message, c(EngineColor::Highlight), black);
93    }
94    if let Some(question) = &view.question {
95        ask::glyph::paint(&ctx.palette, question, buf);
96    }
97    if let Some(leaving) = &view.leaving {
98        ask::glyph::paint(&ctx.palette, leaving, buf);
99    }
100    let bottom = i32::from(buf.height()) - 1;
101    print_centred(buf, bottom, &view.help, dim, black);
102}
103
104/// Paints `row` on console row `y` of `panel`: the focused row's label is a
105/// highlight bar; a value the cursor changes sits between two arrows; the
106/// number box is a highlight bar where the value was.
107fn paint_row<G: GameData>(ctx: &Ctx<G>, buf: &mut GlyphBuffer, row: &RowView, panel: Rect, y: i32) {
108    let (label_x, value_x) = (panel.x + LABEL_DX, panel.x + VALUE_DX);
109    let c = |e| ctx.palette.engine(e);
110    let (bg, text, dim) = (
111        c(EngineColor::Panel),
112        c(EngineColor::Text),
113        c(EngineColor::TextDim),
114    );
115    let bar = c(EngineColor::Focus);
116    if row.focused {
117        buf.print(label_x - 1, y, &format!(" {} ", row.label), bg, bar);
118    } else {
119        buf.print(label_x, y, &row.label, text, bg);
120    }
121    let (value, adjustable) = match &row.value {
122        ValueView::None => return,
123        ValueView::NumberBox { digits, typing } => {
124            let caret = if *typing { "_" } else { "" };
125            let shown = format!(" {:<BOX_W$} ", format!("{digits}{caret}"));
126            buf.print(value_x - 1, y, &shown, bg, bar);
127            return;
128        }
129        ValueView::Text { text, adjustable } => (text.clone(), *adjustable),
130        ValueView::Volume { level, max } => (volume_text(*level, *max), true),
131    };
132    let value_fg = if row.focused {
133        c(EngineColor::Highlight)
134    } else {
135        text
136    };
137    buf.print(value_x, y, &value, value_fg, bg);
138    if row.focused && adjustable {
139        buf.print(value_x - 2, y, "◄", dim, bg);
140        let after = value_x + buf.text_cells(&value) + 1;
141        buf.print(after, y, "►", dim, bg);
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::frame::console::ConsoleSpec;
149    use crate::toy::{ToyCtx, ctx};
150    use crate::widgets::AskView;
151
152    /// The console the skin is laid out for.
153    const PC: ConsoleSpec = ConsoleSpec::text(100, 32);
154    /// A small console, which the skin fits.
155    const SMALL: ConsoleSpec = ConsoleSpec::text(48, 24);
156
157    /// Row `y` of `view` as painted, trimmed.
158    fn painted_row(c: &ToyCtx, view: &OptionsView, y: i32) -> String {
159        let black = c.palette.engine(EngineColor::Background);
160        let mut buf = GlyphBuffer::with_spec(PC, Cell::new(' ', black, black));
161        paint(c, view, &mut buf);
162        let glyphs = (0..i32::from(PC.cols)).map(|x| buf.get(x, y).map_or(' ', |c| c.glyph));
163        glyphs.collect::<String>().trim().to_owned()
164    }
165
166    fn row(label: &str, value: ValueView, focused: bool) -> RowView {
167        RowView {
168            key: "options.row.text_speed",
169            label: label.to_owned(),
170            value,
171            focused,
172            starts_group: false,
173        }
174    }
175
176    fn view(rows: Vec<RowView>) -> OptionsView {
177        OptionsView {
178            title: "Options".to_owned(),
179            rows,
180            message: None,
181            question: None,
182            leaving: None,
183            help: "help".to_owned(),
184        }
185    }
186
187    fn words(text: &str, adjustable: bool) -> ValueView {
188        ValueView::Text {
189            text: text.to_owned(),
190            adjustable,
191        }
192    }
193
194    #[test]
195    fn a_volume_is_a_bar_of_ten_cells_and_its_number() {
196        assert_eq!(volume_text(80, 100), "████████░░  80");
197        assert_eq!(volume_text(0, 100), "░░░░░░░░░░   0");
198        assert_eq!(volume_text(100, 100), "██████████ 100");
199        // A half cell for the odd five; under it rounds down.
200        assert_eq!(volume_text(85, 100), "████████▒░  85");
201        assert_eq!(volume_text(84, 100), "████████░░  84");
202        assert_eq!(volume_text(5, 100), "▒░░░░░░░░░   5");
203        assert_eq!(volume_text(99, 100), "█████████▒  99");
204        // Past the loudest is the loudest; a small scale has no halves.
205        assert_eq!(volume_text(200, 100), "██████████ 100");
206        assert_eq!(volume_text(7, 10), "███████░░░   7");
207        assert_eq!(volume_text(3, 5), "███░░░░░░░   3");
208    }
209
210    #[test]
211    fn rows_are_painted_from_the_second_row_of_the_panel_with_gaps_before_groups() {
212        let c = ctx();
213        let mut grouped = row("Layout", words("Right-handed", false), false);
214        grouped.starts_group = true;
215        let rows = vec![
216            row("Text speed", words("Normal", true), true),
217            row(
218                "Music volume",
219                ValueView::Volume {
220                    level: 65,
221                    max: 100,
222                },
223                false,
224            ),
225            grouped,
226            row("Key bindings", ValueView::None, false),
227        ];
228        let v = view(rows);
229        assert!(painted_row(&c, &v, PANEL.y).starts_with("┌─ Options ─"));
230        assert_eq!(
231            painted_row(&c, &v, PANEL.y + 1),
232            "│                                                          │"
233        );
234        // The focused row: arrows round a value the cursor changes.
235        let first = painted_row(&c, &v, PANEL.y + 2);
236        assert_eq!(
237            first,
238            format!("│   {:<24}◄ Normal ►{:>22}", "Text speed", "│")
239        );
240        let second = painted_row(&c, &v, PANEL.y + 3);
241        assert!(
242            second.contains("Music volume              ██████▒░░░  65"),
243            "{second}"
244        );
245        assert!(!second.contains('◄'));
246        // A blank row before the group.
247        assert_eq!(
248            painted_row(&c, &v, PANEL.y + 4),
249            painted_row(&c, &v, PANEL.y + 1)
250        );
251        assert!(
252            painted_row(&c, &v, PANEL.y + 5).contains("Layout                    Right-handed")
253        );
254        assert!(painted_row(&c, &v, PANEL.y + 6).contains("Key bindings   "));
255        assert_eq!(painted_row(&c, &v, 31), "help");
256    }
257
258    #[test]
259    fn a_focused_value_the_cursor_cant_change_has_no_arrows() {
260        let c = ctx();
261        let v = view(vec![row("Layout", words("Right-handed", false), true)]);
262        let painted = painted_row(&c, &v, PANEL.y + 2);
263        assert!(
264            painted.contains("Right-handed") && !painted.contains('◄') && !painted.contains('►')
265        );
266        let v = view(vec![row(
267            "Music volume",
268            ValueView::Volume {
269                level: 80,
270                max: 100,
271            },
272            true,
273        )]);
274        assert!(painted_row(&c, &v, PANEL.y + 2).contains("◄ ████████░░  80 ►"));
275    }
276
277    #[test]
278    fn the_number_box_shows_a_caret_only_while_typing() {
279        let c = ctx();
280        let boxed = |digits: &str, typing| {
281            let value = ValueView::NumberBox {
282                digits: digits.to_owned(),
283                typing,
284            };
285            painted_row(
286                &c,
287                &view(vec![row("Sound volume", value, true)]),
288                PANEL.y + 2,
289            )
290        };
291        assert!(boxed("37", true).contains(" 37_ "), "{}", boxed("37", true));
292        assert!(boxed("", true).contains("  _ "));
293        assert!(boxed("80", false).contains(" 80 ") && !boxed("80", false).contains('_'));
294        // The box's highlight is as wide whatever is in it.
295        let bar = c.palette.engine(EngineColor::Focus);
296        let width = |digits: &str| {
297            let value = ValueView::NumberBox {
298                digits: digits.to_owned(),
299                typing: true,
300            };
301            let black = c.palette.engine(EngineColor::Background);
302            let mut buf = GlyphBuffer::with_spec(PC, Cell::new(' ', black, black));
303            paint(&c, &view(vec![row("Sound volume", value, true)]), &mut buf);
304            (PANEL.x + VALUE_DX - 1..i32::from(PC.cols))
305                .take_while(|&x| buf.get(x, PANEL.y + 2).is_some_and(|cell| cell.bg == bar))
306                .count()
307        };
308        assert_eq!((width(""), width("100")), (BOX_W + 2, BOX_W + 2));
309    }
310
311    /// The message sits one blank row under the panel's bottom border.
312    #[test]
313    fn the_message_is_painted_under_the_panel() {
314        let c = ctx();
315        let mut v = view(vec![]);
316        v.message = Some("Tips will show again".to_owned());
317        let bottom = PANEL.y + PANEL.h - 1;
318        assert!(painted_row(&c, &v, bottom).starts_with('└'));
319        assert_eq!(painted_row(&c, &v, bottom + 1), "");
320        assert_eq!(painted_row(&c, &v, bottom + 2), "Tips will show again");
321        assert_eq!(painted_row(&c, &v, bottom + 3), "");
322    }
323
324    /// Every row a campaign with pictures shows (20 lines with the two
325    /// group gaps) fits inside the panel: it grows, and the message
326    /// moves down with it.
327    #[test]
328    fn the_panel_grows_to_fit_its_rows() {
329        let c = ctx();
330        let mut rows: Vec<RowView> = (0..18)
331            .map(|i| row(&format!("Row {i}"), ValueView::None, false))
332            .collect();
333        rows[12].starts_group = true;
334        rows[16].starts_group = true;
335        let mut v = view(rows);
336        v.message = Some("Defaults restored".to_owned());
337        let last = PANEL.y + 2 + 20 - 1;
338        assert!(painted_row(&c, &v, last).starts_with("│   Row 17"));
339        assert!(painted_row(&c, &v, last + 1).starts_with('└'));
340        assert_eq!(painted_row(&c, &v, last + 3), "Defaults restored");
341        assert_eq!(painted_row(&c, &v, 31), "help");
342    }
343
344    /// Rows that would push the message onto the help line raise the
345    /// panel instead, a row at a time, up to row 1.
346    #[test]
347    fn a_taller_panel_rises_so_the_message_fits() {
348        let c = ctx();
349        for (count, top) in [(22, PANEL.y), (23, PANEL.y - 1), (24, 1), (25, 1)] {
350            let rows: Vec<RowView> = (0..count)
351                .map(|i| row(&format!("Row {i}"), ValueView::None, false))
352                .collect();
353            let mut v = view(rows);
354            v.message = Some("Defaults restored".to_owned());
355            assert!(painted_row(&c, &v, top).starts_with('┌'), "{count}");
356            let bottom = top + 2 + count + 1;
357            assert!(painted_row(&c, &v, bottom - 1).starts_with('└'), "{count}");
358            assert_eq!(painted_row(&c, &v, bottom + 1), "Defaults restored");
359            assert_eq!(painted_row(&c, &v, 31), "help", "{count}");
360        }
361    }
362
363    #[test]
364    fn a_question_is_a_box_in_the_middle_with_its_answers_under_it() {
365        let c = ctx();
366        let mut v = view(vec![row("Reset tips", ValueView::None, true)]);
367        v.question = Some(AskView {
368            text: "Show every tip again?".to_owned(),
369            answers: Vec::new(),
370            keys: Some("f yes / d no".to_owned()),
371        });
372        let top = (i32::from(PC.rows) - ask::glyph::HEIGHT) / 2;
373        assert!(painted_row(&c, &v, top).contains("╔═══════════════════════╗"));
374        assert!(painted_row(&c, &v, top + 2).contains("║ Show every tip again? ║"));
375        assert!(painted_row(&c, &v, top + 3).contains("║ f yes / d no          ║"));
376        assert!(painted_row(&c, &v, top + 5).contains("╚═══════════════════════╝"));
377    }
378
379    #[test]
380    fn the_question_on_leaving_is_a_box_with_its_answers_in_a_line_the_focused_one_lit() {
381        use crate::screens::options::LeaveChoice;
382        use crate::widgets::AnswerView;
383        let c = ctx();
384        let mut v = view(vec![row("Text speed", words("Fast", true), true)]);
385        let choice = |answer, label: &str| AnswerView {
386            answer,
387            label: label.to_owned(),
388            focused: answer == LeaveChoice::Back,
389        };
390        v.leaving = Some(AskView {
391            text: "Keep these changes?".to_owned(),
392            keys: None,
393            answers: vec![
394                choice(LeaveChoice::Keep, "Keep"),
395                choice(LeaveChoice::Discard, "Discard"),
396                choice(LeaveChoice::Back, "Back"),
397            ],
398        });
399        let top = (i32::from(PC.rows) - ask::glyph::HEIGHT) / 2;
400        // As wide as the answers' line, the wider.
401        let question = format!("║ {:<27} ║", "Keep these changes?");
402        assert!(painted_row(&c, &v, top + 2).contains(&question));
403        assert!(painted_row(&c, &v, top + 3).contains("║  Keep     Discard     Back  ║"));
404        // Only the focused answer is lit.
405        let black = c.palette.engine(EngineColor::Background);
406        let mut buf = GlyphBuffer::with_spec(PC, Cell::new(' ', black, black));
407        paint(&c, &v, &mut buf);
408        let bar = c.palette.engine(EngineColor::Focus);
409        let lit: String = (0..i32::from(PC.cols))
410            .filter_map(|x| buf.get(x, top + 3))
411            .filter(|cell| cell.bg == bar)
412            .map(|cell| cell.glyph)
413            .collect();
414        assert_eq!(lit, " Back ");
415    }
416
417    /// The rows of a game's options, the music volume focused, and a
418    /// message.
419    fn small_view() -> OptionsView {
420        let volume = |level| ValueView::Volume { level, max: 100 };
421        let mut look = row("Map look", words("Glyphs", true), false);
422        look.starts_group = true;
423        let mut keys = row("Key bindings", ValueView::None, false);
424        keys.starts_group = true;
425        let mut v = view(vec![
426            row("Text speed", words("Normal", true), false),
427            row("Fullscreen", words("Off", true), false),
428            row("Music volume", volume(100), true),
429            row("Sound volume", volume(65), false),
430            look,
431            keys,
432            row("Restore defaults", ValueView::None, false),
433        ]);
434        v.message = Some("Couldn't save: the change lasts until you quit".to_owned());
435        v
436    }
437
438    fn painted_small(c: &ToyCtx, view: &OptionsView) -> GlyphBuffer {
439        let black = c.palette.engine(EngineColor::Background);
440        let mut buf = GlyphBuffer::with_spec(SMALL, Cell::new(' ', black, black));
441        paint(c, view, &mut buf);
442        buf
443    }
444
445    /// On a 48-cell console the panel is the console's width and every
446    /// row's label and value is inside it; the message still shows, under
447    /// it.
448    #[test]
449    fn on_a_small_console_every_row_is_inside_the_panel() {
450        let c = ctx();
451        let v = small_view();
452        let buf = painted_small(&c, &v);
453        let row = |y| -> String {
454            (0..i32::from(SMALL.cols))
455                .map(|x| buf.get(x, y).map_or(' ', |c| c.glyph))
456                .collect()
457        };
458        assert_eq!(panel(&v, &buf), Rect::new(0, 3, 48, 17));
459        assert_eq!(
460            row(7),
461            format!("│   {:<24}◄ ██████████ 100 ►{:>2}", "Music volume", "│")
462        );
463        for y in 4..19 {
464            let line = row(y);
465            assert!(line.starts_with('│') && line.ends_with('│'), "{line}");
466        }
467        assert!(row(19).starts_with('└'));
468        assert_eq!(
469            row(21).trim(),
470            "Couldn't save: the change lasts until you quit"
471        );
472        assert_eq!(row(23).trim(), "help");
473        // On the game's console, the panel is where it always was.
474        let mut buf = GlyphBuffer::with_spec(
475            PC,
476            Cell::new(
477                ' ',
478                c.palette.engine(EngineColor::Background),
479                c.palette.engine(EngineColor::Background),
480            ),
481        );
482        paint(&c, &v, &mut buf);
483        assert_eq!(panel(&v, &buf), PANEL);
484    }
485
486    #[test]
487    fn the_small_console_snapshot() {
488        let c = ctx();
489        let buf = painted_small(&c, &small_view());
490        insta::assert_snapshot!(buf.to_snapshot(&c.palette));
491    }
492}