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 {
84 label: label.into(),
85 enabled: true,
86 description: None,
87 }
88 }
89
90 pub fn disabled(label: impl Into<String>) -> Self {
92 Self {
93 label: label.into(),
94 enabled: false,
95 description: None,
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct MenuLayout {
111 pub position: TilePos,
114 pub size: TileRect,
116 pub option_spacing: u32,
119 pub show_cursor: bool,
121}
122
123impl MenuLayout {
124 pub fn new(tx: u32, ty: u32, tw: u32, th: u32) -> Self {
127 Self {
128 position: TilePos::new(tx, ty),
129 size: TileRect::new(tx, ty, tw, th),
130 option_spacing: 2,
131 show_cursor: true,
132 }
133 }
134
135 pub fn with_spacing(mut self, spacing: u32) -> Self {
137 self.option_spacing = spacing;
138 self
139 }
140
141 pub fn with_cursor(mut self, show: bool) -> Self {
143 self.show_cursor = show;
144 self
145 }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155#[non_exhaustive]
156pub enum TileSlot {
157 TopLeftCorner,
158 TopRightCorner,
159 BottomLeftCorner,
160 BottomRightCorner,
161 TopEdge,
162 BottomEdge,
163 LeftEdge,
164 RightEdge,
165 Fill,
166}
167
168#[derive(Debug, Clone, PartialEq)]
176#[non_exhaustive]
177pub struct BorderStyle {
178 pub corner_tl: u16,
179 pub corner_tr: u16,
180 pub corner_bl: u16,
181 pub corner_br: u16,
182 pub edge_top: u16,
183 pub edge_bottom: u16,
184 pub edge_left: u16,
185 pub edge_right: u16,
186 pub fill_bg: u16,
187}
188
189impl Default for BorderStyle {
190 fn default() -> Self {
191 Self {
192 corner_tl: 192,
193 corner_tr: 193, corner_bl: 198,
195 corner_br: 199,
196 edge_top: 194,
197 edge_bottom: 197,
198 edge_left: 195,
199 edge_right: 196,
200 fill_bg: 200,
201 }
202 }
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
211#[non_exhaustive]
212pub enum CursorAnchor {
213 #[default]
214 CenterLeft,
215 TopLeft,
216 BottomLeft,
217 TopRight,
218 CenterRight,
219}
220
221#[derive(Debug, Clone, PartialEq)]
227#[non_exhaustive]
228pub struct CursorStyle {
229 pub tile: Option<u16>,
231 pub anchor: CursorAnchor,
233}
234
235impl Default for CursorStyle {
236 fn default() -> Self {
237 Self {
238 tile: Some(223),
239 anchor: CursorAnchor::CenterLeft,
240 }
241 }
242}
243
244impl CursorStyle {
245 pub fn new(tile: Option<u16>, anchor: CursorAnchor) -> Self {
247 Self { tile, anchor }
248 }
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub struct EdgeInsets {
258 pub top: u32,
260 pub bottom: u32,
262 pub left: u32,
264 pub right: u32,
266}
267
268impl Default for EdgeInsets {
269 fn default() -> Self {
270 Self {
271 top: 1,
272 bottom: 1,
273 left: 1,
274 right: 1,
275 }
276 }
277}
278
279#[derive(Debug, Clone, PartialEq)]
290#[non_exhaustive]
291pub struct MenuConfig {
292 pub area: TileRect,
294 pub border: Option<BorderStyle>,
296 pub content: TileRect,
298 pub cursor: CursorStyle,
300 pub label_positions: Vec<(u32, u32, String)>,
303 pub gap: u32,
305 pub padding: EdgeInsets,
307}
308
309impl MenuConfig {
310 pub fn new(
311 area: TileRect,
312 border: Option<BorderStyle>,
313 content: TileRect,
314 cursor: CursorStyle,
315 ) -> Self {
316 Self {
317 area,
318 border,
319 content,
320 cursor,
321 label_positions: Vec::new(),
322 gap: 1,
323 padding: EdgeInsets::default(),
324 }
325 }
326}
327
328pub trait MenuProvider {
344 type MenuId: Copy + std::fmt::Debug + PartialEq;
346
347 fn title(&self, menu: Self::MenuId) -> &str;
349
350 fn options(&self, menu: Self::MenuId) -> &[MenuOption];
353
354 fn option_count(&self, menu: Self::MenuId) -> u8;
357
358 fn scrollable(&self, menu: Self::MenuId) -> bool;
361
362 fn layout(&self, menu: Self::MenuId) -> MenuLayout;
364}
365
366pub struct MenuSystem<'prov, M: MenuProvider> {
377 provider: &'prov M,
379 pub current_menu: M::MenuId,
381 pub cursor: u8,
383 pub scroll_offset: u8,
386 is_open: bool,
388}
389
390impl<'prov, M: MenuProvider> MenuSystem<'prov, M> {
391 pub fn new(provider: &'prov M) -> Self {
394 Self {
395 provider,
396 current_menu: unsafe { std::mem::zeroed() },
398 cursor: 0,
399 scroll_offset: 0,
400 is_open: false,
401 }
402 }
403
404 pub fn open(&mut self, menu: M::MenuId) {
406 self.current_menu = menu;
407 self.cursor = 0;
408 self.scroll_offset = 0;
409 self.is_open = true;
410 }
411
412 pub fn close(&mut self) {
414 self.is_open = false;
415 }
416
417 pub fn is_open(&self) -> bool {
419 self.is_open
420 }
421
422 pub fn handle_input(&mut self, input: &MenuInput) -> MenuAction {
427 if !self.is_open {
428 return MenuAction::None;
429 }
430
431 let total = self.provider.option_count(self.current_menu);
432 if total == 0 {
433 if input.cancel {
435 self.is_open = false;
436 return MenuAction::Cancelled;
437 }
438 return MenuAction::None;
439 }
440
441 if input.up && self.cursor > 0 {
443 let mut next = self.cursor;
445 loop {
446 if next == 0 {
447 break;
448 }
449 next -= 1;
450 let opts = self.provider.options(self.current_menu);
451 if opts[next as usize].enabled {
452 self.cursor = next;
453 return MenuAction::Up;
454 }
455 }
456 }
457
458 if input.down {
459 let max = (total as usize).saturating_sub(1);
460 let mut next = self.cursor;
461 loop {
462 if (next as usize) >= max {
463 break;
464 }
465 next += 1;
466 let opts = self.provider.options(self.current_menu);
467 if opts[next as usize].enabled {
468 self.cursor = next;
469 return MenuAction::Down;
470 }
471 }
472 }
473
474 if input.confirm {
476 let opts = self.provider.options(self.current_menu);
477 if let Some(opt) = opts.get(self.cursor as usize) {
478 if opt.enabled {
479 return MenuAction::Selected(self.cursor);
480 }
481 }
482 }
483
484 if input.cancel {
486 self.is_open = false;
487 return MenuAction::Cancelled;
488 }
489
490 MenuAction::None
491 }
492
493 pub fn selected_option(&self) -> Option<&MenuOption> {
496 if !self.is_open {
497 return None;
498 }
499 self.provider
500 .options(self.current_menu)
501 .get(self.cursor as usize)
502 }
503
504 pub fn render<P: Painter>(&self, painter: &mut P) {
510 if !self.is_open {
511 return;
512 }
513
514 let layout = self.provider.layout(self.current_menu);
515 let title = self.provider.title(self.current_menu);
516 let options = self.provider.options(self.current_menu);
517 let visible_count = self.provider.option_count(self.current_menu);
518
519 let mut ui = Ui::new(painter);
520
521 ui.text_box(layout.size, Rgba::INK_BLACK, true, |f| {
522 f.label(0, 0, title, Rgba::INK_BLACK);
524
525 let start = self.scroll_offset as usize;
527 let end = (start + visible_count as usize).min(options.len());
528
529 for i in start..end {
530 let row_inner = 1 + ((i - start) as u32) * layout.option_spacing;
531 let opt = &options[i];
532 let color = if opt.enabled {
533 Rgba::INK_BLACK
534 } else {
535 Rgba::INK_LIGHT_GRAY
536 };
537 f.label(1, row_inner, &opt.label, color);
538
539 if layout.show_cursor && i == self.cursor as usize && opt.enabled {
541 f.cursor_at(0, row_inner, Rgba::INK_BLACK);
542 }
543 }
544 });
545 }
546}
547
548pub trait NamedMenuInputSource {
558 fn read_input(&self) -> MenuInput;
560}
561
562#[cfg(test)]
567mod tests {
568 use super::*;
569 use crate::render::{Painter, Rgba, TilePos, TileRect};
570
571 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
574 enum MockMenuId {
575 MainMenu,
576 }
577
578 struct MockMenuProvider {
579 title: &'static str,
580 options: Vec<MenuOption>,
581 layout: MenuLayout,
582 }
583
584 impl MenuProvider for MockMenuProvider {
585 type MenuId = MockMenuId;
586
587 fn title(&self, _menu: Self::MenuId) -> &str {
588 self.title
589 }
590
591 fn options(&self, _menu: Self::MenuId) -> &[MenuOption] {
592 &self.options
593 }
594
595 fn option_count(&self, _menu: Self::MenuId) -> u8 {
596 self.options.len() as u8
597 }
598
599 fn scrollable(&self, _menu: Self::MenuId) -> bool {
600 false
601 }
602
603 fn layout(&self, _menu: Self::MenuId) -> MenuLayout {
604 self.layout
605 }
606 }
607
608 #[derive(Debug, Default)]
612 struct RecordingPainter {
613 text_boxes: Vec<(TileRect, Rgba)>,
614 texts: Vec<(TilePos, String, Rgba)>,
615 glyphs: Vec<(TilePos, char, Rgba)>,
616 }
617
618 impl Painter for RecordingPainter {
619 fn clear(&mut self, _color: Rgba) {}
620 fn draw_text_box(&mut self, rect: TileRect, color: Rgba) {
621 self.text_boxes.push((rect, color));
622 }
623 fn draw_text(&mut self, pos: TilePos, text: &str, color: Rgba) {
624 self.texts.push((pos, text.to_string(), color));
625 }
626 fn draw_glyph(&mut self, pos: TilePos, glyph: char, color: Rgba) {
627 self.glyphs.push((pos, glyph, color));
628 }
629 fn draw_pixel_rect(&mut self, _px: u32, _py: u32, _pw: u32, _ph: u32, _color: Rgba) {}
630 fn draw_gb_tile(&mut self, _pos: TilePos, _tile_id: u8, _fallback: &str, _color: Rgba) {}
631 }
632
633 fn make_provider() -> MockMenuProvider {
634 MockMenuProvider {
635 title: "POKERED",
636 options: vec![
637 MenuOption::new("New Game"),
638 MenuOption::new("Continue"),
639 MenuOption::new("Quit"),
640 ],
641 layout: MenuLayout::new(5, 3, 10, 9),
642 }
643 }
644
645 #[test]
648 fn open_menu_sets_cursor_to_zero() {
649 let provider = make_provider();
650 let mut system = MenuSystem::new(&provider);
651 system.open(MockMenuId::MainMenu);
652 assert!(system.is_open());
653 assert_eq!(system.cursor, 0);
654 assert_eq!(system.scroll_offset, 0);
655 }
656
657 #[test]
658 fn cursor_moves_down_on_down_input() {
659 let provider = make_provider();
660 let mut system = MenuSystem::new(&provider);
661 system.open(MockMenuId::MainMenu);
662
663 let action = system.handle_input(&MenuInput {
664 down: true,
665 ..Default::default()
666 });
667 assert_eq!(action, MenuAction::Down);
668 assert_eq!(system.cursor, 1);
669 }
670
671 #[test]
672 fn cursor_stops_at_last_option() {
673 let provider = make_provider();
674 let mut system = MenuSystem::new(&provider);
675 system.open(MockMenuId::MainMenu);
676
677 system.handle_input(&MenuInput {
679 down: true,
680 ..Default::default()
681 });
682 system.handle_input(&MenuInput {
683 down: true,
684 ..Default::default()
685 });
686 assert_eq!(system.cursor, 2);
687
688 let action = system.handle_input(&MenuInput {
690 down: true,
691 ..Default::default()
692 });
693 assert_eq!(action, MenuAction::None);
694 assert_eq!(system.cursor, 2);
695 }
696
697 #[test]
698 fn cursor_moves_up_on_up_input() {
699 let provider = make_provider();
700 let mut system = MenuSystem::new(&provider);
701 system.open(MockMenuId::MainMenu);
702
703 system.handle_input(&MenuInput {
705 down: true,
706 ..Default::default()
707 });
708 assert_eq!(system.cursor, 1);
709
710 let action = system.handle_input(&MenuInput {
711 up: true,
712 ..Default::default()
713 });
714 assert_eq!(action, MenuAction::Up);
715 assert_eq!(system.cursor, 0);
716 }
717
718 #[test]
719 fn confirm_on_enabled_option_returns_selected() {
720 let provider = make_provider();
721 let mut system = MenuSystem::new(&provider);
722 system.open(MockMenuId::MainMenu);
723
724 system.handle_input(&MenuInput {
726 down: true,
727 ..Default::default()
728 });
729
730 let action = system.handle_input(&MenuInput {
731 confirm: true,
732 ..Default::default()
733 });
734 assert_eq!(action, MenuAction::Selected(1));
735 }
736
737 #[test]
738 fn cancel_returns_cancelled_and_closes_menu() {
739 let provider = make_provider();
740 let mut system = MenuSystem::new(&provider);
741 system.open(MockMenuId::MainMenu);
742
743 let action = system.handle_input(&MenuInput {
744 cancel: true,
745 ..Default::default()
746 });
747 assert_eq!(action, MenuAction::Cancelled);
748 assert!(!system.is_open());
749 }
750
751 #[test]
752 fn disabled_option_is_skipped_by_cursor() {
753 let provider = MockMenuProvider {
754 title: "TEST",
755 options: vec![
756 MenuOption::new("A"),
757 MenuOption::disabled("B"),
758 MenuOption::new("C"),
759 ],
760 layout: MenuLayout::new(0, 0, 6, 5),
761 };
762 let mut system = MenuSystem::new(&provider);
763 system.open(MockMenuId::MainMenu);
764
765 let action = system.handle_input(&MenuInput {
767 down: true,
768 ..Default::default()
769 });
770 assert_eq!(action, MenuAction::Down);
771 assert_eq!(system.cursor, 2);
772 }
773
774 #[test]
775 fn confirm_on_disabled_option_does_nothing() {
776 let provider = MockMenuProvider {
777 title: "TEST",
778 options: vec![MenuOption::disabled("X"), MenuOption::new("Y")],
779 layout: MenuLayout::new(0, 0, 6, 5),
780 };
781 let mut system = MenuSystem::new(&provider);
782 system.open(MockMenuId::MainMenu);
783
784 let action = system.handle_input(&MenuInput {
786 confirm: true,
787 ..Default::default()
788 });
789 assert_eq!(action, MenuAction::None);
790 }
791
792 #[test]
793 fn selected_option_returns_correct_option() {
794 let provider = make_provider();
795 let mut system = MenuSystem::new(&provider);
796 system.open(MockMenuId::MainMenu);
797
798 system.handle_input(&MenuInput {
800 down: true,
801 ..Default::default()
802 });
803
804 let opt = system.selected_option();
805 assert!(opt.is_some());
806 assert_eq!(opt.unwrap().label, "Continue");
807 }
808
809 #[test]
810 fn closed_menu_ignores_input() {
811 let provider = make_provider();
812 let mut system = MenuSystem::new(&provider);
813 let action = system.handle_input(&MenuInput {
816 down: true,
817 confirm: true,
818 ..Default::default()
819 });
820 assert_eq!(action, MenuAction::None);
821 }
822
823 #[test]
824 fn render_draws_text_box_at_layout_position() {
825 let provider = make_provider();
826 let mut system = MenuSystem::new(&provider);
827 system.open(MockMenuId::MainMenu);
828
829 let mut painter = RecordingPainter::default();
830 system.render(&mut painter);
831
832 assert_eq!(painter.text_boxes.len(), 1);
834 let (rect, _) = painter.text_boxes[0];
835 assert_eq!(rect.tx, 5);
836 assert_eq!(rect.ty, 3);
837 assert_eq!(rect.tw, 10);
838 assert_eq!(rect.th, 9);
839 }
840
841 #[test]
842 fn render_draws_title_and_options() {
843 let provider = make_provider();
844 let mut system = MenuSystem::new(&provider);
845 system.open(MockMenuId::MainMenu);
846
847 let mut painter = RecordingPainter::default();
848 system.render(&mut painter);
849
850 let titles: Vec<_> = painter
852 .texts
853 .iter()
854 .filter(|(_, t, _)| t == "POKERED")
855 .collect();
856 assert!(!titles.is_empty(), "Title 'POKERED' was not drawn");
857
858 for expected in &["New Game", "Continue", "Quit"] {
860 let found = painter.texts.iter().any(|(_, t, _)| t == expected);
861 assert!(found, "Option '{}' was not drawn", expected);
862 }
863 }
864
865 #[test]
866 fn render_draws_cursor_at_correct_position() {
867 let provider = make_provider();
868 let mut system = MenuSystem::new(&provider);
869 system.open(MockMenuId::MainMenu);
870
871 system.handle_input(&MenuInput {
873 down: true,
874 ..Default::default()
875 });
876
877 let mut painter = RecordingPainter::default();
878 system.render(&mut painter);
879
880 assert_eq!(painter.glyphs.len(), 1, "Expected exactly 1 cursor glyph");
882 let (glyph_pos, glyph_char, _) = painter.glyphs[0];
883 assert_eq!(glyph_char, '\u{25B6}', "Cursor glyph should be U+25B6");
884
885 assert_eq!(glyph_pos.tx, 6);
891 assert_eq!(glyph_pos.ty, 7);
892 }
893
894 #[test]
895 fn closed_menu_renders_nothing() {
896 let provider = make_provider();
897 let system = MenuSystem::new(&provider);
898 let mut painter = RecordingPainter::default();
901 system.render(&mut painter);
902
903 assert!(painter.text_boxes.is_empty());
904 assert!(painter.texts.is_empty());
905 assert!(painter.glyphs.is_empty());
906 }
907
908 #[test]
909 fn empty_menu_returns_none_on_confirm() {
910 let provider = MockMenuProvider {
911 title: "EMPTY",
912 options: vec![],
913 layout: MenuLayout::new(0, 0, 6, 3),
914 };
915 let mut system = MenuSystem::new(&provider);
916 system.open(MockMenuId::MainMenu);
917
918 let action = system.handle_input(&MenuInput {
919 confirm: true,
920 ..Default::default()
921 });
922 assert_eq!(action, MenuAction::None);
923 }
924
925 #[test]
926 fn menu_option_new_and_disabled() {
927 let opt = MenuOption::new("Hello");
928 assert!(opt.enabled);
929 assert_eq!(opt.label, "Hello");
930 assert!(opt.description.is_none());
931
932 let opt = MenuOption::disabled("Locked");
933 assert!(!opt.enabled);
934 assert_eq!(opt.label, "Locked");
935 }
936
937 #[test]
938 fn menu_layout_builders() {
939 let layout = MenuLayout::new(1, 2, 8, 6)
940 .with_spacing(3)
941 .with_cursor(false);
942
943 assert_eq!(layout.position.tx, 1);
944 assert_eq!(layout.position.ty, 2);
945 assert_eq!(layout.size.tx, 1);
946 assert_eq!(layout.size.ty, 2);
947 assert_eq!(layout.size.tw, 8);
948 assert_eq!(layout.size.th, 6);
949 assert_eq!(layout.option_spacing, 3);
950 assert!(!layout.show_cursor);
951 }
952
953 #[test]
954 fn menu_input_default_is_all_false() {
955 let input = MenuInput::default();
956 assert!(!input.up);
957 assert!(!input.down);
958 assert!(!input.confirm);
959 assert!(!input.cancel);
960 }
961
962 #[test]
963 fn menu_system_close_and_reopen() {
964 let provider = make_provider();
965 let mut system = MenuSystem::new(&provider);
966 system.open(MockMenuId::MainMenu);
967 assert!(system.is_open());
968
969 system.close();
970 assert!(!system.is_open());
971
972 system.open(MockMenuId::MainMenu);
973 assert!(system.is_open());
974 assert_eq!(system.cursor, 0); }
976}