use crate::render::{Painter, Rgba, TilePos, TileRect, Ui};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct MenuInput {
pub up: bool,
pub down: bool,
pub confirm: bool,
pub cancel: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MenuAction {
None,
Selected(u8),
Cancelled,
Up,
Down,
Scroll(u8),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MenuOption {
pub label: String,
pub enabled: bool,
pub description: Option<String>,
}
impl MenuOption {
pub fn new(label: impl Into<String>) -> Self {
Self { label: label.into(), enabled: true, description: None }
}
pub fn disabled(label: impl Into<String>) -> Self {
Self { label: label.into(), enabled: false, description: None }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MenuLayout {
pub position: TilePos,
pub size: TileRect,
pub option_spacing: u32,
pub show_cursor: bool,
}
impl MenuLayout {
pub fn new(tx: u32, ty: u32, tw: u32, th: u32) -> Self {
Self {
position: TilePos::new(tx, ty),
size: TileRect::new(tx, ty, tw, th),
option_spacing: 2,
show_cursor: true,
}
}
pub fn with_spacing(mut self, spacing: u32) -> Self {
self.option_spacing = spacing;
self
}
pub fn with_cursor(mut self, show: bool) -> Self {
self.show_cursor = show;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TileSlot {
TopLeftCorner,
TopRightCorner,
BottomLeftCorner,
BottomRightCorner,
TopEdge,
BottomEdge,
LeftEdge,
RightEdge,
Fill,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct BorderStyle {
pub corner_tl: u16,
pub corner_tr: u16,
pub corner_bl: u16,
pub corner_br: u16,
pub edge_top: u16,
pub edge_bottom: u16,
pub edge_left: u16,
pub edge_right: u16,
pub fill_bg: u16,
}
impl Default for BorderStyle {
fn default() -> Self {
Self {
corner_tl: 192, corner_tr: 193, corner_bl: 198, corner_br: 199,
edge_top: 194, edge_bottom: 197,
edge_left: 195, edge_right: 196,
fill_bg: 200,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum CursorAnchor {
#[default]
CenterLeft,
TopLeft,
BottomLeft,
TopRight,
CenterRight,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct CursorStyle {
pub tile: Option<u16>,
pub anchor: CursorAnchor,
}
impl Default for CursorStyle {
fn default() -> Self {
Self { tile: Some(223), anchor: CursorAnchor::CenterLeft }
}
}
impl CursorStyle {
pub fn new(tile: Option<u16>, anchor: CursorAnchor) -> Self {
Self { tile, anchor }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EdgeInsets {
pub top: u32,
pub bottom: u32,
pub left: u32,
pub right: u32,
}
impl Default for EdgeInsets {
fn default() -> Self {
Self { top: 1, bottom: 1, left: 1, right: 1 }
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct MenuConfig {
pub area: TileRect,
pub border: Option<BorderStyle>,
pub content: TileRect,
pub cursor: CursorStyle,
pub label_positions: Vec<(u32, u32, String)>,
pub gap: u32,
pub padding: EdgeInsets,
}
impl MenuConfig {
pub fn new(area: TileRect, border: Option<BorderStyle>, content: TileRect, cursor: CursorStyle) -> Self {
Self {
area, border, content, cursor,
label_positions: Vec::new(),
gap: 1,
padding: EdgeInsets::default(),
}
}
}
pub trait MenuProvider {
type MenuId: Copy + std::fmt::Debug + PartialEq;
fn title(&self, menu: Self::MenuId) -> &str;
fn options(&self, menu: Self::MenuId) -> &[MenuOption];
fn option_count(&self, menu: Self::MenuId) -> u8;
fn scrollable(&self, menu: Self::MenuId) -> bool;
fn layout(&self, menu: Self::MenuId) -> MenuLayout;
}
pub struct MenuSystem<'prov, M: MenuProvider> {
provider: &'prov M,
pub current_menu: M::MenuId,
pub cursor: u8,
pub scroll_offset: u8,
is_open: bool,
}
impl<'prov, M: MenuProvider> MenuSystem<'prov, M> {
pub fn new(provider: &'prov M) -> Self {
Self {
provider,
current_menu: unsafe { std::mem::zeroed() },
cursor: 0,
scroll_offset: 0,
is_open: false,
}
}
pub fn open(&mut self, menu: M::MenuId) {
self.current_menu = menu;
self.cursor = 0;
self.scroll_offset = 0;
self.is_open = true;
}
pub fn close(&mut self) {
self.is_open = false;
}
pub fn is_open(&self) -> bool {
self.is_open
}
pub fn handle_input(&mut self, input: &MenuInput) -> MenuAction {
if !self.is_open {
return MenuAction::None;
}
let total = self.provider.option_count(self.current_menu);
if total == 0 {
if input.cancel {
self.is_open = false;
return MenuAction::Cancelled;
}
return MenuAction::None;
}
if input.up && self.cursor > 0 {
let mut next = self.cursor;
loop {
if next == 0 {
break;
}
next -= 1;
let opts = self.provider.options(self.current_menu);
if opts[next as usize].enabled {
self.cursor = next;
return MenuAction::Up;
}
}
}
if input.down {
let max = (total as usize).saturating_sub(1);
let mut next = self.cursor;
loop {
if (next as usize) >= max {
break;
}
next += 1;
let opts = self.provider.options(self.current_menu);
if opts[next as usize].enabled {
self.cursor = next;
return MenuAction::Down;
}
}
}
if input.confirm {
let opts = self.provider.options(self.current_menu);
if let Some(opt) = opts.get(self.cursor as usize) {
if opt.enabled {
return MenuAction::Selected(self.cursor);
}
}
}
if input.cancel {
self.is_open = false;
return MenuAction::Cancelled;
}
MenuAction::None
}
pub fn selected_option(&self) -> Option<&MenuOption> {
if !self.is_open {
return None;
}
self.provider
.options(self.current_menu)
.get(self.cursor as usize)
}
pub fn render<P: Painter>(&self, painter: &mut P) {
if !self.is_open {
return;
}
let layout = self.provider.layout(self.current_menu);
let title = self.provider.title(self.current_menu);
let options = self.provider.options(self.current_menu);
let visible_count = self.provider.option_count(self.current_menu);
let mut ui = Ui::new(painter);
ui.text_box(layout.size, Rgba::INK_BLACK, true, |f| {
f.label(0, 0, title, Rgba::INK_BLACK);
let start = self.scroll_offset as usize;
let end = (start + visible_count as usize).min(options.len());
for i in start..end {
let row_inner = 1 + ((i - start) as u32) * layout.option_spacing;
let opt = &options[i];
let color = if opt.enabled {
Rgba::INK_BLACK
} else {
Rgba::INK_LIGHT_GRAY
};
f.label(1, row_inner, &opt.label, color);
if layout.show_cursor && i == self.cursor as usize && opt.enabled {
f.cursor_at(0, row_inner, Rgba::INK_BLACK);
}
}
});
}
}
pub trait NamedMenuInputSource {
fn read_input(&self) -> MenuInput;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::{Painter, Rgba, TilePos, TileRect};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MockMenuId {
MainMenu,
}
struct MockMenuProvider {
title: &'static str,
options: Vec<MenuOption>,
layout: MenuLayout,
}
impl MenuProvider for MockMenuProvider {
type MenuId = MockMenuId;
fn title(&self, _menu: Self::MenuId) -> &str {
self.title
}
fn options(&self, _menu: Self::MenuId) -> &[MenuOption] {
&self.options
}
fn option_count(&self, _menu: Self::MenuId) -> u8 {
self.options.len() as u8
}
fn scrollable(&self, _menu: Self::MenuId) -> bool {
false
}
fn layout(&self, _menu: Self::MenuId) -> MenuLayout {
self.layout
}
}
#[derive(Debug, Default)]
struct RecordingPainter {
text_boxes: Vec<(TileRect, Rgba)>,
texts: Vec<(TilePos, String, Rgba)>,
glyphs: Vec<(TilePos, char, Rgba)>,
}
impl Painter for RecordingPainter {
fn clear(&mut self, _color: Rgba) {}
fn draw_text_box(&mut self, rect: TileRect, color: Rgba) {
self.text_boxes.push((rect, color));
}
fn draw_text(&mut self, pos: TilePos, text: &str, color: Rgba) {
self.texts.push((pos, text.to_string(), color));
}
fn draw_glyph(&mut self, pos: TilePos, glyph: char, color: Rgba) {
self.glyphs.push((pos, glyph, color));
}
fn draw_pixel_rect(&mut self, _px: u32, _py: u32, _pw: u32, _ph: u32, _color: Rgba) {}
fn draw_gb_tile(&mut self, _pos: TilePos, _tile_id: u8, _fallback: &str, _color: Rgba) {}
}
fn make_provider() -> MockMenuProvider {
MockMenuProvider {
title: "POKERED",
options: vec![
MenuOption::new("New Game"),
MenuOption::new("Continue"),
MenuOption::new("Quit"),
],
layout: MenuLayout::new(5, 3, 10, 9),
}
}
#[test]
fn open_menu_sets_cursor_to_zero() {
let provider = make_provider();
let mut system = MenuSystem::new(&provider);
system.open(MockMenuId::MainMenu);
assert!(system.is_open());
assert_eq!(system.cursor, 0);
assert_eq!(system.scroll_offset, 0);
}
#[test]
fn cursor_moves_down_on_down_input() {
let provider = make_provider();
let mut system = MenuSystem::new(&provider);
system.open(MockMenuId::MainMenu);
let action = system.handle_input(&MenuInput { down: true, ..Default::default() });
assert_eq!(action, MenuAction::Down);
assert_eq!(system.cursor, 1);
}
#[test]
fn cursor_stops_at_last_option() {
let provider = make_provider();
let mut system = MenuSystem::new(&provider);
system.open(MockMenuId::MainMenu);
system.handle_input(&MenuInput { down: true, ..Default::default() });
system.handle_input(&MenuInput { down: true, ..Default::default() });
assert_eq!(system.cursor, 2);
let action = system.handle_input(&MenuInput { down: true, ..Default::default() });
assert_eq!(action, MenuAction::None);
assert_eq!(system.cursor, 2);
}
#[test]
fn cursor_moves_up_on_up_input() {
let provider = make_provider();
let mut system = MenuSystem::new(&provider);
system.open(MockMenuId::MainMenu);
system.handle_input(&MenuInput { down: true, ..Default::default() });
assert_eq!(system.cursor, 1);
let action = system.handle_input(&MenuInput { up: true, ..Default::default() });
assert_eq!(action, MenuAction::Up);
assert_eq!(system.cursor, 0);
}
#[test]
fn confirm_on_enabled_option_returns_selected() {
let provider = make_provider();
let mut system = MenuSystem::new(&provider);
system.open(MockMenuId::MainMenu);
system.handle_input(&MenuInput { down: true, ..Default::default() });
let action = system.handle_input(&MenuInput { confirm: true, ..Default::default() });
assert_eq!(action, MenuAction::Selected(1));
}
#[test]
fn cancel_returns_cancelled_and_closes_menu() {
let provider = make_provider();
let mut system = MenuSystem::new(&provider);
system.open(MockMenuId::MainMenu);
let action = system.handle_input(&MenuInput { cancel: true, ..Default::default() });
assert_eq!(action, MenuAction::Cancelled);
assert!(!system.is_open());
}
#[test]
fn disabled_option_is_skipped_by_cursor() {
let provider = MockMenuProvider {
title: "TEST",
options: vec![
MenuOption::new("A"),
MenuOption::disabled("B"),
MenuOption::new("C"),
],
layout: MenuLayout::new(0, 0, 6, 5),
};
let mut system = MenuSystem::new(&provider);
system.open(MockMenuId::MainMenu);
let action = system.handle_input(&MenuInput { down: true, ..Default::default() });
assert_eq!(action, MenuAction::Down);
assert_eq!(system.cursor, 2);
}
#[test]
fn confirm_on_disabled_option_does_nothing() {
let provider = MockMenuProvider {
title: "TEST",
options: vec![
MenuOption::disabled("X"),
MenuOption::new("Y"),
],
layout: MenuLayout::new(0, 0, 6, 5),
};
let mut system = MenuSystem::new(&provider);
system.open(MockMenuId::MainMenu);
let action = system.handle_input(&MenuInput { confirm: true, ..Default::default() });
assert_eq!(action, MenuAction::None);
}
#[test]
fn selected_option_returns_correct_option() {
let provider = make_provider();
let mut system = MenuSystem::new(&provider);
system.open(MockMenuId::MainMenu);
system.handle_input(&MenuInput { down: true, ..Default::default() });
let opt = system.selected_option();
assert!(opt.is_some());
assert_eq!(opt.unwrap().label, "Continue");
}
#[test]
fn closed_menu_ignores_input() {
let provider = make_provider();
let mut system = MenuSystem::new(&provider);
let action = system.handle_input(&MenuInput {
down: true,
confirm: true,
..Default::default()
});
assert_eq!(action, MenuAction::None);
}
#[test]
fn render_draws_text_box_at_layout_position() {
let provider = make_provider();
let mut system = MenuSystem::new(&provider);
system.open(MockMenuId::MainMenu);
let mut painter = RecordingPainter::default();
system.render(&mut painter);
assert_eq!(painter.text_boxes.len(), 1);
let (rect, _) = painter.text_boxes[0];
assert_eq!(rect.tx, 5);
assert_eq!(rect.ty, 3);
assert_eq!(rect.tw, 10);
assert_eq!(rect.th, 9);
}
#[test]
fn render_draws_title_and_options() {
let provider = make_provider();
let mut system = MenuSystem::new(&provider);
system.open(MockMenuId::MainMenu);
let mut painter = RecordingPainter::default();
system.render(&mut painter);
let titles: Vec<_> = painter
.texts
.iter()
.filter(|(_, t, _)| t == "POKERED")
.collect();
assert!(!titles.is_empty(), "Title 'POKERED' was not drawn");
for expected in &["New Game", "Continue", "Quit"] {
let found = painter.texts.iter().any(|(_, t, _)| t == expected);
assert!(found, "Option '{}' was not drawn", expected);
}
}
#[test]
fn render_draws_cursor_at_correct_position() {
let provider = make_provider();
let mut system = MenuSystem::new(&provider);
system.open(MockMenuId::MainMenu);
system.handle_input(&MenuInput { down: true, ..Default::default() });
let mut painter = RecordingPainter::default();
system.render(&mut painter);
assert_eq!(painter.glyphs.len(), 1, "Expected exactly 1 cursor glyph");
let (glyph_pos, glyph_char, _) = painter.glyphs[0];
assert_eq!(glyph_char, '\u{25B6}', "Cursor glyph should be U+25B6");
assert_eq!(glyph_pos.tx, 6);
assert_eq!(glyph_pos.ty, 7);
}
#[test]
fn closed_menu_renders_nothing() {
let provider = make_provider();
let system = MenuSystem::new(&provider);
let mut painter = RecordingPainter::default();
system.render(&mut painter);
assert!(painter.text_boxes.is_empty());
assert!(painter.texts.is_empty());
assert!(painter.glyphs.is_empty());
}
#[test]
fn empty_menu_returns_none_on_confirm() {
let provider = MockMenuProvider {
title: "EMPTY",
options: vec![],
layout: MenuLayout::new(0, 0, 6, 3),
};
let mut system = MenuSystem::new(&provider);
system.open(MockMenuId::MainMenu);
let action = system.handle_input(&MenuInput { confirm: true, ..Default::default() });
assert_eq!(action, MenuAction::None);
}
#[test]
fn menu_option_new_and_disabled() {
let opt = MenuOption::new("Hello");
assert!(opt.enabled);
assert_eq!(opt.label, "Hello");
assert!(opt.description.is_none());
let opt = MenuOption::disabled("Locked");
assert!(!opt.enabled);
assert_eq!(opt.label, "Locked");
}
#[test]
fn menu_layout_builders() {
let layout = MenuLayout::new(1, 2, 8, 6)
.with_spacing(3)
.with_cursor(false);
assert_eq!(layout.position.tx, 1);
assert_eq!(layout.position.ty, 2);
assert_eq!(layout.size.tx, 1);
assert_eq!(layout.size.ty, 2);
assert_eq!(layout.size.tw, 8);
assert_eq!(layout.size.th, 6);
assert_eq!(layout.option_spacing, 3);
assert!(!layout.show_cursor);
}
#[test]
fn menu_input_default_is_all_false() {
let input = MenuInput::default();
assert!(!input.up);
assert!(!input.down);
assert!(!input.confirm);
assert!(!input.cancel);
}
#[test]
fn menu_system_close_and_reopen() {
let provider = make_provider();
let mut system = MenuSystem::new(&provider);
system.open(MockMenuId::MainMenu);
assert!(system.is_open());
system.close();
assert!(!system.is_open());
system.open(MockMenuId::MainMenu);
assert!(system.is_open());
assert_eq!(system.cursor, 0); }
}