Skip to main content

shuyi_ui/widgets/menu/
glyph.rs

1//! The glyph look of a [`Menu`](super::Menu): paints a [`MenuView`] as a
2//! single-line box of text rows with the focused one as a bar and, when
3//! its items have notes, the focused item's note under a rule. A menu that
4//! scrolls shows its window, with a mark on the border where more items
5//! are out of the box. Everything
6//! about the look lives here; nothing here decides what the menu shows.
7//! Another look is another `paint` of the same view (game ADR-0054).
8
9use super::view::{MenuItemView, MenuView, SuffixKind};
10use crate::ctx::{Ctx, GameData};
11use crate::frame::color::{EngineColor, Palette};
12use crate::frame::draw::PanelLook;
13use crate::frame::glyph_buffer::{BoxStyle, Cell, GlyphBuffer, Rect};
14use crate::prelude::*;
15use shuyi_assets::font::CellWidths;
16use shuyi_assets::lang::text_width;
17
18/// What each item's label says, in the player's language: the view's
19/// labels as [`Ctx::label`] words them, in order.
20fn labels<G: GameData>(ctx: &Ctx<G>, view: &MenuView) -> Vec<String> {
21    view.items.iter().map(|i| ctx.label(&i.label)).collect()
22}
23
24/// Cells `label` and the suffix of `item` take, measured with `widths`.
25fn width(item: &MenuItemView, label: &str, widths: &CellWidths) -> usize {
26    let suffix = item
27        .suffix
28        .as_ref()
29        .map_or(0, |(t, _)| text_width(t, widths) + 1);
30    text_width(label, widths) + suffix
31}
32
33/// Cells the note of `item` takes, measured with `widths`.
34fn note_width(item: &MenuItemView, widths: &CellWidths) -> usize {
35    item.note.as_ref().map_or(0, |n| text_width(n, widths))
36}
37
38/// Whether the menu has a note row: some item has a note.
39fn has_notes(view: &MenuView) -> bool {
40    view.items.iter().any(|i| i.note.is_some())
41}
42
43/// Box size in cells: the widest label in the player's language (or note,
44/// so the box keeps its width as the focus moves or the menu scrolls) plus
45/// a space and a border on each side, by one row per item shown plus the
46/// border, and a rule and a note row if any item has a note.
47pub fn size<G: GameData>(ctx: &Ctx<G>, view: &MenuView) -> (i32, i32) {
48    size_with(view, &labels(ctx, view), ctx.widths())
49}
50
51/// [`size`], the labels worded as `labels` and measured with `widths`.
52fn size_with(view: &MenuView, labels: &[String], widths: &CellWidths) -> (i32, i32) {
53    let widest = view
54        .items
55        .iter()
56        .zip(labels)
57        .map(|(i, label)| width(i, label, widths).max(note_width(i, widths)))
58        .max()
59        .unwrap_or(0);
60    let w = i32::try_from(widest).unwrap_or(i32::MAX).saturating_add(4);
61    let notes = if has_notes(view) { 2 } else { 0 };
62    let h = i32::try_from(view.shown_items().len())
63        .unwrap_or(i32::MAX)
64        .saturating_add(2 + notes);
65    (w, h)
66}
67
68/// The colour of the text after a label: the warning colour for a
69/// warning, dim for an aside.
70pub const fn suffix_color(kind: SuffixKind) -> EngineColor {
71    match kind {
72        SuffixKind::Warning => EngineColor::Warning,
73        SuffixKind::Aside => EngineColor::TextDim,
74    }
75}
76
77/// Paints `view` with its top-left corner at `(x, y)`: a
78/// [`Panel`](EngineColor::Panel) box with a [`Border`](EngineColor::Border)
79/// single line, items in [`Text`](EngineColor::Text), disabled ones in
80/// [`TextDim`](EngineColor::TextDim), and the focused one as a bar of panel
81/// text on [`Focus`](EngineColor::Focus) (on the border's color for a
82/// disabled one: a menu that browses disabled items). Under the items, if
83/// any has a note: a border rule and the focused item's note in text. A
84/// scrolled menu paints only the items it shows, with a `▲` in the top
85/// border if there are more above and a `▼` in the border under them if
86/// there are more below, in text. Labels are in the player's language
87/// ([`Ctx::label`]).
88pub fn paint<G: GameData>(ctx: &Ctx<G>, view: &MenuView, buf: &mut GlyphBuffer, x: i32, y: i32) {
89    let palette: &Palette = &ctx.palette;
90    let color = |e| palette.engine(e);
91    let bg = color(EngineColor::Panel);
92    let labels = labels(ctx, view);
93    let (w, h) = size_with(view, &labels, buf.widths());
94    let rect = Rect::new(x, y, w, h);
95    let look = PanelLook::new(BoxStyle::Single, color(EngineColor::Text), bg)
96        .border(color(EngineColor::Border));
97    buf.panel(rect, &look, None);
98    let first = view.first_shown.min(view.items.len());
99    let shown = view.shown_items().iter().zip(&labels[first..]);
100    for (row, (item, label)) in (y + 1..).zip(shown) {
101        let (fg, row_bg) = match (item.focused, item.enabled) {
102            (true, false) => (bg, color(EngineColor::Border)),
103            (false, false) => (color(EngineColor::TextDim), bg),
104            (true, true) => (bg, color(EngineColor::Focus)),
105            (false, true) => (color(EngineColor::Text), bg),
106        };
107        buf.fill_rect(Rect::new(x + 1, row, w - 2, 1), Cell::new(' ', fg, row_bg));
108        buf.print(x + 2, row, label, fg, row_bg);
109        if let Some((text, kind)) = &item.suffix {
110            let at = x + 3 + buf.text_cells(label);
111            // On the focus bar the colour would vanish: keep the bar's.
112            let fg = if item.focused {
113                fg
114            } else {
115                color(suffix_color(*kind))
116            };
117            buf.print(at, row, text, fg, row_bg);
118        }
119    }
120    let notes = if has_notes(view) { 2 } else { 0 };
121    let mark = |buf: &mut GlyphBuffer, row, glyph| {
122        buf.set(
123            x + w - 2,
124            row,
125            Cell::new(glyph, color(EngineColor::Text), bg),
126        );
127    };
128    if has_notes(view) {
129        let rule = y + h - 3;
130        let border = color(EngineColor::Border);
131        buf.fill_rect(Rect::new(x + 1, rule, w - 2, 1), Cell::new('─', border, bg));
132        buf.set(x, rule, Cell::new('├', border, bg));
133        buf.set(x + w - 1, rule, Cell::new('┤', border, bg));
134        if let Some(note) = view.focused().and_then(|i| i.note.as_ref()) {
135            buf.print(x + 2, rule + 1, note, color(EngineColor::Text), bg);
136        }
137    }
138    if view.more_above() {
139        mark(buf, y, '▲');
140    }
141    if view.more_below() {
142        mark(buf, y + h - 1 - notes, '▼');
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::frame::color::tests::engine_palette;
150    use crate::toy::ToyCtx;
151
152    /// The toy's context, painting in the engine's test palette.
153    fn ctx() -> ToyCtx {
154        let mut c = crate::toy::ctx();
155        c.palette = engine_palette();
156        c
157    }
158
159    fn size(view: &MenuView) -> (i32, i32) {
160        super::size(&ctx(), view)
161    }
162
163    fn item(label: &str, enabled: bool, focused: bool) -> MenuItemView {
164        MenuItemView {
165            label: label.into(),
166            suffix: None,
167            note: None,
168            enabled,
169            focused,
170        }
171    }
172
173    fn painted(view: &MenuView) -> (GlyphBuffer, Palette) {
174        let c = ctx();
175        let p = c.palette.clone();
176        let (w, h) = size(view);
177        let blank = Cell::new(
178            ' ',
179            p.engine(EngineColor::Text),
180            p.engine(EngineColor::Background),
181        );
182        let mut buf = GlyphBuffer::new(
183            u16::try_from(w + 2).unwrap(),
184            u16::try_from(h + 2).unwrap(),
185            blank,
186        );
187        paint(&c, view, &mut buf, 1, 1);
188        (buf, p)
189    }
190
191    fn row(buf: &GlyphBuffer, y: i32) -> String {
192        (0..i32::from(buf.width()))
193            .map(|x| buf.get(x, y).map_or(' ', |c| c.glyph))
194            .collect()
195    }
196
197    #[test]
198    fn the_box_is_the_widest_item_plus_a_space_and_border_each_side() {
199        assert_eq!(size(&MenuView::new(vec![])), (4, 2));
200        let view = MenuView::new(vec![item("a", true, true), item("bcd", false, false)]);
201        assert_eq!(size(&view), (7, 4));
202        // A suffix counts, with the space before it.
203        let mut with = item("ab", true, true);
204        with.suffix = Some(("(x)".to_owned(), SuffixKind::Warning));
205        assert_eq!(size(&MenuView::new(vec![with])), (10, 3));
206    }
207
208    #[test]
209    fn items_are_painted_one_to_a_row_inside_the_border() {
210        let view = MenuView::new(vec![item("Move", true, false), item("Wait", true, true)]);
211        let (buf, _) = painted(&view);
212        assert_eq!(row(&buf, 1).trim(), "┌──────┐");
213        assert_eq!(row(&buf, 2).trim(), "│ Move │");
214        assert_eq!(row(&buf, 3).trim(), "│ Wait │");
215        assert_eq!(row(&buf, 4).trim(), "└──────┘");
216    }
217
218    #[test]
219    fn the_focused_item_is_a_bar_and_a_disabled_one_is_dim() {
220        let view = MenuView::new(vec![
221            item("Move", true, false),
222            item("Attack", false, false),
223            item("Wait", true, true),
224        ]);
225        let (buf, p) = painted(&view);
226        let bar = p.engine(EngineColor::Focus);
227        let at = |x, y| *buf.get(x, y).unwrap();
228        // The bar fills the row inside the border, in the panel's colour.
229        assert_eq!(at(2, 4).bg, bar);
230        assert_eq!(at(2, 4).fg, p.engine(EngineColor::Panel));
231        assert_eq!(at(8, 4).bg, bar);
232        assert_ne!(at(2, 2).bg, bar);
233        assert_eq!(at(3, 2).fg, p.engine(EngineColor::Text));
234        assert_eq!(at(3, 3).fg, p.engine(EngineColor::TextDim));
235        assert_ne!(at(3, 3).bg, bar);
236    }
237
238    #[test]
239    fn the_focused_items_note_shows_under_a_rule_at_the_widest_notes_width() {
240        let mut brace = item("Brace", true, true);
241        brace.note = Some("self: Def +5".to_owned());
242        let mut push = item("Nudge", false, false);
243        push.note = Some("push 1 tile, 5 on impact".to_owned());
244        let view = MenuView::new(vec![brace, push]);
245        // As wide as the longest note, whichever item has the focus.
246        assert_eq!(size(&view), (28, 6));
247        let (buf, p) = painted(&view);
248        assert_eq!(row(&buf, 4).trim(), "├──────────────────────────┤");
249        assert_eq!(row(&buf, 5).trim(), "│ self: Def +5             │");
250        assert_eq!(row(&buf, 6).trim(), "└──────────────────────────┘");
251        assert_eq!(buf.get(3, 5).unwrap().fg, p.engine(EngineColor::Text));
252    }
253
254    #[test]
255    fn a_focused_disabled_item_is_a_muted_bar_with_its_note() {
256        let mut brace = item("Brace", true, false);
257        brace.note = Some("self".to_owned());
258        let mut push = item("Nudge", false, true);
259        push.note = Some("push".to_owned());
260        push.suffix = Some(("no uses left".to_owned(), SuffixKind::Warning));
261        let view = MenuView::new(vec![brace, push]);
262        let (buf, p) = painted(&view);
263        let at = |x, y| *buf.get(x, y).unwrap();
264        assert_eq!(at(2, 3).bg, p.engine(EngineColor::Border));
265        assert_eq!(at(3, 3).fg, p.engine(EngineColor::Panel));
266        // The reason takes the bar's colour too.
267        assert_eq!(at(9, 3).fg, p.engine(EngineColor::Panel));
268        assert_eq!(row(&buf, 5).trim(), "│ push               │");
269    }
270
271    #[test]
272    fn a_scrolled_menu_marks_the_border_where_more_items_are() {
273        let mut items: Vec<_> = (0..5)
274            .map(|i| item(&format!("{i}"), true, i == 2))
275            .collect();
276        items[2].note = Some("two".to_owned());
277        let mut view = MenuView::new(items);
278        (view.first_shown, view.shown) = (1, 3);
279        assert_eq!(size(&view), (7, 7));
280        let (buf, _) = painted(&view);
281        assert_eq!(row(&buf, 1).trim(), "┌────▲┐");
282        assert_eq!(row(&buf, 2).trim(), "│ 1   │");
283        assert_eq!(row(&buf, 4).trim(), "│ 3   │");
284        // With notes, the mark below is on the rule.
285        assert_eq!(row(&buf, 5).trim(), "├────▼┤");
286        assert_eq!(row(&buf, 6).trim(), "│ two │");
287        assert_eq!(row(&buf, 7).trim(), "└─────┘");
288    }
289
290    #[test]
291    fn a_menu_without_notes_has_no_rule() {
292        let view = MenuView::new(vec![item("Wait", true, true)]);
293        assert_eq!(size(&view), (8, 3));
294    }
295
296    #[test]
297    fn a_suffix_keeps_its_colour_except_on_the_focus_bar() {
298        let mut broken = item("Sword", false, false);
299        broken.suffix = Some(("(broken)".to_owned(), SuffixKind::Warning));
300        let mut focused = item("Axe", true, true);
301        focused.suffix = Some(("(worn)".to_owned(), SuffixKind::Warning));
302        let view = MenuView::new(vec![broken, focused]);
303        let (buf, p) = painted(&view);
304        let at = |x, y| *buf.get(x, y).unwrap();
305        // After the label and a space: column 3 + 5 on the first row.
306        assert_eq!(row(&buf, 2).trim(), "│ Sword (broken) │");
307        assert_eq!(at(9, 2).glyph, '(');
308        assert_eq!(at(9, 2).fg, p.engine(EngineColor::Warning));
309        assert_eq!(at(7, 3).glyph, '(');
310        assert_eq!(at(7, 3).fg, p.engine(EngineColor::Panel));
311    }
312}