use alloc::{string::String, vec::Vec};
use rlvgl_core::draw::draw_widget_bg;
use rlvgl_core::event::Event;
use rlvgl_core::font::{FontMetrics, WidgetFont, shape_text_ltr};
use rlvgl_core::renderer::Renderer;
use rlvgl_core::style::Style;
use rlvgl_core::widget::{Color, Rect, Widget};
const ROW_HEIGHT: i32 = 16;
const HEADER_HEIGHT: i32 = 22;
const SEPARATOR_HEIGHT: i32 = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MenuPageId(pub u16);
pub const PAGE_NONE: MenuPageId = MenuPageId(u16::MAX);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MenuItemId(pub u16);
#[derive(Debug, Clone)]
pub enum MenuItem {
Label(String),
SubPage {
label: String,
target: MenuPageId,
},
Separator,
}
impl MenuItem {
fn is_navigable(&self) -> bool {
!matches!(self, MenuItem::Separator)
}
fn row_height(&self) -> i32 {
match self {
MenuItem::Separator => SEPARATOR_HEIGHT,
_ => ROW_HEIGHT,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MenuHeaderMode {
TopFixed,
TopUnfixed,
BottomFixed,
}
struct MenuPage {
title: String,
items: Vec<MenuItem>,
}
pub struct Menu {
bounds: Rect,
pages: Vec<MenuPage>,
stack: Vec<MenuPageId>,
root_page: MenuPageId,
highlight: Option<usize>,
header_mode: MenuHeaderMode,
show_root_back_button: bool,
pub style: Style,
pub item_style: Style,
pub selected_color: Color,
pub header_color: Color,
pub text_color: Color,
pub separator_color: Color,
font: WidgetFont,
}
impl Menu {
pub fn new(bounds: Rect) -> Self {
Self {
bounds,
pages: Vec::new(),
stack: Vec::new(),
root_page: PAGE_NONE,
highlight: None,
header_mode: MenuHeaderMode::TopFixed,
show_root_back_button: false,
style: Style::default(),
item_style: Style {
bg_color: Color(245, 245, 245, 255),
..Style::default()
},
selected_color: Color(160, 200, 240, 255),
header_color: Color(80, 80, 180, 255),
text_color: Color(20, 20, 20, 255),
separator_color: Color(180, 180, 180, 255),
font: WidgetFont::new(),
}
}
pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
self.font.set(font);
}
pub fn add_page(&mut self, title: &str) -> MenuPageId {
let id = MenuPageId(self.pages.len() as u16);
self.pages.push(MenuPage {
title: title.into(),
items: Vec::new(),
});
id
}
pub fn set_root_page(&mut self, id: MenuPageId) {
self.root_page = id;
self.stack.clear();
self.highlight = None;
}
pub fn add_item(&mut self, page: MenuPageId, item: MenuItem) -> MenuItemId {
let idx = page.0 as usize;
if idx >= self.pages.len() {
return MenuItemId(u16::MAX);
}
let item_id = MenuItemId(self.pages[idx].items.len() as u16);
self.pages[idx].items.push(item);
item_id
}
pub fn set_page(&mut self, id: MenuPageId) {
if let Some(pos) = self.stack.iter().position(|&p| p == id) {
self.stack.truncate(pos + 1);
} else {
self.stack.push(id);
}
self.highlight = None;
}
pub fn back(&mut self) {
if self.stack.is_empty() {
return;
}
let top = *self.stack.last().unwrap();
if top == self.root_page && !self.show_root_back_button {
return;
}
self.stack.pop();
self.highlight = None;
}
pub fn active_page(&self) -> MenuPageId {
self.stack.last().copied().unwrap_or(self.root_page)
}
pub fn set_header_mode(&mut self, mode: MenuHeaderMode) {
self.header_mode = mode;
}
pub fn header_mode(&self) -> MenuHeaderMode {
self.header_mode
}
pub fn set_root_back_button(&mut self, enable: bool) {
self.show_root_back_button = enable;
}
pub fn root_back_button(&self) -> bool {
self.show_root_back_button
}
pub fn navigate_next(&mut self) {
let items = self.current_items();
if items.is_empty() {
return;
}
let navigable: Vec<usize> = items
.iter()
.enumerate()
.filter(|(_, it)| it.is_navigable())
.map(|(i, _)| i)
.collect();
if navigable.is_empty() {
return;
}
let next = match self.highlight {
None => navigable[0],
Some(cur) => {
let pos = navigable.iter().position(|&i| i == cur);
match pos {
Some(p) => navigable[(p + 1) % navigable.len()],
None => navigable[0],
}
}
};
self.highlight = Some(next);
}
pub fn navigate_prev(&mut self) {
let items = self.current_items();
if items.is_empty() {
return;
}
let navigable: Vec<usize> = items
.iter()
.enumerate()
.filter(|(_, it)| it.is_navigable())
.map(|(i, _)| i)
.collect();
if navigable.is_empty() {
return;
}
let prev = match self.highlight {
None => *navigable.last().unwrap(),
Some(cur) => {
let pos = navigable.iter().position(|&i| i == cur);
match pos {
Some(p) => navigable[(p + navigable.len() - 1) % navigable.len()],
None => *navigable.last().unwrap(),
}
}
};
self.highlight = Some(prev);
}
pub fn activate_selected(&mut self) {
let Some(hi) = self.highlight else { return };
let maybe_target: Option<MenuPageId> = self
.current_page_ref()
.and_then(|p| p.items.get(hi))
.and_then(|item| {
if let MenuItem::SubPage { target, .. } = item {
Some(*target)
} else {
None
}
});
if let Some(target) = maybe_target {
self.stack.push(target);
self.highlight = None;
}
}
fn page_index(&self, id: MenuPageId) -> Option<usize> {
let idx = id.0 as usize;
if idx < self.pages.len() {
Some(idx)
} else {
None
}
}
fn current_page_ref(&self) -> Option<&MenuPage> {
let id = self.active_page();
self.page_index(id).map(|i| &self.pages[i])
}
fn current_items(&self) -> &[MenuItem] {
self.current_page_ref()
.map(|p| p.items.as_slice())
.unwrap_or(&[])
}
fn header_rect(&self) -> Rect {
match self.header_mode {
MenuHeaderMode::BottomFixed => Rect {
x: self.bounds.x,
y: self.bounds.y + self.bounds.height - HEADER_HEIGHT,
width: self.bounds.width,
height: HEADER_HEIGHT,
},
_ => Rect {
x: self.bounds.x,
y: self.bounds.y,
width: self.bounds.width,
height: HEADER_HEIGHT.min(self.bounds.height),
},
}
}
fn list_rect(&self) -> Rect {
let hdr = self.header_rect();
match self.header_mode {
MenuHeaderMode::BottomFixed => Rect {
x: self.bounds.x,
y: self.bounds.y,
width: self.bounds.width,
height: (self.bounds.height - HEADER_HEIGHT).max(0),
},
_ => Rect {
x: self.bounds.x,
y: hdr.y + hdr.height,
width: self.bounds.width,
height: (self.bounds.height - hdr.height).max(0),
},
}
}
}
impl Widget for Menu {
fn bounds(&self) -> Rect {
self.bounds
}
fn widget_font_mut(&mut self) -> Option<&mut WidgetFont> {
Some(&mut self.font)
}
fn set_bounds(&mut self, bounds: Rect) {
self.bounds = bounds;
}
fn draw(&self, renderer: &mut dyn Renderer) {
let a = self.style.alpha;
let font = self.font.resolve();
draw_widget_bg(renderer, self.bounds, &self.style);
let hdr = self.header_rect();
if hdr.height > 0 {
renderer.fill_rect(hdr, self.header_color.with_alpha(a));
let can_go_back = !self.stack.is_empty()
&& (*self.stack.last().unwrap() != self.root_page || self.show_root_back_button);
let is_at_root = !can_go_back;
let title_x_offset = if !is_at_root {
let back_shaped = shape_text_ltr(font, "<", (hdr.x + 4, hdr.y + 15), 0);
renderer.draw_text_shaped(
&back_shaped,
(0, 0),
Color(255, 255, 255, 255).with_alpha(a),
);
16
} else {
4
};
if let Some(page) = self.current_page_ref() {
let shaped =
shape_text_ltr(font, &page.title, (hdr.x + title_x_offset, hdr.y + 15), 0);
renderer.draw_text_shaped(&shaped, (0, 0), Color(255, 255, 255, 255).with_alpha(a));
}
}
let lr = self.list_rect();
if lr.height <= 0 {
return;
}
draw_widget_bg(renderer, lr, &self.item_style);
let item_a = self.item_style.alpha;
let items = self.current_items();
let mut y = lr.y;
for (i, item) in items.iter().enumerate() {
if y >= lr.y + lr.height {
break;
}
let rh = item.row_height();
let row_rect = Rect {
x: lr.x,
y,
width: lr.width,
height: rh.min(lr.y + lr.height - y).max(0),
};
match item {
MenuItem::Separator => {
renderer.fill_rect(
Rect {
x: lr.x + 4,
y: y + rh / 2,
width: lr.width - 8,
height: 1,
},
self.separator_color.with_alpha(item_a),
);
}
MenuItem::Label(text) | MenuItem::SubPage { label: text, .. } => {
if self.highlight == Some(i) {
renderer.fill_rect(row_rect, self.selected_color.with_alpha(item_a));
}
let baseline = y + rh - 2;
let shaped = shape_text_ltr(font, text, (lr.x + 6, baseline), 0);
renderer.draw_text_shaped(&shaped, (0, 0), self.text_color.with_alpha(item_a));
if matches!(item, MenuItem::SubPage { .. }) {
let arrow_x = lr.x + lr.width - 10;
let arrow_y = y + rh / 2 - 2;
for dy in 0..3i32 {
let x_off = if dy == 1 { 3 } else { 0 };
renderer.fill_rect(
Rect {
x: arrow_x + x_off,
y: arrow_y + dy,
width: 1,
height: 1,
},
self.text_color.with_alpha(item_a),
);
}
}
}
}
y += rh;
}
}
fn handle_event(&mut self, event: &Event) -> bool {
let Event::PressRelease { x, y } = event else {
return false;
};
let hdr = self.header_rect();
if *x >= hdr.x && *x < hdr.x + 16 && *y >= hdr.y && *y < hdr.y + hdr.height {
let can_go_back = !self.stack.is_empty()
&& (*self.stack.last().unwrap() != self.root_page || self.show_root_back_button);
if can_go_back {
self.back();
return true;
}
}
let lr = self.list_rect();
if *x < lr.x || *x >= lr.x + lr.width || *y < lr.y || *y >= lr.y + lr.height {
return false;
}
let mut row_y = lr.y;
let items = self.current_items();
for (i, item) in items.iter().enumerate() {
let rh = item.row_height();
if *y >= row_y && *y < row_y + rh {
if item.is_navigable() {
self.highlight = Some(i);
self.activate_selected();
return true;
}
return false;
}
row_y += rh;
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rect(x: i32, y: i32, w: i32, h: i32) -> Rect {
Rect {
x,
y,
width: w,
height: h,
}
}
struct NullRenderer;
impl rlvgl_core::renderer::Renderer for NullRenderer {
fn fill_rect(&mut self, _: Rect, _: Color) {}
fn draw_text(&mut self, _: (i32, i32), _: &str, _: Color) {}
}
#[test]
fn new_menu_has_no_pages() {
let m = Menu::new(rect(0, 0, 200, 300));
assert_eq!(m.active_page(), PAGE_NONE);
}
#[test]
fn add_page_and_set_root() {
let mut m = Menu::new(rect(0, 0, 200, 300));
let root = m.add_page("Root");
m.set_root_page(root);
assert_eq!(m.active_page(), root);
}
#[test]
fn items_are_owned_strings() {
let mut m = Menu::new(rect(0, 0, 200, 300));
let p = m.add_page("P");
{
let label = alloc::format!("owned {}", 42);
m.add_item(p, MenuItem::Label(label));
}
m.set_root_page(p);
let items = m.current_items();
assert_eq!(items.len(), 1);
if let MenuItem::Label(ref s) = items[0] {
assert_eq!(s, "owned 42");
} else {
panic!("expected Label");
}
}
#[test]
fn navigate_next_and_prev_move_highlight() {
let mut m = Menu::new(rect(0, 0, 200, 300));
let p = m.add_page("P");
m.add_item(p, MenuItem::Label("A".into()));
m.add_item(p, MenuItem::Separator);
m.add_item(p, MenuItem::Label("B".into()));
m.set_root_page(p);
m.navigate_next();
assert_eq!(m.highlight, Some(0));
m.navigate_next();
assert_eq!(m.highlight, Some(2));
m.navigate_next(); assert_eq!(m.highlight, Some(0));
m.navigate_prev(); assert_eq!(m.highlight, Some(2));
}
#[test]
fn activate_sub_page_pushes_stack() {
let mut m = Menu::new(rect(0, 0, 200, 300));
let root = m.add_page("Root");
let child = m.add_page("Child");
m.add_item(
root,
MenuItem::SubPage {
label: "Go to child".into(),
target: child,
},
);
m.set_root_page(root);
m.navigate_next();
m.activate_selected();
assert_eq!(m.active_page(), child);
}
#[test]
fn back_pops_to_parent() {
let mut m = Menu::new(rect(0, 0, 200, 300));
let root = m.add_page("Root");
let child = m.add_page("Child");
m.add_item(
root,
MenuItem::SubPage {
label: "Child".into(),
target: child,
},
);
m.set_root_page(root);
m.set_page(child);
assert_eq!(m.active_page(), child);
m.back();
assert_eq!(m.active_page(), root);
}
#[test]
fn back_is_noop_at_root() {
let mut m = Menu::new(rect(0, 0, 200, 300));
let root = m.add_page("Root");
m.set_root_page(root);
m.back(); assert_eq!(m.active_page(), root);
}
#[test]
fn set_bounds_adopted() {
let mut m = Menu::new(rect(0, 0, 200, 300));
m.set_bounds(rect(5, 10, 250, 350));
assert_eq!(m.bounds(), rect(5, 10, 250, 350));
}
#[test]
fn draw_does_not_panic() {
let mut m = Menu::new(rect(0, 0, 200, 300));
let p = m.add_page("Main");
m.add_item(p, MenuItem::Label("Item 1".into()));
m.add_item(p, MenuItem::Separator);
let child = m.add_page("Settings");
m.add_item(
p,
MenuItem::SubPage {
label: "Settings".into(),
target: child,
},
);
m.set_root_page(p);
let mut r = NullRenderer;
m.draw(&mut r);
}
#[test]
fn header_mode_bottom_fixed() {
let mut m = Menu::new(rect(0, 0, 200, 300));
m.set_header_mode(MenuHeaderMode::BottomFixed);
assert_eq!(m.header_mode(), MenuHeaderMode::BottomFixed);
let hdr = m.header_rect();
assert_eq!(hdr.y + hdr.height, m.bounds.y + m.bounds.height);
}
#[test]
fn set_page_navigates_and_back_returns() {
let mut m = Menu::new(rect(0, 0, 200, 300));
let root = m.add_page("Root");
let child = m.add_page("Child");
let grandchild = m.add_page("Grandchild");
m.set_root_page(root);
m.set_page(child);
m.set_page(grandchild);
assert_eq!(m.active_page(), grandchild);
m.back();
assert_eq!(m.active_page(), child);
m.back();
assert_eq!(m.active_page(), root);
}
#[test]
fn add_item_oob_page_returns_sentinel() {
let mut m = Menu::new(rect(0, 0, 200, 300));
let id = m.add_item(PAGE_NONE, MenuItem::Label("X".into()));
assert_eq!(id.0, u16::MAX);
}
}