use crate::components::{Box, Text};
use crate::core::{Color, Element, FlexDirection};
#[derive(Debug, Clone)]
pub enum MenuItem {
Action {
id: String,
label: String,
shortcut: Option<String>,
disabled: bool,
icon: Option<String>,
},
Separator,
Submenu {
label: String,
items: Vec<MenuItem>,
},
}
impl MenuItem {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self::Action {
id: id.into(),
label: label.into(),
shortcut: None,
disabled: false,
icon: None,
}
}
pub fn separator() -> Self {
Self::Separator
}
pub fn submenu(label: impl Into<String>, items: Vec<MenuItem>) -> Self {
Self::Submenu {
label: label.into(),
items,
}
}
pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
if let Self::Action {
shortcut: ref mut s,
..
} = self
{
*s = Some(shortcut.into());
}
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
if let Self::Action {
disabled: ref mut d,
..
} = self
{
*d = disabled;
}
self
}
pub fn icon(mut self, icon: impl Into<String>) -> Self {
if let Self::Action {
icon: ref mut i, ..
} = self
{
*i = Some(icon.into());
}
self
}
pub fn is_separator(&self) -> bool {
matches!(self, Self::Separator)
}
pub fn is_submenu(&self) -> bool {
matches!(self, Self::Submenu { .. })
}
pub fn id(&self) -> Option<&str> {
match self {
Self::Action { id, .. } => Some(id),
_ => None,
}
}
pub fn label(&self) -> Option<&str> {
match self {
Self::Action { label, .. } | Self::Submenu { label, .. } => Some(label),
Self::Separator => None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ContextMenuState {
pub open: bool,
pub selected: usize,
pub position: (u16, u16),
}
impl ContextMenuState {
pub fn new() -> Self {
Self::default()
}
pub fn open_at(&mut self, x: u16, y: u16) {
self.open = true;
self.position = (x, y);
self.selected = 0;
}
pub fn close(&mut self) {
self.open = false;
self.selected = 0;
}
pub fn toggle(&mut self) {
self.open = !self.open;
}
pub fn select_prev(&mut self, items: &[MenuItem]) {
let selectable_count = items.iter().filter(|i| !i.is_separator()).count();
if selectable_count == 0 {
return;
}
loop {
if self.selected > 0 {
self.selected -= 1;
} else {
self.selected = items.len() - 1;
}
if !items[self.selected].is_separator() {
break;
}
}
}
pub fn select_next(&mut self, items: &[MenuItem]) {
let selectable_count = items.iter().filter(|i| !i.is_separator()).count();
if selectable_count == 0 {
return;
}
loop {
if self.selected < items.len() - 1 {
self.selected += 1;
} else {
self.selected = 0;
}
if !items[self.selected].is_separator() {
break;
}
}
}
}
#[derive(Debug, Clone)]
pub struct ContextMenuStyle {
pub border_color: Color,
pub background: Color,
pub text_color: Color,
pub selected_bg: Color,
pub selected_fg: Color,
pub disabled_color: Color,
pub shortcut_color: Color,
pub separator_color: Color,
pub width: usize,
pub padding: usize,
}
impl Default for ContextMenuStyle {
fn default() -> Self {
Self {
border_color: Color::White,
background: Color::Black,
text_color: Color::White,
selected_bg: Color::Blue,
selected_fg: Color::White,
disabled_color: Color::BrightBlack,
shortcut_color: Color::BrightBlack,
separator_color: Color::BrightBlack,
width: 30,
padding: 1,
}
}
}
impl ContextMenuStyle {
pub fn new() -> Self {
Self::default()
}
pub fn width(mut self, width: usize) -> Self {
self.width = width;
self
}
pub fn background(mut self, color: Color) -> Self {
self.background = color;
self
}
pub fn selected_bg(mut self, color: Color) -> Self {
self.selected_bg = color;
self
}
}
#[derive(Debug)]
pub struct ContextMenu {
items: Vec<MenuItem>,
state: ContextMenuState,
style: ContextMenuStyle,
}
impl ContextMenu {
pub fn new(items: Vec<MenuItem>) -> Self {
Self {
items,
state: ContextMenuState::new(),
style: ContextMenuStyle::default(),
}
}
pub fn state(mut self, state: ContextMenuState) -> Self {
self.state = state;
self
}
pub fn style(mut self, style: ContextMenuStyle) -> Self {
self.style = style;
self
}
pub fn selected_item(&self) -> Option<&MenuItem> {
self.items.get(self.state.selected)
}
fn render_item(&self, item: &MenuItem, is_selected: bool, index: usize) -> Element {
let padding = " ".repeat(self.style.padding);
match item {
MenuItem::Separator => {
let line = "─".repeat(self.style.width - 2);
Text::new(format!(" {} ", line))
.color(self.style.separator_color)
.into_element()
}
MenuItem::Action {
label,
shortcut,
disabled,
icon,
..
} => {
let mut line = String::new();
if is_selected && index == self.state.selected {
line.push('>');
} else {
line.push(' ');
}
line.push_str(&padding);
if let Some(icon) = icon {
line.push_str(icon);
line.push(' ');
}
line.push_str(label);
if let Some(shortcut) = shortcut {
let current_len = line.len();
let shortcut_space = self
.style
.width
.saturating_sub(current_len + shortcut.len() + 2);
line.push_str(&" ".repeat(shortcut_space));
line.push_str(shortcut);
}
line.push_str(&padding);
if line.len() > self.style.width {
line.truncate(self.style.width - 3);
line.push_str("...");
}
while line.len() < self.style.width {
line.push(' ');
}
let (fg, bg) = if is_selected && index == self.state.selected {
(self.style.selected_fg, self.style.selected_bg)
} else if *disabled {
(self.style.disabled_color, self.style.background)
} else {
(self.style.text_color, self.style.background)
};
Text::new(line).color(fg).background(bg).into_element()
}
MenuItem::Submenu { label, .. } => {
let mut line = String::new();
if is_selected && index == self.state.selected {
line.push('>');
} else {
line.push(' ');
}
line.push_str(&padding);
line.push_str(label);
let arrow = "▶";
let current_len = line.len();
let arrow_space = self.style.width.saturating_sub(current_len + 2);
line.push_str(&" ".repeat(arrow_space));
line.push_str(arrow);
line.push_str(&padding);
let (fg, bg) = if is_selected && index == self.state.selected {
(self.style.selected_fg, self.style.selected_bg)
} else {
(self.style.text_color, self.style.background)
};
Text::new(line).color(fg).background(bg).into_element()
}
}
}
pub fn into_element(self) -> Element {
if !self.state.open {
return Box::new().into_element();
}
let mut container = Box::new().flex_direction(FlexDirection::Column);
let top_border = format!("┌{}┐", "─".repeat(self.style.width - 2));
container = container.child(
Text::new(top_border)
.color(self.style.border_color)
.into_element(),
);
for (i, item) in self.items.iter().enumerate() {
let is_selected = i == self.state.selected;
container = container.child(self.render_item(item, is_selected, i));
}
let bottom_border = format!("└{}┘", "─".repeat(self.style.width - 2));
container = container.child(
Text::new(bottom_border)
.color(self.style.border_color)
.into_element(),
);
container.into_element()
}
}
pub fn context_menu(items: Vec<MenuItem>) -> ContextMenu {
ContextMenu::new(items)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_menu_item_action() {
let item = MenuItem::new("test", "Test Item");
assert_eq!(item.id(), Some("test"));
assert_eq!(item.label(), Some("Test Item"));
assert!(!item.is_separator());
}
#[test]
fn test_menu_item_separator() {
let item = MenuItem::separator();
assert!(item.is_separator());
assert!(item.id().is_none());
}
#[test]
fn test_menu_item_submenu() {
let item = MenuItem::submenu("Edit", vec![MenuItem::new("cut", "Cut")]);
assert!(item.is_submenu());
assert_eq!(item.label(), Some("Edit"));
}
#[test]
fn test_menu_item_builder() {
let item = MenuItem::new("copy", "Copy")
.shortcut("Ctrl+C")
.icon("📋")
.disabled(false);
if let MenuItem::Action {
shortcut,
icon,
disabled,
..
} = item
{
assert_eq!(shortcut, Some("Ctrl+C".to_string()));
assert_eq!(icon, Some("📋".to_string()));
assert!(!disabled);
}
}
#[test]
fn test_context_menu_state() {
let mut state = ContextMenuState::new();
assert!(!state.open);
state.open_at(10, 20);
assert!(state.open);
assert_eq!(state.position, (10, 20));
state.close();
assert!(!state.open);
}
#[test]
fn test_context_menu_state_navigation() {
let items = vec![
MenuItem::new("a", "A"),
MenuItem::separator(),
MenuItem::new("b", "B"),
MenuItem::new("c", "C"),
];
let mut state = ContextMenuState::new();
state.selected = 0;
state.select_next(&items);
assert_eq!(state.selected, 2);
state.select_next(&items);
assert_eq!(state.selected, 3);
state.select_prev(&items);
assert_eq!(state.selected, 2);
}
#[test]
fn test_context_menu_creation() {
let items = vec![MenuItem::new("cut", "Cut"), MenuItem::new("copy", "Copy")];
let menu = ContextMenu::new(items);
assert_eq!(menu.items.len(), 2);
}
#[test]
fn test_context_menu_into_element() {
let items = vec![MenuItem::new("test", "Test")];
let mut state = ContextMenuState::new();
state.open = true;
let menu = ContextMenu::new(items).state(state);
let _ = menu.into_element();
}
#[test]
fn test_context_menu_style() {
let style = ContextMenuStyle::new().width(40).background(Color::Blue);
assert_eq!(style.width, 40);
assert_eq!(style.background, Color::Blue);
}
#[test]
fn test_context_menu_helper() {
let menu = context_menu(vec![MenuItem::new("test", "Test")]);
assert_eq!(menu.items.len(), 1);
}
}