1use crate::render::{Painter, Rgba, TilePos, TileRect, Ui};
20
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
30pub struct MenuInput {
31 pub up: bool,
33 pub down: bool,
35 pub confirm: bool,
37 pub cancel: bool,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum MenuAction {
51 None,
53 Selected(u8),
55 Cancelled,
57 Up,
59 Down,
61 Scroll(u8),
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct MenuOption {
72 pub label: String,
74 pub enabled: bool,
76 pub description: Option<String>,
78}
79
80impl MenuOption {
81 pub fn new(label: impl Into<String>) -> Self {
83 Self { label: label.into(), enabled: true, description: None }
84 }
85
86 pub fn disabled(label: impl Into<String>) -> Self {
88 Self { label: label.into(), enabled: false, description: None }
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct MenuLayout {
103 pub position: TilePos,
106 pub size: TileRect,
108 pub option_spacing: u32,
111 pub show_cursor: bool,
113}
114
115impl MenuLayout {
116 pub fn new(tx: u32, ty: u32, tw: u32, th: u32) -> Self {
119 Self {
120 position: TilePos::new(tx, ty),
121 size: TileRect::new(tx, ty, tw, th),
122 option_spacing: 2,
123 show_cursor: true,
124 }
125 }
126
127 pub fn with_spacing(mut self, spacing: u32) -> Self {
129 self.option_spacing = spacing;
130 self
131 }
132
133 pub fn with_cursor(mut self, show: bool) -> Self {
135 self.show_cursor = show;
136 self
137 }
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147#[non_exhaustive]
148pub enum TileSlot {
149 TopLeftCorner,
150 TopRightCorner,
151 BottomLeftCorner,
152 BottomRightCorner,
153 TopEdge,
154 BottomEdge,
155 LeftEdge,
156 RightEdge,
157 Fill,
158}
159
160#[derive(Debug, Clone, PartialEq)]
168#[non_exhaustive]
169pub struct BorderStyle {
170 pub corner_tl: u16,
171 pub corner_tr: u16,
172 pub corner_bl: u16,
173 pub corner_br: u16,
174 pub edge_top: u16,
175 pub edge_bottom: u16,
176 pub edge_left: u16,
177 pub edge_right: u16,
178 pub fill_bg: u16,
179}
180
181impl Default for BorderStyle {
182 fn default() -> Self {
183 Self {
184 corner_tl: 192, corner_tr: 193, corner_bl: 198, corner_br: 199,
186 edge_top: 194, edge_bottom: 197,
187 edge_left: 195, edge_right: 196,
188 fill_bg: 200,
189 }
190 }
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
199#[non_exhaustive]
200pub enum CursorAnchor {
201 #[default]
202 CenterLeft,
203 TopLeft,
204 BottomLeft,
205 TopRight,
206 CenterRight,
207}
208
209#[derive(Debug, Clone, PartialEq)]
215#[non_exhaustive]
216pub struct CursorStyle {
217 pub tile: Option<u16>,
219 pub anchor: CursorAnchor,
221}
222
223impl Default for CursorStyle {
224 fn default() -> Self {
225 Self { tile: Some(223), anchor: CursorAnchor::CenterLeft }
226 }
227}
228
229impl CursorStyle {
230 pub fn new(tile: Option<u16>, anchor: CursorAnchor) -> Self {
232 Self { tile, anchor }
233 }
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub struct EdgeInsets {
243 pub top: u32,
245 pub bottom: u32,
247 pub left: u32,
249 pub right: u32,
251}
252
253impl Default for EdgeInsets {
254 fn default() -> Self {
255 Self { top: 1, bottom: 1, left: 1, right: 1 }
256 }
257}
258
259#[derive(Debug, Clone, PartialEq)]
270#[non_exhaustive]
271pub struct MenuConfig {
272 pub area: TileRect,
274 pub border: Option<BorderStyle>,
276 pub content: TileRect,
278 pub cursor: CursorStyle,
280 pub label_positions: Vec<(u32, u32, String)>,
283 pub gap: u32,
285 pub padding: EdgeInsets,
287}
288
289impl MenuConfig {
290 pub fn new(area: TileRect, border: Option<BorderStyle>, content: TileRect, cursor: CursorStyle) -> Self {
291 Self {
292 area, border, content, cursor,
293 label_positions: Vec::new(),
294 gap: 1,
295 padding: EdgeInsets::default(),
296 }
297 }
298}
299
300pub trait MenuProvider {
316 type MenuId: Copy + std::fmt::Debug + PartialEq;
318
319 fn title(&self, menu: Self::MenuId) -> &str;
321
322 fn options(&self, menu: Self::MenuId) -> &[MenuOption];
325
326 fn option_count(&self, menu: Self::MenuId) -> u8;
329
330 fn scrollable(&self, menu: Self::MenuId) -> bool;
333
334 fn layout(&self, menu: Self::MenuId) -> MenuLayout;
336}
337
338pub struct MenuSystem<'prov, M: MenuProvider> {
349 provider: &'prov M,
351 pub current_menu: M::MenuId,
353 pub cursor: u8,
355 pub scroll_offset: u8,
358 is_open: bool,
360}
361
362impl<'prov, M: MenuProvider> MenuSystem<'prov, M> {
363 pub fn new(provider: &'prov M) -> Self {
366 Self {
367 provider,
368 current_menu: unsafe { std::mem::zeroed() },
370 cursor: 0,
371 scroll_offset: 0,
372 is_open: false,
373 }
374 }
375
376 pub fn open(&mut self, menu: M::MenuId) {
378 self.current_menu = menu;
379 self.cursor = 0;
380 self.scroll_offset = 0;
381 self.is_open = true;
382 }
383
384 pub fn close(&mut self) {
386 self.is_open = false;
387 }
388
389 pub fn is_open(&self) -> bool {
391 self.is_open
392 }
393
394 pub fn handle_input(&mut self, input: &MenuInput) -> MenuAction {
399 if !self.is_open {
400 return MenuAction::None;
401 }
402
403 let total = self.provider.option_count(self.current_menu);
404 if total == 0 {
405 if input.cancel {
407 self.is_open = false;
408 return MenuAction::Cancelled;
409 }
410 return MenuAction::None;
411 }
412
413 if input.up && self.cursor > 0 {
415 let mut next = self.cursor;
417 loop {
418 if next == 0 {
419 break;
420 }
421 next -= 1;
422 let opts = self.provider.options(self.current_menu);
423 if opts[next as usize].enabled {
424 self.cursor = next;
425 return MenuAction::Up;
426 }
427 }
428 }
429
430 if input.down {
431 let max = (total as usize).saturating_sub(1);
432 let mut next = self.cursor;
433 loop {
434 if (next as usize) >= max {
435 break;
436 }
437 next += 1;
438 let opts = self.provider.options(self.current_menu);
439 if opts[next as usize].enabled {
440 self.cursor = next;
441 return MenuAction::Down;
442 }
443 }
444 }
445
446 if input.confirm {
448 let opts = self.provider.options(self.current_menu);
449 if let Some(opt) = opts.get(self.cursor as usize) {
450 if opt.enabled {
451 return MenuAction::Selected(self.cursor);
452 }
453 }
454 }
455
456 if input.cancel {
458 self.is_open = false;
459 return MenuAction::Cancelled;
460 }
461
462 MenuAction::None
463 }
464
465 pub fn selected_option(&self) -> Option<&MenuOption> {
468 if !self.is_open {
469 return None;
470 }
471 self.provider
472 .options(self.current_menu)
473 .get(self.cursor as usize)
474 }
475
476 pub fn render<P: Painter>(&self, painter: &mut P) {
482 if !self.is_open {
483 return;
484 }
485
486 let layout = self.provider.layout(self.current_menu);
487 let title = self.provider.title(self.current_menu);
488 let options = self.provider.options(self.current_menu);
489 let visible_count = self.provider.option_count(self.current_menu);
490
491 let mut ui = Ui::new(painter);
492
493 ui.text_box(layout.size, Rgba::INK_BLACK, true, |f| {
494 f.label(0, 0, title, Rgba::INK_BLACK);
496
497 let start = self.scroll_offset as usize;
499 let end = (start + visible_count as usize).min(options.len());
500
501 for i in start..end {
502 let row_inner = 1 + ((i - start) as u32) * layout.option_spacing;
503 let opt = &options[i];
504 let color = if opt.enabled {
505 Rgba::INK_BLACK
506 } else {
507 Rgba::INK_LIGHT_GRAY
508 };
509 f.label(1, row_inner, &opt.label, color);
510
511 if layout.show_cursor && i == self.cursor as usize && opt.enabled {
513 f.cursor_at(0, row_inner, Rgba::INK_BLACK);
514 }
515 }
516 });
517 }
518}
519
520pub trait NamedMenuInputSource {
530 fn read_input(&self) -> MenuInput;
532}
533
534#[cfg(test)]
539mod tests {
540 use super::*;
541 use crate::render::{Painter, Rgba, TilePos, TileRect};
542
543 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
546 enum MockMenuId {
547 MainMenu,
548 }
549
550 struct MockMenuProvider {
551 title: &'static str,
552 options: Vec<MenuOption>,
553 layout: MenuLayout,
554 }
555
556 impl MenuProvider for MockMenuProvider {
557 type MenuId = MockMenuId;
558
559 fn title(&self, _menu: Self::MenuId) -> &str {
560 self.title
561 }
562
563 fn options(&self, _menu: Self::MenuId) -> &[MenuOption] {
564 &self.options
565 }
566
567 fn option_count(&self, _menu: Self::MenuId) -> u8 {
568 self.options.len() as u8
569 }
570
571 fn scrollable(&self, _menu: Self::MenuId) -> bool {
572 false
573 }
574
575 fn layout(&self, _menu: Self::MenuId) -> MenuLayout {
576 self.layout
577 }
578 }
579
580 #[derive(Debug, Default)]
584 struct RecordingPainter {
585 text_boxes: Vec<(TileRect, Rgba)>,
586 texts: Vec<(TilePos, String, Rgba)>,
587 glyphs: Vec<(TilePos, char, Rgba)>,
588 }
589
590 impl Painter for RecordingPainter {
591 fn clear(&mut self, _color: Rgba) {}
592 fn draw_text_box(&mut self, rect: TileRect, color: Rgba) {
593 self.text_boxes.push((rect, color));
594 }
595 fn draw_text(&mut self, pos: TilePos, text: &str, color: Rgba) {
596 self.texts.push((pos, text.to_string(), color));
597 }
598 fn draw_glyph(&mut self, pos: TilePos, glyph: char, color: Rgba) {
599 self.glyphs.push((pos, glyph, color));
600 }
601 fn draw_pixel_rect(&mut self, _px: u32, _py: u32, _pw: u32, _ph: u32, _color: Rgba) {}
602 fn draw_gb_tile(&mut self, _pos: TilePos, _tile_id: u8, _fallback: &str, _color: Rgba) {}
603 }
604
605 fn make_provider() -> MockMenuProvider {
606 MockMenuProvider {
607 title: "POKERED",
608 options: vec![
609 MenuOption::new("New Game"),
610 MenuOption::new("Continue"),
611 MenuOption::new("Quit"),
612 ],
613 layout: MenuLayout::new(5, 3, 10, 9),
614 }
615 }
616
617 #[test]
620 fn open_menu_sets_cursor_to_zero() {
621 let provider = make_provider();
622 let mut system = MenuSystem::new(&provider);
623 system.open(MockMenuId::MainMenu);
624 assert!(system.is_open());
625 assert_eq!(system.cursor, 0);
626 assert_eq!(system.scroll_offset, 0);
627 }
628
629 #[test]
630 fn cursor_moves_down_on_down_input() {
631 let provider = make_provider();
632 let mut system = MenuSystem::new(&provider);
633 system.open(MockMenuId::MainMenu);
634
635 let action = system.handle_input(&MenuInput { down: true, ..Default::default() });
636 assert_eq!(action, MenuAction::Down);
637 assert_eq!(system.cursor, 1);
638 }
639
640 #[test]
641 fn cursor_stops_at_last_option() {
642 let provider = make_provider();
643 let mut system = MenuSystem::new(&provider);
644 system.open(MockMenuId::MainMenu);
645
646 system.handle_input(&MenuInput { down: true, ..Default::default() });
648 system.handle_input(&MenuInput { down: true, ..Default::default() });
649 assert_eq!(system.cursor, 2);
650
651 let action = system.handle_input(&MenuInput { down: true, ..Default::default() });
653 assert_eq!(action, MenuAction::None);
654 assert_eq!(system.cursor, 2);
655 }
656
657 #[test]
658 fn cursor_moves_up_on_up_input() {
659 let provider = make_provider();
660 let mut system = MenuSystem::new(&provider);
661 system.open(MockMenuId::MainMenu);
662
663 system.handle_input(&MenuInput { down: true, ..Default::default() });
665 assert_eq!(system.cursor, 1);
666
667 let action = system.handle_input(&MenuInput { up: true, ..Default::default() });
668 assert_eq!(action, MenuAction::Up);
669 assert_eq!(system.cursor, 0);
670 }
671
672 #[test]
673 fn confirm_on_enabled_option_returns_selected() {
674 let provider = make_provider();
675 let mut system = MenuSystem::new(&provider);
676 system.open(MockMenuId::MainMenu);
677
678 system.handle_input(&MenuInput { down: true, ..Default::default() });
680
681 let action = system.handle_input(&MenuInput { confirm: true, ..Default::default() });
682 assert_eq!(action, MenuAction::Selected(1));
683 }
684
685 #[test]
686 fn cancel_returns_cancelled_and_closes_menu() {
687 let provider = make_provider();
688 let mut system = MenuSystem::new(&provider);
689 system.open(MockMenuId::MainMenu);
690
691 let action = system.handle_input(&MenuInput { cancel: true, ..Default::default() });
692 assert_eq!(action, MenuAction::Cancelled);
693 assert!(!system.is_open());
694 }
695
696 #[test]
697 fn disabled_option_is_skipped_by_cursor() {
698 let provider = MockMenuProvider {
699 title: "TEST",
700 options: vec![
701 MenuOption::new("A"),
702 MenuOption::disabled("B"),
703 MenuOption::new("C"),
704 ],
705 layout: MenuLayout::new(0, 0, 6, 5),
706 };
707 let mut system = MenuSystem::new(&provider);
708 system.open(MockMenuId::MainMenu);
709
710 let action = system.handle_input(&MenuInput { down: true, ..Default::default() });
712 assert_eq!(action, MenuAction::Down);
713 assert_eq!(system.cursor, 2);
714 }
715
716 #[test]
717 fn confirm_on_disabled_option_does_nothing() {
718 let provider = MockMenuProvider {
719 title: "TEST",
720 options: vec![
721 MenuOption::disabled("X"),
722 MenuOption::new("Y"),
723 ],
724 layout: MenuLayout::new(0, 0, 6, 5),
725 };
726 let mut system = MenuSystem::new(&provider);
727 system.open(MockMenuId::MainMenu);
728
729 let action = system.handle_input(&MenuInput { confirm: true, ..Default::default() });
731 assert_eq!(action, MenuAction::None);
732 }
733
734 #[test]
735 fn selected_option_returns_correct_option() {
736 let provider = make_provider();
737 let mut system = MenuSystem::new(&provider);
738 system.open(MockMenuId::MainMenu);
739
740 system.handle_input(&MenuInput { down: true, ..Default::default() });
742
743 let opt = system.selected_option();
744 assert!(opt.is_some());
745 assert_eq!(opt.unwrap().label, "Continue");
746 }
747
748 #[test]
749 fn closed_menu_ignores_input() {
750 let provider = make_provider();
751 let mut system = MenuSystem::new(&provider);
752 let action = system.handle_input(&MenuInput {
755 down: true,
756 confirm: true,
757 ..Default::default()
758 });
759 assert_eq!(action, MenuAction::None);
760 }
761
762 #[test]
763 fn render_draws_text_box_at_layout_position() {
764 let provider = make_provider();
765 let mut system = MenuSystem::new(&provider);
766 system.open(MockMenuId::MainMenu);
767
768 let mut painter = RecordingPainter::default();
769 system.render(&mut painter);
770
771 assert_eq!(painter.text_boxes.len(), 1);
773 let (rect, _) = painter.text_boxes[0];
774 assert_eq!(rect.tx, 5);
775 assert_eq!(rect.ty, 3);
776 assert_eq!(rect.tw, 10);
777 assert_eq!(rect.th, 9);
778 }
779
780 #[test]
781 fn render_draws_title_and_options() {
782 let provider = make_provider();
783 let mut system = MenuSystem::new(&provider);
784 system.open(MockMenuId::MainMenu);
785
786 let mut painter = RecordingPainter::default();
787 system.render(&mut painter);
788
789 let titles: Vec<_> = painter
791 .texts
792 .iter()
793 .filter(|(_, t, _)| t == "POKERED")
794 .collect();
795 assert!(!titles.is_empty(), "Title 'POKERED' was not drawn");
796
797 for expected in &["New Game", "Continue", "Quit"] {
799 let found = painter.texts.iter().any(|(_, t, _)| t == expected);
800 assert!(found, "Option '{}' was not drawn", expected);
801 }
802 }
803
804 #[test]
805 fn render_draws_cursor_at_correct_position() {
806 let provider = make_provider();
807 let mut system = MenuSystem::new(&provider);
808 system.open(MockMenuId::MainMenu);
809
810 system.handle_input(&MenuInput { down: true, ..Default::default() });
812
813 let mut painter = RecordingPainter::default();
814 system.render(&mut painter);
815
816 assert_eq!(painter.glyphs.len(), 1, "Expected exactly 1 cursor glyph");
818 let (glyph_pos, glyph_char, _) = painter.glyphs[0];
819 assert_eq!(glyph_char, '\u{25B6}', "Cursor glyph should be U+25B6");
820
821 assert_eq!(glyph_pos.tx, 6);
827 assert_eq!(glyph_pos.ty, 7);
828 }
829
830 #[test]
831 fn closed_menu_renders_nothing() {
832 let provider = make_provider();
833 let system = MenuSystem::new(&provider);
834 let mut painter = RecordingPainter::default();
837 system.render(&mut painter);
838
839 assert!(painter.text_boxes.is_empty());
840 assert!(painter.texts.is_empty());
841 assert!(painter.glyphs.is_empty());
842 }
843
844 #[test]
845 fn empty_menu_returns_none_on_confirm() {
846 let provider = MockMenuProvider {
847 title: "EMPTY",
848 options: vec![],
849 layout: MenuLayout::new(0, 0, 6, 3),
850 };
851 let mut system = MenuSystem::new(&provider);
852 system.open(MockMenuId::MainMenu);
853
854 let action = system.handle_input(&MenuInput { confirm: true, ..Default::default() });
855 assert_eq!(action, MenuAction::None);
856 }
857
858 #[test]
859 fn menu_option_new_and_disabled() {
860 let opt = MenuOption::new("Hello");
861 assert!(opt.enabled);
862 assert_eq!(opt.label, "Hello");
863 assert!(opt.description.is_none());
864
865 let opt = MenuOption::disabled("Locked");
866 assert!(!opt.enabled);
867 assert_eq!(opt.label, "Locked");
868 }
869
870 #[test]
871 fn menu_layout_builders() {
872 let layout = MenuLayout::new(1, 2, 8, 6)
873 .with_spacing(3)
874 .with_cursor(false);
875
876 assert_eq!(layout.position.tx, 1);
877 assert_eq!(layout.position.ty, 2);
878 assert_eq!(layout.size.tx, 1);
879 assert_eq!(layout.size.ty, 2);
880 assert_eq!(layout.size.tw, 8);
881 assert_eq!(layout.size.th, 6);
882 assert_eq!(layout.option_spacing, 3);
883 assert!(!layout.show_cursor);
884 }
885
886 #[test]
887 fn menu_input_default_is_all_false() {
888 let input = MenuInput::default();
889 assert!(!input.up);
890 assert!(!input.down);
891 assert!(!input.confirm);
892 assert!(!input.cancel);
893 }
894
895 #[test]
896 fn menu_system_close_and_reopen() {
897 let provider = make_provider();
898 let mut system = MenuSystem::new(&provider);
899 system.open(MockMenuId::MainMenu);
900 assert!(system.is_open());
901
902 system.close();
903 assert!(!system.is_open());
904
905 system.open(MockMenuId::MainMenu);
906 assert!(system.is_open());
907 assert_eq!(system.cursor, 0); }
909}