Skip to main content

rlvgl_widgets/
menu.rs

1//! Multi-page hierarchical navigation widget (LPAR-13 §5.E).
2//!
3//! A [`Menu`] owns a set of [`MenuPage`]s, each containing a list of
4//! [`MenuItem`] rows (labels, sub-page links, and separators).  Navigation
5//! pushes child pages onto an internal stack; [`back`](Menu::back) pops it.
6//!
7//! # Draw layout
8//!
9//! - **Header bar** — drawn at the top (or bottom for `BottomFixed`) with
10//!   the current page title and an optional back-button.
11//! - **Item list** — the remaining height holds scrollable item rows.
12//!
13//! In v1 item layout uses static row heights (`16 px`, matching
14//! [`crate::list::List`]).  Full LPAR-10 flex layout is deferred-Coupled.
15//!
16//! # Key navigation (LPAR-12/LPAR-13 §5.J)
17//!
18//! Wire `ObjectEvent::Key` to the imperative helpers:
19//!
20//! ```ignore
21//! menu.navigate_next();      // ArrowDown
22//! menu.navigate_prev();      // ArrowUp
23//! menu.activate_selected();  // Enter — drills into sub-page or marks item
24//! menu.back();               // Escape / back button
25//! ```
26
27use alloc::{string::String, vec::Vec};
28use rlvgl_core::draw::draw_widget_bg;
29use rlvgl_core::event::Event;
30use rlvgl_core::font::{FontMetrics, WidgetFont, shape_text_ltr};
31use rlvgl_core::renderer::Renderer;
32use rlvgl_core::style::Style;
33use rlvgl_core::widget::{Color, Rect, Widget};
34
35/// Row height for items in pixels (mirrors [`crate::list::List`]).
36const ROW_HEIGHT: i32 = 16;
37/// Height of the header bar in pixels.
38const HEADER_HEIGHT: i32 = 22;
39/// Height of a separator row in pixels.
40const SEPARATOR_HEIGHT: i32 = 4;
41
42// ── Public types ──────────────────────────────────────────────────────────────
43
44/// Opaque identifier for a [`MenuPage`] within a [`Menu`].
45///
46/// Assigned sequentially by [`Menu::add_page`].
47/// `PAGE_NONE` is the sentinel for "no page".
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct MenuPageId(pub u16);
50
51/// Sentinel [`MenuPageId`] meaning "no page".
52pub const PAGE_NONE: MenuPageId = MenuPageId(u16::MAX);
53
54/// Opaque identifier for a [`MenuItem`] within a page.
55///
56/// Assigned sequentially by [`Menu::add_item`].
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct MenuItemId(pub u16);
59
60/// A single row inside a [`MenuPage`].
61///
62/// Registration policy: Specification Required (LPAR-13 §7).
63#[derive(Debug, Clone)]
64pub enum MenuItem {
65    /// Plain text label.
66    Label(String),
67    /// Text label that navigates to `target` page when activated.
68    SubPage {
69        /// Displayed text for this item.
70        label: String,
71        /// Page to push onto the stack when activated.
72        target: MenuPageId,
73    },
74    /// Horizontal rule / divider between groups of items.
75    Separator,
76}
77
78impl MenuItem {
79    /// Return `true` when this item can be navigated to / highlighted.
80    fn is_navigable(&self) -> bool {
81        !matches!(self, MenuItem::Separator)
82    }
83
84    /// Row height for this item variant.
85    fn row_height(&self) -> i32 {
86        match self {
87            MenuItem::Separator => SEPARATOR_HEIGHT,
88            _ => ROW_HEIGHT,
89        }
90    }
91}
92
93/// Position of the header bar within the [`Menu`] bounds.
94///
95/// Registration policy: Specification Required (LPAR-13 §7).
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum MenuHeaderMode {
98    /// Header bar pinned to the top of the widget (default).
99    TopFixed,
100    /// Header bar at the top but scrolls with content (deferred-Safe in v1;
101    /// behaves as `TopFixed` pending LPAR-05 ObjectNode integration).
102    TopUnfixed,
103    /// Header bar pinned to the bottom of the widget.
104    BottomFixed,
105}
106
107// ── Internal page structure ───────────────────────────────────────────────────
108
109struct MenuPage {
110    title: String,
111    items: Vec<MenuItem>,
112}
113
114// ── Menu ──────────────────────────────────────────────────────────────────────
115
116/// Multi-page hierarchical navigation surface (LPAR-13 §5.E).
117///
118/// Stores all page data and item strings as owned values.
119pub struct Menu {
120    bounds: Rect,
121    pages: Vec<MenuPage>,
122    /// Stack of page ids; `stack.last()` is the currently displayed page.
123    stack: Vec<MenuPageId>,
124    /// Root page (shown when nothing is on the stack).
125    root_page: MenuPageId,
126    /// Index within the current page's navigable items (None = no highlight).
127    highlight: Option<usize>,
128    header_mode: MenuHeaderMode,
129    show_root_back_button: bool,
130    /// Background / border style for the full widget (`Part::MAIN`).
131    pub style: Style,
132    /// Style applied to item rows (`Part::ITEMS`).
133    pub item_style: Style,
134    /// Highlight color for the focused item (`Part::SELECTED`).
135    pub selected_color: Color,
136    /// Background color for the header bar.
137    pub header_color: Color,
138    /// Color for all text labels.
139    pub text_color: Color,
140    /// Color for separator rules.
141    pub separator_color: Color,
142    /// Font assignment for this widget (FONT-00 §5); resolves to `FONT_6X10`
143    /// when unset.
144    font: WidgetFont,
145}
146
147impl Menu {
148    /// Create a [`Menu`] widget occupying `bounds` with no pages.
149    pub fn new(bounds: Rect) -> Self {
150        Self {
151            bounds,
152            pages: Vec::new(),
153            stack: Vec::new(),
154            root_page: PAGE_NONE,
155            highlight: None,
156            header_mode: MenuHeaderMode::TopFixed,
157            show_root_back_button: false,
158            style: Style::default(),
159            item_style: Style {
160                bg_color: Color(245, 245, 245, 255),
161                ..Style::default()
162            },
163            selected_color: Color(160, 200, 240, 255),
164            header_color: Color(80, 80, 180, 255),
165            text_color: Color(20, 20, 20, 255),
166            separator_color: Color(180, 180, 180, 255),
167            font: WidgetFont::new(),
168        }
169    }
170
171    /// Assign the font used to render this widget (FONT-00 §5); resolves to
172    /// `FONT_6X10` when unset.
173    pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
174        self.font.set(font);
175    }
176
177    // ── Page management ───────────────────────────────────────────────────
178
179    /// Create a new page with the given `title` and return its id.
180    pub fn add_page(&mut self, title: &str) -> MenuPageId {
181        let id = MenuPageId(self.pages.len() as u16);
182        self.pages.push(MenuPage {
183            title: title.into(),
184            items: Vec::new(),
185        });
186        id
187    }
188
189    /// Set the root (initial) page displayed when the stack is empty.
190    pub fn set_root_page(&mut self, id: MenuPageId) {
191        self.root_page = id;
192        // Reset stack so the root is visible immediately
193        self.stack.clear();
194        self.highlight = None;
195    }
196
197    /// Append `item` to `page` and return its id within that page.
198    ///
199    /// The item string data is owned by the widget.
200    pub fn add_item(&mut self, page: MenuPageId, item: MenuItem) -> MenuItemId {
201        let idx = page.0 as usize;
202        if idx >= self.pages.len() {
203            return MenuItemId(u16::MAX);
204        }
205        let item_id = MenuItemId(self.pages[idx].items.len() as u16);
206        self.pages[idx].items.push(item);
207        item_id
208    }
209
210    /// Navigate directly to `id`, pushing it onto the stack.
211    ///
212    /// If `id` is already in the stack, pops back to that position.
213    pub fn set_page(&mut self, id: MenuPageId) {
214        if let Some(pos) = self.stack.iter().position(|&p| p == id) {
215            self.stack.truncate(pos + 1);
216        } else {
217            self.stack.push(id);
218        }
219        self.highlight = None;
220    }
221
222    /// Pop the current page from the stack.
223    ///
224    /// No-op when already at the root page (i.e. `active_page() == root_page`
225    /// and the stack is empty or has the root page at position 0).
226    pub fn back(&mut self) {
227        // If the stack is empty the root page is showing — nothing to pop.
228        if self.stack.is_empty() {
229            return;
230        }
231        // If the visible page is the designated root page, only allow back when
232        // the root back-button is explicitly enabled.
233        let top = *self.stack.last().unwrap();
234        if top == self.root_page && !self.show_root_back_button {
235            return;
236        }
237        self.stack.pop();
238        self.highlight = None;
239    }
240
241    /// Return the id of the currently displayed page.
242    pub fn active_page(&self) -> MenuPageId {
243        self.stack.last().copied().unwrap_or(self.root_page)
244    }
245
246    // ── Header configuration ──────────────────────────────────────────────
247
248    /// Set the position of the header bar.
249    pub fn set_header_mode(&mut self, mode: MenuHeaderMode) {
250        self.header_mode = mode;
251    }
252
253    /// Return the current header mode.
254    pub fn header_mode(&self) -> MenuHeaderMode {
255        self.header_mode
256    }
257
258    /// Enable or disable the back button when on the root page.
259    pub fn set_root_back_button(&mut self, enable: bool) {
260        self.show_root_back_button = enable;
261    }
262
263    /// Return whether the root back button is enabled.
264    pub fn root_back_button(&self) -> bool {
265        self.show_root_back_button
266    }
267
268    // ── Key navigation helpers (LPAR-13 §5.J) ────────────────────────────
269
270    /// Move the highlight to the next navigable item on the current page,
271    /// wrapping at the end.
272    ///
273    /// Wire to `ObjectEvent::Key(Key::ArrowDown)`.
274    pub fn navigate_next(&mut self) {
275        let items = self.current_items();
276        if items.is_empty() {
277            return;
278        }
279        let navigable: Vec<usize> = items
280            .iter()
281            .enumerate()
282            .filter(|(_, it)| it.is_navigable())
283            .map(|(i, _)| i)
284            .collect();
285        if navigable.is_empty() {
286            return;
287        }
288        let next = match self.highlight {
289            None => navigable[0],
290            Some(cur) => {
291                let pos = navigable.iter().position(|&i| i == cur);
292                match pos {
293                    Some(p) => navigable[(p + 1) % navigable.len()],
294                    None => navigable[0],
295                }
296            }
297        };
298        self.highlight = Some(next);
299    }
300
301    /// Move the highlight to the previous navigable item on the current page,
302    /// wrapping at the start.
303    ///
304    /// Wire to `ObjectEvent::Key(Key::ArrowUp)`.
305    pub fn navigate_prev(&mut self) {
306        let items = self.current_items();
307        if items.is_empty() {
308            return;
309        }
310        let navigable: Vec<usize> = items
311            .iter()
312            .enumerate()
313            .filter(|(_, it)| it.is_navigable())
314            .map(|(i, _)| i)
315            .collect();
316        if navigable.is_empty() {
317            return;
318        }
319        let prev = match self.highlight {
320            None => *navigable.last().unwrap(),
321            Some(cur) => {
322                let pos = navigable.iter().position(|&i| i == cur);
323                match pos {
324                    Some(p) => navigable[(p + navigable.len() - 1) % navigable.len()],
325                    None => *navigable.last().unwrap(),
326                }
327            }
328        };
329        self.highlight = Some(prev);
330    }
331
332    /// Activate the currently highlighted item.
333    ///
334    /// - [`MenuItem::SubPage`] — pushes the target page onto the stack.
335    /// - [`MenuItem::Label`] — no structural change (caller polls via `active_page` etc.).
336    /// - [`MenuItem::Separator`] — not reachable by navigation.
337    ///
338    /// Wire to `ObjectEvent::Key(Key::Enter)`.
339    pub fn activate_selected(&mut self) {
340        let Some(hi) = self.highlight else { return };
341        // Inspect the current page's items by index.  We need to clone the
342        // target page id if it's a SubPage to avoid holding an immutable borrow
343        // while calling set_page (which takes &mut self).
344        let maybe_target: Option<MenuPageId> = self
345            .current_page_ref()
346            .and_then(|p| p.items.get(hi))
347            .and_then(|item| {
348                if let MenuItem::SubPage { target, .. } = item {
349                    Some(*target)
350                } else {
351                    None
352                }
353            });
354
355        if let Some(target) = maybe_target {
356            self.stack.push(target);
357            self.highlight = None;
358        }
359    }
360
361    // ── Internal helpers ──────────────────────────────────────────────────
362
363    /// Return the page index for `id`, or `None`.
364    fn page_index(&self, id: MenuPageId) -> Option<usize> {
365        let idx = id.0 as usize;
366        if idx < self.pages.len() {
367            Some(idx)
368        } else {
369            None
370        }
371    }
372
373    /// Return a reference to the current page, if any.
374    fn current_page_ref(&self) -> Option<&MenuPage> {
375        let id = self.active_page();
376        self.page_index(id).map(|i| &self.pages[i])
377    }
378
379    /// Collect a slice of items on the current page (zero-copy borrow).
380    fn current_items(&self) -> &[MenuItem] {
381        self.current_page_ref()
382            .map(|p| p.items.as_slice())
383            .unwrap_or(&[])
384    }
385
386    /// Geometry: header rect based on header mode.
387    fn header_rect(&self) -> Rect {
388        match self.header_mode {
389            MenuHeaderMode::BottomFixed => Rect {
390                x: self.bounds.x,
391                y: self.bounds.y + self.bounds.height - HEADER_HEIGHT,
392                width: self.bounds.width,
393                height: HEADER_HEIGHT,
394            },
395            _ => Rect {
396                x: self.bounds.x,
397                y: self.bounds.y,
398                width: self.bounds.width,
399                height: HEADER_HEIGHT.min(self.bounds.height),
400            },
401        }
402    }
403
404    /// Geometry: item list rect (complement of header).
405    fn list_rect(&self) -> Rect {
406        let hdr = self.header_rect();
407        match self.header_mode {
408            MenuHeaderMode::BottomFixed => Rect {
409                x: self.bounds.x,
410                y: self.bounds.y,
411                width: self.bounds.width,
412                height: (self.bounds.height - HEADER_HEIGHT).max(0),
413            },
414            _ => Rect {
415                x: self.bounds.x,
416                y: hdr.y + hdr.height,
417                width: self.bounds.width,
418                height: (self.bounds.height - hdr.height).max(0),
419            },
420        }
421    }
422}
423
424impl Widget for Menu {
425    fn bounds(&self) -> Rect {
426        self.bounds
427    }
428
429    fn widget_font_mut(&mut self) -> Option<&mut WidgetFont> {
430        Some(&mut self.font)
431    }
432
433    fn set_bounds(&mut self, bounds: Rect) {
434        self.bounds = bounds;
435    }
436
437    fn draw(&self, renderer: &mut dyn Renderer) {
438        let a = self.style.alpha;
439        let font = self.font.resolve();
440
441        // ── Widget background (Part::MAIN) ────────────────────────────────
442        draw_widget_bg(renderer, self.bounds, &self.style);
443
444        // ── Header bar ────────────────────────────────────────────────────
445        let hdr = self.header_rect();
446        if hdr.height > 0 {
447            renderer.fill_rect(hdr, self.header_color.with_alpha(a));
448
449            // Show back button when we can navigate back.
450            let can_go_back = !self.stack.is_empty()
451                && (*self.stack.last().unwrap() != self.root_page || self.show_root_back_button);
452            let is_at_root = !can_go_back;
453            let title_x_offset = if !is_at_root {
454                let back_shaped = shape_text_ltr(font, "<", (hdr.x + 4, hdr.y + 15), 0);
455                renderer.draw_text_shaped(
456                    &back_shaped,
457                    (0, 0),
458                    Color(255, 255, 255, 255).with_alpha(a),
459                );
460                16
461            } else {
462                4
463            };
464
465            // Page title
466            if let Some(page) = self.current_page_ref() {
467                let shaped =
468                    shape_text_ltr(font, &page.title, (hdr.x + title_x_offset, hdr.y + 15), 0);
469                renderer.draw_text_shaped(&shaped, (0, 0), Color(255, 255, 255, 255).with_alpha(a));
470            }
471        }
472
473        // ── Item list (Part::ITEMS / Part::SELECTED) ──────────────────────
474        let lr = self.list_rect();
475        if lr.height <= 0 {
476            return;
477        }
478        draw_widget_bg(renderer, lr, &self.item_style);
479
480        let item_a = self.item_style.alpha;
481        let items = self.current_items();
482        let mut y = lr.y;
483        for (i, item) in items.iter().enumerate() {
484            if y >= lr.y + lr.height {
485                break;
486            }
487            let rh = item.row_height();
488            let row_rect = Rect {
489                x: lr.x,
490                y,
491                width: lr.width,
492                height: rh.min(lr.y + lr.height - y).max(0),
493            };
494
495            match item {
496                MenuItem::Separator => {
497                    // Draw a horizontal line
498                    renderer.fill_rect(
499                        Rect {
500                            x: lr.x + 4,
501                            y: y + rh / 2,
502                            width: lr.width - 8,
503                            height: 1,
504                        },
505                        self.separator_color.with_alpha(item_a),
506                    );
507                }
508                MenuItem::Label(text) | MenuItem::SubPage { label: text, .. } => {
509                    // Selected / highlighted item background
510                    if self.highlight == Some(i) {
511                        renderer.fill_rect(row_rect, self.selected_color.with_alpha(item_a));
512                    }
513
514                    // Item text
515                    let baseline = y + rh - 2;
516                    let shaped = shape_text_ltr(font, text, (lr.x + 6, baseline), 0);
517                    renderer.draw_text_shaped(&shaped, (0, 0), self.text_color.with_alpha(item_a));
518
519                    // Sub-page arrow indicator
520                    if matches!(item, MenuItem::SubPage { .. }) {
521                        let arrow_x = lr.x + lr.width - 10;
522                        let arrow_y = y + rh / 2 - 2;
523                        // Simple ">" drawn as 3 fill_rect calls
524                        for dy in 0..3i32 {
525                            let x_off = if dy == 1 { 3 } else { 0 };
526                            renderer.fill_rect(
527                                Rect {
528                                    x: arrow_x + x_off,
529                                    y: arrow_y + dy,
530                                    width: 1,
531                                    height: 1,
532                                },
533                                self.text_color.with_alpha(item_a),
534                            );
535                        }
536                    }
537                }
538            }
539
540            y += rh;
541        }
542    }
543
544    fn handle_event(&mut self, event: &Event) -> bool {
545        let Event::PressRelease { x, y } = event else {
546            return false;
547        };
548
549        // Click on header back-button area
550        let hdr = self.header_rect();
551        if *x >= hdr.x && *x < hdr.x + 16 && *y >= hdr.y && *y < hdr.y + hdr.height {
552            let can_go_back = !self.stack.is_empty()
553                && (*self.stack.last().unwrap() != self.root_page || self.show_root_back_button);
554            if can_go_back {
555                self.back();
556                return true;
557            }
558        }
559
560        // Click on an item row
561        let lr = self.list_rect();
562        if *x < lr.x || *x >= lr.x + lr.width || *y < lr.y || *y >= lr.y + lr.height {
563            return false;
564        }
565
566        let mut row_y = lr.y;
567        let items = self.current_items();
568        for (i, item) in items.iter().enumerate() {
569            let rh = item.row_height();
570            if *y >= row_y && *y < row_y + rh {
571                if item.is_navigable() {
572                    self.highlight = Some(i);
573                    self.activate_selected();
574                    return true;
575                }
576                return false;
577            }
578            row_y += rh;
579        }
580        false
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587
588    fn rect(x: i32, y: i32, w: i32, h: i32) -> Rect {
589        Rect {
590            x,
591            y,
592            width: w,
593            height: h,
594        }
595    }
596
597    struct NullRenderer;
598    impl rlvgl_core::renderer::Renderer for NullRenderer {
599        fn fill_rect(&mut self, _: Rect, _: Color) {}
600        fn draw_text(&mut self, _: (i32, i32), _: &str, _: Color) {}
601    }
602
603    #[test]
604    fn new_menu_has_no_pages() {
605        let m = Menu::new(rect(0, 0, 200, 300));
606        assert_eq!(m.active_page(), PAGE_NONE);
607    }
608
609    #[test]
610    fn add_page_and_set_root() {
611        let mut m = Menu::new(rect(0, 0, 200, 300));
612        let root = m.add_page("Root");
613        m.set_root_page(root);
614        assert_eq!(m.active_page(), root);
615    }
616
617    #[test]
618    fn items_are_owned_strings() {
619        let mut m = Menu::new(rect(0, 0, 200, 300));
620        let p = m.add_page("P");
621        {
622            let label = alloc::format!("owned {}", 42);
623            m.add_item(p, MenuItem::Label(label));
624        }
625        // Data still accessible after label string dropped
626        m.set_root_page(p);
627        let items = m.current_items();
628        assert_eq!(items.len(), 1);
629        if let MenuItem::Label(ref s) = items[0] {
630            assert_eq!(s, "owned 42");
631        } else {
632            panic!("expected Label");
633        }
634    }
635
636    #[test]
637    fn navigate_next_and_prev_move_highlight() {
638        let mut m = Menu::new(rect(0, 0, 200, 300));
639        let p = m.add_page("P");
640        m.add_item(p, MenuItem::Label("A".into()));
641        m.add_item(p, MenuItem::Separator);
642        m.add_item(p, MenuItem::Label("B".into()));
643        m.set_root_page(p);
644
645        m.navigate_next();
646        assert_eq!(m.highlight, Some(0)); // A
647
648        m.navigate_next();
649        assert_eq!(m.highlight, Some(2)); // B (separator skipped)
650
651        m.navigate_next(); // wrap → A
652        assert_eq!(m.highlight, Some(0));
653
654        m.navigate_prev(); // B
655        assert_eq!(m.highlight, Some(2));
656    }
657
658    #[test]
659    fn activate_sub_page_pushes_stack() {
660        let mut m = Menu::new(rect(0, 0, 200, 300));
661        let root = m.add_page("Root");
662        let child = m.add_page("Child");
663        m.add_item(
664            root,
665            MenuItem::SubPage {
666                label: "Go to child".into(),
667                target: child,
668            },
669        );
670        m.set_root_page(root);
671
672        m.navigate_next();
673        m.activate_selected();
674        assert_eq!(m.active_page(), child);
675    }
676
677    #[test]
678    fn back_pops_to_parent() {
679        let mut m = Menu::new(rect(0, 0, 200, 300));
680        let root = m.add_page("Root");
681        let child = m.add_page("Child");
682        m.add_item(
683            root,
684            MenuItem::SubPage {
685                label: "Child".into(),
686                target: child,
687            },
688        );
689        m.set_root_page(root);
690        m.set_page(child);
691        assert_eq!(m.active_page(), child);
692
693        m.back();
694        // After back the stack should be empty or at root
695        // stack was [child], back pops → stack is empty → active is root_page
696        // Actually set_page pushes: stack = [child]; back pops → stack = []
697        // active_page returns stack.last() or root_page → root
698        assert_eq!(m.active_page(), root);
699    }
700
701    #[test]
702    fn back_is_noop_at_root() {
703        let mut m = Menu::new(rect(0, 0, 200, 300));
704        let root = m.add_page("Root");
705        m.set_root_page(root);
706        m.back(); // should not panic
707        assert_eq!(m.active_page(), root);
708    }
709
710    #[test]
711    fn set_bounds_adopted() {
712        let mut m = Menu::new(rect(0, 0, 200, 300));
713        m.set_bounds(rect(5, 10, 250, 350));
714        assert_eq!(m.bounds(), rect(5, 10, 250, 350));
715    }
716
717    #[test]
718    fn draw_does_not_panic() {
719        let mut m = Menu::new(rect(0, 0, 200, 300));
720        let p = m.add_page("Main");
721        m.add_item(p, MenuItem::Label("Item 1".into()));
722        m.add_item(p, MenuItem::Separator);
723        let child = m.add_page("Settings");
724        m.add_item(
725            p,
726            MenuItem::SubPage {
727                label: "Settings".into(),
728                target: child,
729            },
730        );
731        m.set_root_page(p);
732        let mut r = NullRenderer;
733        m.draw(&mut r);
734    }
735
736    #[test]
737    fn header_mode_bottom_fixed() {
738        let mut m = Menu::new(rect(0, 0, 200, 300));
739        m.set_header_mode(MenuHeaderMode::BottomFixed);
740        assert_eq!(m.header_mode(), MenuHeaderMode::BottomFixed);
741        let hdr = m.header_rect();
742        // Header should be at the bottom
743        assert_eq!(hdr.y + hdr.height, m.bounds.y + m.bounds.height);
744    }
745
746    #[test]
747    fn set_page_navigates_and_back_returns() {
748        let mut m = Menu::new(rect(0, 0, 200, 300));
749        let root = m.add_page("Root");
750        let child = m.add_page("Child");
751        let grandchild = m.add_page("Grandchild");
752        m.set_root_page(root);
753        m.set_page(child);
754        m.set_page(grandchild);
755        assert_eq!(m.active_page(), grandchild);
756        m.back();
757        assert_eq!(m.active_page(), child);
758        m.back();
759        assert_eq!(m.active_page(), root);
760    }
761
762    #[test]
763    fn add_item_oob_page_returns_sentinel() {
764        let mut m = Menu::new(rect(0, 0, 200, 300));
765        let id = m.add_item(PAGE_NONE, MenuItem::Label("X".into()));
766        assert_eq!(id.0, u16::MAX);
767    }
768}