Skip to main content

shuyi_ui/screens/profiles/
glyph.rs

1//! The glyph look of the profiles screen: a panel with the profiles, the
2//! one in use marked, then the row that adds one (in the style of the
3//! options screen); the menu of what can be done to a profile, the name
4//! boxes and the questions in double-bordered boxes over it. Everything
5//! about the look lives here; nothing here decides what the screen shows.
6//! Another look is another `paint` of the same view
7//! ([`ProfilesScreen::painted_by`](super::ProfilesScreen::painted_by)).
8
9use super::RowKind;
10use super::view::{ActionsView, ProfilesView, RowView};
11use crate::ctx::{Ctx, GameData};
12use crate::frame::color::EngineColor;
13use crate::frame::glyph_buffer::{BoxStyle, Cell, GlyphBuffer, Rect};
14use crate::prelude::*;
15use crate::screens::print_centred;
16use crate::widgets::ask;
17use crate::widgets::name_entry::glyph::{TYPING_HEIGHT, grid_height, paint_grid, paint_typing};
18
19/// The panel's top and width, in cells; it is centred on the buffer's
20/// width (and no wider than it) and as tall as its rows ([`panel`]).
21const PANEL_Y: i32 = 5;
22/// Width of the panel.
23const PANEL_W: i32 = 40;
24/// Column of the row labels, from the panel's left edge.
25const LABEL_DX: i32 = 4;
26/// Column of the mark of the profile in use, from the panel's left edge.
27const MARK_DX: i32 = 24;
28/// Width of the name boxes (no wider than the buffer).
29const NAME_BOX_W: i32 = 46;
30
31/// The panel for `rows` rows on `buf`: the border, a blank row above and
32/// below them, and one between the profiles and the row that adds one.
33fn panel(rows: usize, buf: &GlyphBuffer) -> Rect {
34    let rows = i32::try_from(rows).unwrap_or(0);
35    let width = i32::from(buf.width());
36    let w = PANEL_W.min(width);
37    Rect::new((width - w) / 2, PANEL_Y, w, rows + 5)
38}
39
40/// A rect `w` wide (no wider than `buf`) and `h` tall in the middle of
41/// `buf`.
42fn middle(buf: &GlyphBuffer, w: i32, h: i32) -> Rect {
43    let w = w.min(i32::from(buf.width()));
44    Rect::new(
45        (i32::from(buf.width()) - w) / 2,
46        ((i32::from(buf.height()) - h) / 2).max(0),
47        w,
48        h,
49    )
50}
51
52/// Paints `view` over the whole of `buf`.
53pub fn paint<G: GameData>(ctx: &Ctx<G>, view: &ProfilesView, buf: &mut GlyphBuffer) {
54    let c = |e| ctx.palette.engine(e);
55    let (black, bg) = (c(EngineColor::Background), c(EngineColor::Panel));
56    let (text, dim) = (c(EngineColor::Text), c(EngineColor::TextDim));
57    let panel = panel(view.rows.len(), buf);
58    buf.fill_rect(buf.bounds(), Cell::new(' ', text, black));
59    buf.fill_rect(panel, Cell::new(' ', text, bg));
60    buf.draw_box(panel, BoxStyle::Single, c(EngineColor::Border), bg);
61    let title = format!(" {} ", view.title);
62    buf.print(panel.x + 2, panel.y, &title, c(EngineColor::Highlight), bg);
63
64    let mut y = panel.y + 2;
65    for row in &view.rows {
66        if row.kind == RowKind::Add {
67            // A blank row between the profiles and the row that adds one.
68            y += 1;
69        }
70        paint_row(ctx, buf, row, &view.current_mark, panel.x, y);
71        y += 1;
72    }
73
74    if let Some(message) = &view.message {
75        // One blank row under the panel.
76        let y = panel.y + panel.h + 1;
77        print_centred(buf, y, message, c(EngineColor::Highlight), black);
78    }
79    if let Some(actions) = &view.actions {
80        paint_actions(ctx, buf, actions);
81    }
82    if let Some(grid) = &view.grid {
83        let rect = middle(buf, NAME_BOX_W, grid_height(grid));
84        paint_grid(&ctx.palette, buf, rect, grid);
85    }
86    if let Some(typing) = &view.typing {
87        let rect = middle(buf, NAME_BOX_W, TYPING_HEIGHT);
88        paint_typing(&ctx.palette, buf, rect, typing);
89    }
90    if let Some(question) = &view.leaving {
91        ask::glyph::paint(&ctx.palette, question, buf);
92    }
93    if let Some(question) = &view.deleting {
94        ask::glyph::paint(&ctx.palette, question, buf);
95    }
96    let bottom = i32::from(buf.height()) - 1;
97    print_centred(buf, bottom, &view.help, dim, black);
98}
99
100/// Paints `row` on console row `y` of the panel at column `x`: the focused
101/// row's label is a highlight bar, the profile in use has `mark` beside
102/// it, a row that does nothing is dim.
103fn paint_row<G: GameData>(
104    ctx: &Ctx<G>,
105    buf: &mut GlyphBuffer,
106    row: &RowView,
107    mark: &str,
108    x: i32,
109    y: i32,
110) {
111    let c = |e| ctx.palette.engine(e);
112    let bg = c(EngineColor::Panel);
113    let fg = if row.enabled {
114        c(EngineColor::Text)
115    } else {
116        c(EngineColor::TextDim)
117    };
118    let label_x = x + LABEL_DX;
119    if row.focused {
120        let bar = c(EngineColor::Focus);
121        buf.print(label_x - 1, y, &format!(" {} ", row.label), bg, bar);
122    } else {
123        buf.print(label_x, y, &row.label, fg, bg);
124    }
125    if row.current {
126        buf.print(x + MARK_DX, y, mark, c(EngineColor::Highlight), bg);
127    }
128}
129
130/// The menu of what can be done to a profile: a double-bordered box in
131/// the middle with the profile's name in its top border and the items
132/// under each other in the player's language, the focused one as a
133/// highlight bar.
134fn paint_actions<G: GameData>(ctx: &Ctx<G>, buf: &mut GlyphBuffer, actions: &ActionsView) {
135    let c = |e| ctx.palette.engine(e);
136    let (bg, text) = (c(EngineColor::Panel), c(EngineColor::Text));
137    let items = &actions.menu.items;
138    let labels: Vec<String> = items.iter().map(|i| ctx.label(&i.label)).collect();
139    let widest = labels.iter().map(|l| buf.text_cells(l)).max();
140    let w = widest.unwrap_or(0).max(buf.text_cells(&actions.title) + 2) + 6;
141    let h = i32::try_from(items.len()).unwrap_or(0) + 4;
142    let rect = middle(buf, w, h);
143    buf.fill_rect(rect, Cell::new(' ', text, bg));
144    buf.draw_box(rect, BoxStyle::Double, c(EngineColor::Focus), bg);
145    let title = format!(" {} ", actions.title);
146    buf.print(rect.x + 2, rect.y, &title, c(EngineColor::Highlight), bg);
147    for ((item, label), y) in items.iter().zip(&labels).zip(rect.y + 2..) {
148        if item.focused {
149            let bar = c(EngineColor::Focus);
150            buf.print(rect.x + 2, y, &format!(" {label} "), bg, bar);
151        } else {
152            buf.print(rect.x + 3, y, label, text, bg);
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::frame::console::ConsoleSpec;
161    use crate::profiles::ProfileId;
162    use crate::screens::profiles::DeleteChoice;
163    use crate::toy::{ToyCtx, ctx};
164    use crate::widgets::{AnswerView, AskView, Menu, MenuItem};
165
166    const PC: ConsoleSpec = ConsoleSpec::text(100, 32);
167    /// The panel's left edge on [`PC`].
168    const PANEL_X: i32 = 30;
169
170    /// `view` as painted on [`PC`].
171    fn painted(c: &ToyCtx, view: &ProfilesView) -> GlyphBuffer {
172        let black = c.palette.engine(EngineColor::Background);
173        let mut buf = GlyphBuffer::with_spec(PC, Cell::new(' ', black, black));
174        paint(c, view, &mut buf);
175        buf
176    }
177
178    /// Row `y` of `buf`, trimmed.
179    fn row(buf: &GlyphBuffer, y: i32) -> String {
180        let glyphs = (0..i32::from(buf.width())).map(|x| buf.get(x, y).map_or(' ', |c| c.glyph));
181        glyphs.collect::<String>().trim().to_owned()
182    }
183
184    fn profile(label: &str, current: bool, focused: bool) -> RowView {
185        RowView {
186            kind: RowKind::Profile(ProfileId::FIRST),
187            label: label.to_owned(),
188            current,
189            focused,
190            enabled: true,
191        }
192    }
193
194    fn view(rows: Vec<RowView>) -> ProfilesView {
195        ProfilesView {
196            title: "Profile".to_owned(),
197            rows,
198            current_mark: "in use".to_owned(),
199            actions: None,
200            typing: None,
201            grid: None,
202            leaving: None,
203            deleting: None,
204            message: None,
205            help: "help".to_owned(),
206        }
207    }
208
209    #[test]
210    fn the_profiles_then_a_blank_row_then_the_add_row_the_current_one_marked() {
211        let c = ctx();
212        let add = RowView {
213            kind: RowKind::Add,
214            label: "Add a profile".to_owned(),
215            current: false,
216            focused: false,
217            enabled: false,
218        };
219        let v = view(vec![
220            profile("Player 1", true, false),
221            profile("Mei", false, true),
222            add,
223        ]);
224        let buf = painted(&c, &v);
225        assert!(row(&buf, PANEL_Y).starts_with("┌─ Profile ─"));
226        let first = row(&buf, PANEL_Y + 2);
227        assert_eq!(first, format!("│   {:<20}in use{:>10}", "Player 1", "│"));
228        assert!(row(&buf, PANEL_Y + 3).starts_with("│   Mei "));
229        assert_eq!(row(&buf, PANEL_Y + 4), row(&buf, PANEL_Y + 1));
230        assert!(row(&buf, PANEL_Y + 5).starts_with("│   Add a profile"));
231        // The add row that does nothing is dim; the focused row is lit.
232        let label_x = PANEL_X + LABEL_DX;
233        let dim = c.palette.engine(EngineColor::TextDim);
234        assert_eq!(buf.get(label_x, PANEL_Y + 5).map(|c| c.fg), Some(dim));
235        let bar = c.palette.engine(EngineColor::Focus);
236        assert_eq!(buf.get(label_x, PANEL_Y + 3).map(|c| c.bg), Some(bar));
237        assert_eq!(row(&buf, PANEL_Y + 6), row(&buf, PANEL_Y + 1));
238        assert!(row(&buf, PANEL_Y + 7).starts_with('└'));
239        assert_eq!(row(&buf, 31), "help");
240    }
241
242    #[test]
243    fn the_message_is_painted_one_blank_row_under_the_panel() {
244        let c = ctx();
245        let mut v = view(vec![profile("Player 1", true, true)]);
246        v.message = Some("There can be at most 8 profiles.".to_owned());
247        let buf = painted(&c, &v);
248        let bottom = PANEL_Y + 5;
249        assert!(row(&buf, bottom).starts_with('└'));
250        assert_eq!(row(&buf, bottom + 1), "");
251        assert_eq!(row(&buf, bottom + 2), "There can be at most 8 profiles.");
252    }
253
254    #[test]
255    fn the_delete_question_is_the_question_widgets_box() {
256        let c = ctx();
257        let mut v = view(vec![profile("Player 1", true, true)]);
258        let answer = |answer, label: &str, focused| AnswerView {
259            answer,
260            label: label.to_owned(),
261            focused,
262        };
263        v.deleting = Some(AskView {
264            text: "Delete Mei?".to_owned(),
265            answers: vec![
266                answer(DeleteChoice::Yes, "Yes", false),
267                answer(DeleteChoice::No, "No", true),
268            ],
269            keys: None,
270        });
271        let buf = painted(&c, &v);
272        let top = (i32::from(PC.rows) - ask::glyph::HEIGHT) / 2;
273        assert!(row(&buf, top + 2).contains("║ Delete Mei?  ║"));
274        assert!(row(&buf, top + 3).contains("║  Yes     No  ║"));
275    }
276
277    #[test]
278    fn the_actions_are_a_box_titled_with_the_profile_the_focused_item_lit() {
279        let c = ctx();
280        let mut v = view(vec![profile("Player 1", true, true)]);
281        let menu = Menu::new(vec![
282            MenuItem::keyed("profile.action.rename"),
283            MenuItem::keyed("profile.action.delete"),
284        ]);
285        v.actions = Some(ActionsView {
286            title: "Mei".to_owned(),
287            menu: menu.view(),
288        });
289        let buf = painted(&c, &v);
290        let top = (i32::from(PC.rows) - 6) / 2;
291        assert!(row(&buf, top).contains("╔═ Mei ═"), "{}", row(&buf, top));
292        assert!(row(&buf, top + 2).contains("║  Rename  ║"));
293        assert!(row(&buf, top + 3).contains("║  Delete  ║"));
294        let bar = c.palette.engine(EngineColor::Focus);
295        let lit: String = (0..i32::from(PC.cols))
296            .filter_map(|x| buf.get(x, top + 2))
297            .filter(|cell| cell.bg == bar)
298            .map(|cell| cell.glyph)
299            .collect();
300        assert_eq!(lit, " Rename ");
301        // A name wider than the items widens the box.
302        if let Some(actions) = &mut v.actions {
303            actions.title = "Abcdefghijkl".to_owned();
304        }
305        let buf = painted(&c, &v);
306        assert!(
307            row(&buf, top).contains("╔═ Abcdefghijkl ═══╗"),
308            "{}",
309            row(&buf, top)
310        );
311    }
312}