1use 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
16const PANEL: Rect = Rect::new(20, 3, 60, 20);
21const LABEL_DX: i32 = 4;
23const VALUE_DX: i32 = 30;
25const TOP_MIN: i32 = 1;
28const BELOW_PANEL: i32 = 3;
31const PANEL_FRAME_H: i32 = 3;
34const VOLUME_BAR: u8 = 10;
36const BOX_W: usize = 4;
38
39pub 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 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
52fn 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
68pub 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 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
104fn 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 const PC: ConsoleSpec = ConsoleSpec::text(100, 32);
154 const SMALL: ConsoleSpec = ConsoleSpec::text(48, 24);
156
157 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 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 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 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 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 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 #[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 #[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 #[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 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 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 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 #[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 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}