Skip to main content

dotzuki_engine/menu/
mod.rs

1//! Menu system abstractions for JRPG engine.
2//!
3//! This module defines:
4//!
5//! * **`MenuProvider` trait** — game-data provider for menu definitions
6//!   (titles, options, layouts). Implemented by the data crate.
7//! * **`MenuSystem<M>` struct** — stateful menu controller that manages
8//!   cursor position, scroll offset, input handling, and rendering.
9//! * **`MenuInput` / `MenuAction`** — input abstraction and action results.
10//! * **`NamedMenuInputSource` trait** — platform-level input conversion.
11//!
12//! # Design
13//!
14//! The menu system is **data-driven**: the provider supplies static layout
15//! and option data, while `MenuSystem` owns the runtime state (cursor,
16//! scroll, open/closed).  Rendering uses the [`Painter`](crate::render::Painter)
17//! trait, so menus work identically across pixel and recording backends.
18
19use crate::render::{Painter, Rgba, TilePos, TileRect, Ui};
20
21// ---------------------------------------------------------------------------
22// MenuInput — abstracted directional + confirm/cancel input
23// ---------------------------------------------------------------------------
24
25/// Abstract menu input, decoupled from any specific platform or key binding.
26///
27/// A platform input layer (keyboard, gamepad, touch) converts its events
28/// into a `MenuInput` struct and passes it to [`MenuSystem::handle_input`].
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
30pub struct MenuInput {
31    /// Up / D-Pad Up pressed this frame.
32    pub up: bool,
33    /// Down / D-Pad Down pressed this frame.
34    pub down: bool,
35    /// Confirm / A button pressed this frame.
36    pub confirm: bool,
37    /// Cancel / B button pressed this frame.
38    pub cancel: bool,
39}
40
41// ---------------------------------------------------------------------------
42// MenuAction — result of processing one frame of input
43// ---------------------------------------------------------------------------
44
45/// Outcome of [`MenuSystem::handle_input`].
46///
47/// The caller (game loop) uses this to transition game state — e.g.
48/// `Selected(0)` on the main menu means "New Game".
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum MenuAction {
51    /// No input or no state change this frame.
52    None,
53    /// The player confirmed on option `N` (0-based index).
54    Selected(u8),
55    /// The player pressed Cancel / B.
56    Cancelled,
57    /// Cursor moved up (may be used for sound effects).
58    Up,
59    /// Cursor moved down (may be used for sound effects).
60    Down,
61    /// Scroll position changed to `N` (for scrollable menus).
62    Scroll(u8),
63}
64
65// ---------------------------------------------------------------------------
66// MenuOption — a single selectable entry
67// ---------------------------------------------------------------------------
68
69/// A single option in a menu list.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct MenuOption {
72    /// Display text shown to the player.
73    pub label: String,
74    /// If `false`, this option is visually dimmed and cannot be selected.
75    pub enabled: bool,
76    /// Optional longer description / tooltip (not normally displayed).
77    pub description: Option<String>,
78}
79
80impl MenuOption {
81    /// Create an enabled option with just a label.
82    pub fn new(label: impl Into<String>) -> Self {
83        Self { label: label.into(), enabled: true, description: None }
84    }
85
86    /// Create a disabled option.
87    pub fn disabled(label: impl Into<String>) -> Self {
88        Self { label: label.into(), enabled: false, description: None }
89    }
90}
91
92// ---------------------------------------------------------------------------
93// MenuLayout — static positioning and appearance
94// ---------------------------------------------------------------------------
95
96/// Static layout descriptor for a menu.
97///
98/// All coordinates are in **tile units** (8x8 pixels per tile).  The
99/// rendering code uses [`TilePos`] and [`TileRect`] to position the
100/// menu box on screen.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct MenuLayout {
103    /// Screen position of the menu (top-left corner of the border box in
104    /// tile coordinates).
105    pub position: TilePos,
106    /// Size of the menu box including its border (in tile units).
107    pub size: TileRect,
108    /// Vertical spacing between option rows (in tile units).  Typically
109    /// `2` for single-spaced lists.
110    pub option_spacing: u32,
111    /// If `true`, draw a cursor next to the currently-selected option.
112    pub show_cursor: bool,
113}
114
115impl MenuLayout {
116    /// Create a layout at the given tile position, with `tw * th` size
117    /// (INCLUDING the 1-tile border on each side).
118    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    /// Builder: set vertical option spacing.
128    pub fn with_spacing(mut self, spacing: u32) -> Self {
129        self.option_spacing = spacing;
130        self
131    }
132
133    /// Builder: show or hide the cursor indicator.
134    pub fn with_cursor(mut self, show: bool) -> Self {
135        self.show_cursor = show;
136        self
137    }
138}
139
140// ---------------------------------------------------------------------------
141// TileSlot — border position identifier
142// ---------------------------------------------------------------------------
143
144/// Position slot in a border tile set. Used by editors to identify which
145/// border tile to swap.
146#[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// ---------------------------------------------------------------------------
161// BorderStyle — 9-slot tile border configuration
162// ---------------------------------------------------------------------------
163
164/// A configurable 9-slot border for menus. Each slot references a tile index
165/// in the currently active tileset. Editors can swap these to change the
166/// visual style without code changes.
167#[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, // GB standard menu border
185            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// ---------------------------------------------------------------------------
194// CursorAnchor — cursor positioning relative to a menu entry
195// ---------------------------------------------------------------------------
196
197/// Cursor anchor point relative to a menu entry.
198#[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// ---------------------------------------------------------------------------
210// CursorStyle — cursor indicator appearance
211// ---------------------------------------------------------------------------
212
213/// Cursor indicator style for menus.
214#[derive(Debug, Clone, PartialEq)]
215#[non_exhaustive]
216pub struct CursorStyle {
217    /// Tile index for the cursor glyph. `None` → invisible cursor.
218    pub tile: Option<u16>,
219    /// How the cursor is positioned relative to the menu entry.
220    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    /// Create a cursor style with the given tile index and anchor.
231    pub fn new(tile: Option<u16>, anchor: CursorAnchor) -> Self {
232        Self { tile, anchor }
233    }
234}
235
236// ---------------------------------------------------------------------------
237// EdgeInsets — layout padding
238// ---------------------------------------------------------------------------
239
240/// Padding inside a menu container (in tiles).
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub struct EdgeInsets {
243    /// Padding from the top of the container (in tiles).
244    pub top: u32,
245    /// Padding from the bottom of the container (in tiles).
246    pub bottom: u32,
247    /// Padding from the left side of the container (in tiles).
248    pub left: u32,
249    /// Padding from the right side of the container (in tiles).
250    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// ---------------------------------------------------------------------------
260// MenuConfig — rendering-level layout configuration
261// ---------------------------------------------------------------------------
262
263/// Rendering-level layout for a menu. Describes where and how a menu is
264/// drawn on the 160×144 canvas.
265///
266/// Unlike [`MenuLayout`] (which is a game-data provider interface),
267/// `MenuConfig` is the concrete rendering configuration consumed by
268/// [`dotzuki_ui`](crate) widget drawing functions.
269#[derive(Debug, Clone, PartialEq)]
270#[non_exhaustive]
271pub struct MenuConfig {
272    /// The outer bounding box of the menu in tile coordinates.
273    pub area: TileRect,
274    /// Border tile configuration. `None` → no border (transparent background).
275    pub border: Option<BorderStyle>,
276    /// The inner content area (text, icons, etc.) relative to `area`.
277    pub content: TileRect,
278    /// Cursor appearance.
279    pub cursor: CursorStyle,
280    /// Pre-positioned labels at frame-relative tile coordinates.
281    /// Each entry is `(tx, ty, text)`.
282    pub label_positions: Vec<(u32, u32, String)>,
283    /// Vertical gap between list items in tile rows.
284    pub gap: u32,
285    /// Interior padding inside the border.
286    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
300// ---------------------------------------------------------------------------
301// MenuProvider trait
302// ---------------------------------------------------------------------------
303
304/// Provider of static menu definitions (titles, options, layouts).
305///
306/// `MenuProvider` decouples menu **data** from menu **state** and
307/// **rendering**.  The implementing crate supplies concrete menu IDs and
308/// the associated data; the engine's [`MenuSystem`] consumes this data
309/// without knowing anything about the game's menu hierarchy.
310///
311/// # Associated Types
312///
313/// * `MenuId` — an enum (or other `Copy + PartialEq + Debug` type) that
314///   identifies which menu screen to display.
315pub trait MenuProvider {
316    /// The type that identifies a specific menu screen.
317    type MenuId: Copy + std::fmt::Debug + PartialEq;
318
319    /// Title string displayed at the top of the menu box.
320    fn title(&self, menu: Self::MenuId) -> &str;
321
322    /// The list of options for this menu.  The returned slice must live
323    /// at least as long as `self`.
324    fn options(&self, menu: Self::MenuId) -> &[MenuOption];
325
326    /// Number of **visible** options (for cursor bounds checking and
327    /// scroll window size).
328    fn option_count(&self, menu: Self::MenuId) -> u8;
329
330    /// Whether the option list can scroll when it exceeds the visible
331    /// window.
332    fn scrollable(&self, menu: Self::MenuId) -> bool;
333
334    /// Static layout descriptor (position, size, spacing, cursor policy).
335    fn layout(&self, menu: Self::MenuId) -> MenuLayout;
336}
337
338// ---------------------------------------------------------------------------
339// MenuSystem — stateful menu controller
340// ---------------------------------------------------------------------------
341
342/// Stateful menu controller that owns cursor position, scroll offset,
343/// and open/closed state.
344///
345/// `MenuSystem` is parameterised over the `MenuProvider` implementation,
346/// so it can call into the provider for data without any downcasting or
347/// dynamic dispatch overhead.
348pub struct MenuSystem<'prov, M: MenuProvider> {
349    /// Reference to the menu data provider.
350    provider: &'prov M,
351    /// Which menu is currently active.
352    pub current_menu: M::MenuId,
353    /// 0-based index of the currently-highlighted option.
354    pub cursor: u8,
355    /// Scroll offset (for scrollable menus).  0 = first visible option
356    /// is at index 0 in the full option list.
357    pub scroll_offset: u8,
358    /// Whether the menu is currently open / visible.
359    is_open: bool,
360}
361
362impl<'prov, M: MenuProvider> MenuSystem<'prov, M> {
363    /// Create a new `MenuSystem` backed by `provider`.  The menu starts
364    /// closed; call [`open`](Self::open) to activate it.
365    pub fn new(provider: &'prov M) -> Self {
366        Self {
367            provider,
368            // We need a default for current_menu.  Use open() to set it.
369            current_menu: unsafe { std::mem::zeroed() },
370            cursor: 0,
371            scroll_offset: 0,
372            is_open: false,
373        }
374    }
375
376    /// Open a specific menu, resetting cursor and scroll to their defaults.
377    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    /// Close the menu.
385    pub fn close(&mut self) {
386        self.is_open = false;
387    }
388
389    /// Returns `true` if the menu is currently open.
390    pub fn is_open(&self) -> bool {
391        self.is_open
392    }
393
394    /// Process one frame of abstract input and return a [`MenuAction`].
395    ///
396    /// The caller should call this once per frame with the aggregated
397    /// directional / confirm / cancel state.
398    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            // No options -- only cancel is valid.
406            if input.cancel {
407                self.is_open = false;
408                return MenuAction::Cancelled;
409            }
410            return MenuAction::None;
411        }
412
413        // --- direction ---
414        if input.up && self.cursor > 0 {
415            // Find previous enabled option (skip disabled).
416            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        // --- confirm ---
447        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        // --- cancel ---
457        if input.cancel {
458            self.is_open = false;
459            return MenuAction::Cancelled;
460        }
461
462        MenuAction::None
463    }
464
465    /// Return the currently-selected option, or `None` if the cursor is
466    /// out of range or the menu is closed.
467    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    /// Render the menu onto `painter` using the [`Painter`] trait.
477    ///
478    /// This draws the text box, title, options, and cursor indicator
479    /// through the backend-agnostic `Painter` interface.  TUI and pixel
480    /// backends produce identical visual output (modulo resolution).
481    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            // Title row (row 0 inside the border).
495            f.label(0, 0, title, Rgba::INK_BLACK);
496
497            // Options start at row 1 (just below the title), skip scroll_offset.
498            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                // Cursor
512                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
520// ---------------------------------------------------------------------------
521// NamedMenuInputSource trait -- platform input to MenuInput
522// ---------------------------------------------------------------------------
523
524/// Trait for converting platform-specific input (keyboard, gamepad,
525/// touch) into abstract [`MenuInput`].
526///
527/// Implementors read their hardware / event state and produce a
528/// `MenuInput` struct once per frame.
529pub trait NamedMenuInputSource {
530    /// Read the current input state and return a [`MenuInput`].
531    fn read_input(&self) -> MenuInput;
532}
533
534// ---------------------------------------------------------------------------
535// Tests
536// ---------------------------------------------------------------------------
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use crate::render::{Painter, Rgba, TilePos, TileRect};
542
543    // -- Mock types -------------------------------------------------------
544
545    #[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    // -- Recording painter for tests -------------------------------------
581
582    /// A `Painter` that records every call for later inspection.
583    #[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    // -- Tests -----------------------------------------------------------
618
619    #[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        // Move to last option (index 2).
647        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        // One more down should do nothing (stay at 2).
652        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        // Move down first, then up.
664        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        // Move to "Continue" (index 1).
679        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        // Cursor starts at "A" (index 0). Down should skip disabled "B" and land on "C".
711        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        // Cursor starts at 0 (disabled). Confirm should do nothing.
730        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        // Move to "Continue".
741        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        // Menu is NOT opened.
753
754        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        // Should draw one text box.
772        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        // Title should be drawn.
790        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        // All three options should be drawn.
798        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        // Move cursor to index 1 ("Continue").
811        system.handle_input(&MenuInput { down: true, ..Default::default() });
812
813        let mut painter = RecordingPainter::default();
814        system.render(&mut painter);
815
816        // Should have exactly one cursor glyph.
817        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        // Cursor should be on the row corresponding to option index 1.
822        // Layout: title at inner row 0, options start at inner row 1,
823        // option_spacing = 2, so option 1 is at inner row 3.
824        // Frame origin = layout.pos + 1-tile border inset = (6, 4).
825        // So absolute tile pos for cursor = (6 + 0, 4 + 1 + 1*2) = (6, 7).
826        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        // NOT opened.
835
836        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); // reset
908    }
909}