1use 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
35const ROW_HEIGHT: i32 = 16;
37const HEADER_HEIGHT: i32 = 22;
39const SEPARATOR_HEIGHT: i32 = 4;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct MenuPageId(pub u16);
50
51pub const PAGE_NONE: MenuPageId = MenuPageId(u16::MAX);
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct MenuItemId(pub u16);
59
60#[derive(Debug, Clone)]
64pub enum MenuItem {
65 Label(String),
67 SubPage {
69 label: String,
71 target: MenuPageId,
73 },
74 Separator,
76}
77
78impl MenuItem {
79 fn is_navigable(&self) -> bool {
81 !matches!(self, MenuItem::Separator)
82 }
83
84 fn row_height(&self) -> i32 {
86 match self {
87 MenuItem::Separator => SEPARATOR_HEIGHT,
88 _ => ROW_HEIGHT,
89 }
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum MenuHeaderMode {
98 TopFixed,
100 TopUnfixed,
103 BottomFixed,
105}
106
107struct MenuPage {
110 title: String,
111 items: Vec<MenuItem>,
112}
113
114pub struct Menu {
120 bounds: Rect,
121 pages: Vec<MenuPage>,
122 stack: Vec<MenuPageId>,
124 root_page: MenuPageId,
126 highlight: Option<usize>,
128 header_mode: MenuHeaderMode,
129 show_root_back_button: bool,
130 pub style: Style,
132 pub item_style: Style,
134 pub selected_color: Color,
136 pub header_color: Color,
138 pub text_color: Color,
140 pub separator_color: Color,
142 font: WidgetFont,
145}
146
147impl Menu {
148 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 pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
174 self.font.set(font);
175 }
176
177 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 pub fn set_root_page(&mut self, id: MenuPageId) {
191 self.root_page = id;
192 self.stack.clear();
194 self.highlight = None;
195 }
196
197 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 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 pub fn back(&mut self) {
227 if self.stack.is_empty() {
229 return;
230 }
231 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 pub fn active_page(&self) -> MenuPageId {
243 self.stack.last().copied().unwrap_or(self.root_page)
244 }
245
246 pub fn set_header_mode(&mut self, mode: MenuHeaderMode) {
250 self.header_mode = mode;
251 }
252
253 pub fn header_mode(&self) -> MenuHeaderMode {
255 self.header_mode
256 }
257
258 pub fn set_root_back_button(&mut self, enable: bool) {
260 self.show_root_back_button = enable;
261 }
262
263 pub fn root_back_button(&self) -> bool {
265 self.show_root_back_button
266 }
267
268 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 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 pub fn activate_selected(&mut self) {
340 let Some(hi) = self.highlight else { return };
341 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 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 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 fn current_items(&self) -> &[MenuItem] {
381 self.current_page_ref()
382 .map(|p| p.items.as_slice())
383 .unwrap_or(&[])
384 }
385
386 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 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 draw_widget_bg(renderer, self.bounds, &self.style);
443
444 let hdr = self.header_rect();
446 if hdr.height > 0 {
447 renderer.fill_rect(hdr, self.header_color.with_alpha(a));
448
449 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 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 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 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 if self.highlight == Some(i) {
511 renderer.fill_rect(row_rect, self.selected_color.with_alpha(item_a));
512 }
513
514 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 if matches!(item, MenuItem::SubPage { .. }) {
521 let arrow_x = lr.x + lr.width - 10;
522 let arrow_y = y + rh / 2 - 2;
523 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 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 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 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)); m.navigate_next();
649 assert_eq!(m.highlight, Some(2)); m.navigate_next(); assert_eq!(m.highlight, Some(0));
653
654 m.navigate_prev(); 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 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(); 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 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}