use ratatui::widgets::Paragraph;
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct MenuItem {
label: String,
enabled: bool,
}
impl MenuItem {
pub fn label(&self) -> &str {
&self.label
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn set_label(&mut self, label: impl Into<String>) {
self.label = label.into();
}
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
enabled: true,
}
}
pub fn disabled(label: impl Into<String>) -> Self {
Self {
label: label.into(),
enabled: false,
}
}
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MenuMessage {
Right,
Left,
Select,
SelectIndex(usize),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MenuOutput {
Selected(usize),
SelectionChanged(usize),
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct MenuState {
items: Vec<MenuItem>,
selected_index: Option<usize>,
}
impl MenuState {
pub fn new(items: Vec<MenuItem>) -> Self {
let selected_index = if items.is_empty() { None } else { Some(0) };
Self {
items,
selected_index,
}
}
pub fn items(&self) -> &[MenuItem] {
&self.items
}
pub fn set_items(&mut self, items: Vec<MenuItem>) {
self.items = items;
if self.items.is_empty() {
self.selected_index = None;
} else if self.selected_index.is_none_or(|i| i >= self.items.len()) {
self.selected_index = Some(0);
}
}
pub fn add_item(&mut self, item: MenuItem) {
self.items.push(item);
if self.selected_index.is_none() {
self.selected_index = Some(0);
}
}
pub fn remove_item(&mut self, index: usize) {
if index >= self.items.len() {
return;
}
self.items.remove(index);
if self.items.is_empty() {
self.selected_index = None;
} else if let Some(selected) = self.selected_index {
if selected >= self.items.len() {
self.selected_index = Some(self.items.len() - 1);
}
}
}
pub fn selected_index(&self) -> Option<usize> {
self.selected_index
}
pub fn selected(&self) -> Option<usize> {
self.selected_index()
}
pub fn set_selected(&mut self, index: Option<usize>) {
match index {
Some(i) => {
if !self.items.is_empty() {
self.selected_index = Some(i.min(self.items.len() - 1));
}
}
None => self.selected_index = None,
}
}
pub fn with_selected(mut self, index: usize) -> Self {
self.set_selected(Some(index));
self
}
pub fn selected_item(&self) -> Option<&MenuItem> {
self.items.get(self.selected_index?)
}
pub fn update(&mut self, msg: MenuMessage) -> Option<MenuOutput> {
Menu::update(self, msg)
}
}
pub struct Menu;
impl Component for Menu {
type State = MenuState;
type Message = MenuMessage;
type Output = MenuOutput;
fn init() -> Self::State {
MenuState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
if state.items.is_empty() {
return None;
}
let selected = state.selected_index?;
match msg {
MenuMessage::Right => {
let new_index = (selected + 1) % state.items.len();
state.selected_index = Some(new_index);
Some(MenuOutput::SelectionChanged(new_index))
}
MenuMessage::Left => {
let new_index = if selected == 0 {
state.items.len() - 1
} else {
selected - 1
};
state.selected_index = Some(new_index);
Some(MenuOutput::SelectionChanged(new_index))
}
MenuMessage::Select => {
if let Some(item) = state.items.get(selected) {
if item.is_enabled() {
Some(MenuOutput::Selected(selected))
} else {
None
}
} else {
None
}
}
MenuMessage::SelectIndex(index) => {
if index < state.items.len() && state.selected_index != Some(index) {
state.selected_index = Some(index);
Some(MenuOutput::SelectionChanged(index))
} else {
None
}
}
}
}
fn handle_event(
_state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
if let Some(key) = event.as_key() {
match key.code {
Key::Left => Some(MenuMessage::Left),
Key::Right => Some(MenuMessage::Right),
Key::Enter => Some(MenuMessage::Select),
_ => None,
}
} else {
None
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
let mut menu_text = String::new();
for (idx, item) in state.items.iter().enumerate() {
if idx > 0 {
menu_text.push_str(" ");
}
let item_text = if Some(idx) == state.selected_index && ctx.focused {
format!("[{}]", item.label())
} else {
item.label().to_string()
};
menu_text.push_str(&item_text);
}
let style = if ctx.disabled {
ctx.theme.disabled_style()
} else if ctx.focused {
ctx.theme.focused_style()
} else {
ctx.theme.normal_style()
};
let paragraph = Paragraph::new(menu_text).style(style);
let annotation = crate::annotation::Annotation::new(crate::annotation::WidgetType::Menu)
.with_id("menu")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled);
let annotated = crate::annotation::Annotate::new(paragraph, annotation);
ctx.frame.render_widget(annotated, ctx.area);
}
}
#[cfg(test)]
mod tests;