1use 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
18fn labels<G: GameData>(ctx: &Ctx<G>, view: &MenuView) -> Vec<String> {
21 view.items.iter().map(|i| ctx.label(&i.label)).collect()
22}
23
24fn 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
33fn note_width(item: &MenuItemView, widths: &CellWidths) -> usize {
35 item.note.as_ref().map_or(0, |n| text_width(n, widths))
36}
37
38fn has_notes(view: &MenuView) -> bool {
40 view.items.iter().any(|i| i.note.is_some())
41}
42
43pub fn size<G: GameData>(ctx: &Ctx<G>, view: &MenuView) -> (i32, i32) {
48 size_with(view, &labels(ctx, view), ctx.widths())
49}
50
51fn 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
68pub const fn suffix_color(kind: SuffixKind) -> EngineColor {
71 match kind {
72 SuffixKind::Warning => EngineColor::Warning,
73 SuffixKind::Aside => EngineColor::TextDim,
74 }
75}
76
77pub 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 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 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 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 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 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 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 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 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}