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 {
84            label: label.into(),
85            enabled: true,
86            description: None,
87        }
88    }
89
90    /// Create a disabled option.
91    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// ---------------------------------------------------------------------------
101// MenuLayout — static positioning and appearance
102// ---------------------------------------------------------------------------
103
104/// Static layout descriptor for a menu.
105///
106/// All coordinates are in **tile units** (8x8 pixels per tile).  The
107/// rendering code uses [`TilePos`] and [`TileRect`] to position the
108/// menu box on screen.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct MenuLayout {
111    /// Screen position of the menu (top-left corner of the border box in
112    /// tile coordinates).
113    pub position: TilePos,
114    /// Size of the menu box including its border (in tile units).
115    pub size: TileRect,
116    /// Vertical spacing between option rows (in tile units).  Typically
117    /// `2` for single-spaced lists.
118    pub option_spacing: u32,
119    /// If `true`, draw a cursor next to the currently-selected option.
120    pub show_cursor: bool,
121}
122
123impl MenuLayout {
124    /// Create a layout at the given tile position, with `tw * th` size
125    /// (INCLUDING the 1-tile border on each side).
126    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    /// Builder: set vertical option spacing.
136    pub fn with_spacing(mut self, spacing: u32) -> Self {
137        self.option_spacing = spacing;
138        self
139    }
140
141    /// Builder: show or hide the cursor indicator.
142    pub fn with_cursor(mut self, show: bool) -> Self {
143        self.show_cursor = show;
144        self
145    }
146}
147
148// ---------------------------------------------------------------------------
149// TileSlot — border position identifier
150// ---------------------------------------------------------------------------
151
152/// Position slot in a border tile set. Used by editors to identify which
153/// border tile to swap.
154#[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// ---------------------------------------------------------------------------
169// BorderStyle — 9-slot tile border configuration
170// ---------------------------------------------------------------------------
171
172/// A configurable 9-slot border for menus. Each slot references a tile index
173/// in the currently active tileset. Editors can swap these to change the
174/// visual style without code changes.
175#[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, // GB standard menu border
194            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// ---------------------------------------------------------------------------
206// CursorAnchor — cursor positioning relative to a menu entry
207// ---------------------------------------------------------------------------
208
209/// Cursor anchor point relative to a menu entry.
210#[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// ---------------------------------------------------------------------------
222// CursorStyle — cursor indicator appearance
223// ---------------------------------------------------------------------------
224
225/// Cursor indicator style for menus.
226#[derive(Debug, Clone, PartialEq)]
227#[non_exhaustive]
228pub struct CursorStyle {
229    /// Tile index for the cursor glyph. `None` → invisible cursor.
230    pub tile: Option<u16>,
231    /// How the cursor is positioned relative to the menu entry.
232    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    /// Create a cursor style with the given tile index and anchor.
246    pub fn new(tile: Option<u16>, anchor: CursorAnchor) -> Self {
247        Self { tile, anchor }
248    }
249}
250
251// ---------------------------------------------------------------------------
252// EdgeInsets — layout padding
253// ---------------------------------------------------------------------------
254
255/// Padding inside a menu container (in tiles).
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub struct EdgeInsets {
258    /// Padding from the top of the container (in tiles).
259    pub top: u32,
260    /// Padding from the bottom of the container (in tiles).
261    pub bottom: u32,
262    /// Padding from the left side of the container (in tiles).
263    pub left: u32,
264    /// Padding from the right side of the container (in tiles).
265    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// ---------------------------------------------------------------------------
280// MenuConfig — rendering-level layout configuration
281// ---------------------------------------------------------------------------
282
283/// Rendering-level layout for a menu. Describes where and how a menu is
284/// drawn on the 160×144 canvas.
285///
286/// Unlike [`MenuLayout`] (which is a game-data provider interface),
287/// `MenuConfig` is the concrete rendering configuration consumed by
288/// [`dotzuki_ui`](crate) widget drawing functions.
289#[derive(Debug, Clone, PartialEq)]
290#[non_exhaustive]
291pub struct MenuConfig {
292    /// The outer bounding box of the menu in tile coordinates.
293    pub area: TileRect,
294    /// Border tile configuration. `None` → no border (transparent background).
295    pub border: Option<BorderStyle>,
296    /// The inner content area (text, icons, etc.) relative to `area`.
297    pub content: TileRect,
298    /// Cursor appearance.
299    pub cursor: CursorStyle,
300    /// Pre-positioned labels at frame-relative tile coordinates.
301    /// Each entry is `(tx, ty, text)`.
302    pub label_positions: Vec<(u32, u32, String)>,
303    /// Vertical gap between list items in tile rows.
304    pub gap: u32,
305    /// Interior padding inside the border.
306    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
328// ---------------------------------------------------------------------------
329// MenuProvider trait
330// ---------------------------------------------------------------------------
331
332/// Provider of static menu definitions (titles, options, layouts).
333///
334/// `MenuProvider` decouples menu **data** from menu **state** and
335/// **rendering**.  The implementing crate supplies concrete menu IDs and
336/// the associated data; the engine's [`MenuSystem`] consumes this data
337/// without knowing anything about the game's menu hierarchy.
338///
339/// # Associated Types
340///
341/// * `MenuId` — an enum (or other `Copy + PartialEq + Debug` type) that
342///   identifies which menu screen to display.
343pub trait MenuProvider {
344    /// The type that identifies a specific menu screen.
345    type MenuId: Copy + core::fmt::Debug + PartialEq;
346
347    /// Title string displayed at the top of the menu box.
348    fn title(&self, menu: Self::MenuId) -> &str;
349
350    /// The list of options for this menu.  The returned slice must live
351    /// at least as long as `self`.
352    fn options(&self, menu: Self::MenuId) -> &[MenuOption];
353
354    /// Number of **visible** options (for cursor bounds checking and
355    /// scroll window size).
356    fn option_count(&self, menu: Self::MenuId) -> u8;
357
358    /// Whether the option list can scroll when it exceeds the visible
359    /// window.
360    fn scrollable(&self, menu: Self::MenuId) -> bool;
361
362    /// Static layout descriptor (position, size, spacing, cursor policy).
363    fn layout(&self, menu: Self::MenuId) -> MenuLayout;
364}
365
366// ---------------------------------------------------------------------------
367// MenuSystem — stateful menu controller
368// ---------------------------------------------------------------------------
369
370/// Stateful menu controller that owns cursor position, scroll offset,
371/// and open/closed state.
372///
373/// `MenuSystem` is parameterised over the `MenuProvider` implementation,
374/// so it can call into the provider for data without any downcasting or
375/// dynamic dispatch overhead.
376pub struct MenuSystem<'prov, M: MenuProvider> {
377    /// Reference to the menu data provider.
378    provider: &'prov M,
379    /// Which menu is currently active.
380    pub current_menu: M::MenuId,
381    /// 0-based index of the currently-highlighted option.
382    pub cursor: u8,
383    /// Scroll offset (for scrollable menus).  0 = first visible option
384    /// is at index 0 in the full option list.
385    pub scroll_offset: u8,
386    /// Whether the menu is currently open / visible.
387    is_open: bool,
388}
389
390impl<'prov, M: MenuProvider> MenuSystem<'prov, M> {
391    /// Create a new `MenuSystem` backed by `provider`.  The menu starts
392    /// closed; call [`open`](Self::open) to activate it.
393    pub fn new(provider: &'prov M) -> Self {
394        Self {
395            provider,
396            // We need a default for current_menu.  Use open() to set it.
397            current_menu: unsafe { core::mem::zeroed() },
398            cursor: 0,
399            scroll_offset: 0,
400            is_open: false,
401        }
402    }
403
404    /// Open a specific menu, resetting cursor and scroll to their defaults.
405    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    /// Close the menu.
413    pub fn close(&mut self) {
414        self.is_open = false;
415    }
416
417    /// Returns `true` if the menu is currently open.
418    pub fn is_open(&self) -> bool {
419        self.is_open
420    }
421
422    /// Process one frame of abstract input and return a [`MenuAction`].
423    ///
424    /// The caller should call this once per frame with the aggregated
425    /// directional / confirm / cancel state.
426    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            // No options -- only cancel is valid.
434            if input.cancel {
435                self.is_open = false;
436                return MenuAction::Cancelled;
437            }
438            return MenuAction::None;
439        }
440
441        // --- direction ---
442        if input.up && self.cursor > 0 {
443            // Find previous enabled option (skip disabled).
444            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        // --- confirm ---
475        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        // --- cancel ---
485        if input.cancel {
486            self.is_open = false;
487            return MenuAction::Cancelled;
488        }
489
490        MenuAction::None
491    }
492
493    /// Return the currently-selected option, or `None` if the cursor is
494    /// out of range or the menu is closed.
495    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    /// Render the menu onto `painter` using the [`Painter`] trait.
505    ///
506    /// This draws the text box, title, options, and cursor indicator
507    /// through the backend-agnostic `Painter` interface.  TUI and pixel
508    /// backends produce identical visual output (modulo resolution).
509    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            // Title row (row 0 inside the border).
523            f.label(0, 0, title, Rgba::INK_BLACK);
524
525            // Options start at row 1 (just below the title), skip scroll_offset.
526            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                // Cursor
540                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
548// ---------------------------------------------------------------------------
549// NamedMenuInputSource trait -- platform input to MenuInput
550// ---------------------------------------------------------------------------
551
552/// Trait for converting platform-specific input (keyboard, gamepad,
553/// touch) into abstract [`MenuInput`].
554///
555/// Implementors read their hardware / event state and produce a
556/// `MenuInput` struct once per frame.
557pub trait NamedMenuInputSource {
558    /// Read the current input state and return a [`MenuInput`].
559    fn read_input(&self) -> MenuInput;
560}
561
562// ---------------------------------------------------------------------------
563// Tests
564// ---------------------------------------------------------------------------
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use crate::render::{Painter, Rgba, TilePos, TileRect};
570
571    // -- Mock types -------------------------------------------------------
572
573    #[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    // -- Recording painter for tests -------------------------------------
609
610    /// A `Painter` that records every call for later inspection.
611    #[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    // -- Tests -----------------------------------------------------------
646
647    #[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        // Move to last option (index 2).
678        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        // One more down should do nothing (stay at 2).
689        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        // Move down first, then up.
704        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        // Move to "Continue" (index 1).
725        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        // Cursor starts at "A" (index 0). Down should skip disabled "B" and land on "C".
766        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        // Cursor starts at 0 (disabled). Confirm should do nothing.
785        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        // Move to "Continue".
799        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        // Menu is NOT opened.
814
815        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        // Should draw one text box.
833        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        // Title should be drawn.
851        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        // All three options should be drawn.
859        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        // Move cursor to index 1 ("Continue").
872        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        // Should have exactly one cursor glyph.
881        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        // Cursor should be on the row corresponding to option index 1.
886        // Layout: title at inner row 0, options start at inner row 1,
887        // option_spacing = 2, so option 1 is at inner row 3.
888        // Frame origin = layout.pos + 1-tile border inset = (6, 4).
889        // So absolute tile pos for cursor = (6 + 0, 4 + 1 + 1*2) = (6, 7).
890        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        // NOT opened.
899
900        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); // reset
975    }
976}