use ratatui_core::{
buffer::Buffer,
layout::{Margin, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{StatefulWidget, Widget},
};
use ratatui_widgets::{block::Block, borders::Borders, clear::Clear};
use std::{borrow::Cow, marker::PhantomData};
#[derive(Debug)]
pub enum MenuEvent<T> {
Selected(T),
}
pub struct MenuState<T> {
root_item: MenuItem<T>,
events: Vec<MenuEvent<T>>,
}
impl<T: Clone> MenuState<T> {
pub fn new(items: Vec<MenuItem<T>>) -> Self {
let mut root_item = MenuItem::group("root", items);
root_item.is_highlight = true;
Self {
root_item,
events: Default::default(),
}
}
pub fn activate(&mut self) {
self.root_item.highlight_next();
}
pub fn is_active(&self) -> bool {
self.root_item.highlight().is_some()
}
pub fn up(&mut self) {
match self.active_depth() {
0 | 1 => {
}
2 => match self
.root_item
.highlight_child()
.and_then(|child| child.highlight_child_index())
{
Some(0) => {
self.pop();
}
_ => {
self.prev();
}
},
_ => {
self.prev();
}
}
}
pub fn down(&mut self) {
if self.active_depth() == 1 {
self.push();
} else {
self.next();
}
}
pub fn left(&mut self) {
if self.active_depth() == 0 {
} else if self.active_depth() == 1 {
self.prev();
} else if self.active_depth() == 2 {
self.pop();
self.prev();
} else {
self.pop();
}
}
pub fn right(&mut self) {
if self.active_depth() == 0 {
} else if self.active_depth() == 1 {
self.next();
} else if self.active_depth() == 2 {
if self.push().is_none() {
self.pop();
self.next();
}
} else {
self.push();
}
}
fn prev(&mut self) {
if let Some(item) = self.root_item.highlight_last_but_one() {
item.highlight_prev();
} else {
self.root_item.highlight_prev();
}
}
fn next(&mut self) {
if let Some(item) = self.root_item.highlight_last_but_one() {
item.highlight_next();
} else {
self.root_item.highlight_next();
}
}
fn active_depth(&self) -> usize {
let mut item = self.root_item.highlight_child();
let mut depth = 0;
while let Some(inner_item) = item {
depth += 1;
item = inner_item.highlight_child();
}
depth
}
fn dropdown_count(&self) -> u16 {
let mut node = &self.root_item;
let mut count = 0;
loop {
match node.highlight_child() {
None => {
return count;
}
Some(highlight_child) => {
if highlight_child.is_group() {
count += 1;
} else if node.children.iter().any(|c| c.is_group()) {
count += 1;
}
node = highlight_child;
}
}
}
}
pub fn select(&mut self) {
if let Some(item) = self.root_item.highlight_mut() {
if !item.children.is_empty() {
self.push();
} else if let Some(ref data) = item.data {
self.events.push(MenuEvent::Selected(data.clone()));
}
}
}
pub fn push(&mut self) -> Option<()> {
self.root_item.highlight_mut()?.highlight_first_child()
}
pub fn pop(&mut self) {
if let Some(item) = self.root_item.highlight_mut() {
item.clear_highlight();
}
}
pub fn reset(&mut self) {
self.root_item
.children
.iter_mut()
.for_each(|c| c.clear_highlight());
}
pub fn drain_events(&mut self) -> impl Iterator<Item = MenuEvent<T>> {
std::mem::take(&mut self.events).into_iter()
}
pub fn highlight(&self) -> Option<&MenuItem<T>> {
self.root_item.highlight()
}
}
pub struct MenuItem<T> {
name: Cow<'static, str>,
pub data: Option<T>,
children: Vec<MenuItem<T>>,
is_highlight: bool,
}
impl<T> MenuItem<T> {
pub fn item(name: impl Into<Cow<'static, str>>, data: T) -> Self {
Self {
name: name.into(),
data: Some(data),
is_highlight: false,
children: vec![],
}
}
pub fn group(name: impl Into<Cow<'static, str>>, children: Vec<Self>) -> Self {
Self {
name: name.into(),
data: None,
is_highlight: false,
children,
}
}
#[cfg(test)]
fn with_highlight(mut self, highlight: bool) -> Self {
self.is_highlight = highlight;
self
}
pub fn is_group(&self) -> bool {
!self.children.is_empty()
}
fn name(&self) -> &str {
&self.name
}
fn highlight_first_child(&mut self) -> Option<()> {
if !self.children.is_empty() {
if let Some(it) = self.children.get_mut(0) {
it.is_highlight = true;
}
Some(())
} else {
None
}
}
fn highlight_prev(&mut self) {
let Some(current_index) = self.highlight_child_index() else {
self.highlight_first_child();
return;
};
let index_to_highlight = if current_index > 0 {
current_index - 1
} else {
0
};
self.children[current_index].clear_highlight();
self.children[index_to_highlight].is_highlight = true;
}
fn highlight_next(&mut self) {
let Some(current_index) = self.highlight_child_index() else {
self.highlight_first_child();
return;
};
let index_to_highlight = (current_index + 1).min(self.children.len() - 1);
self.children[current_index].clear_highlight();
self.children[index_to_highlight].is_highlight = true;
}
fn highlight_child_index(&self) -> Option<usize> {
for (idx, child) in self.children.iter().enumerate() {
if child.is_highlight {
return Some(idx);
}
}
None
}
fn highlight_child(&self) -> Option<&Self> {
self.children.iter().filter(|i| i.is_highlight).nth(0)
}
fn highlight_child_mut(&mut self) -> Option<&mut Self> {
self.children.iter_mut().filter(|i| i.is_highlight).nth(0)
}
fn clear_highlight(&mut self) {
self.is_highlight = false;
for child in self.children.iter_mut() {
child.clear_highlight();
}
}
pub fn highlight(&self) -> Option<&Self> {
if !self.is_highlight {
return None;
}
let mut highlight_item = self;
while highlight_item.highlight_child().is_some() {
highlight_item = highlight_item.highlight_child().unwrap();
}
Some(highlight_item)
}
fn highlight_mut(&mut self) -> Option<&mut Self> {
if !self.is_highlight {
return None;
}
let mut highlight_item = self;
while highlight_item.highlight_child_mut().is_some() {
highlight_item = highlight_item.highlight_child_mut().unwrap();
}
Some(highlight_item)
}
fn highlight_last_but_one(&mut self) -> Option<&mut Self> {
if !self.is_highlight || self.highlight_child_mut().is_none() {
return None;
}
let mut last_but_one = self;
while last_but_one
.highlight_child_mut()
.and_then(|x| x.highlight_child_mut())
.is_some()
{
last_but_one = last_but_one.highlight_child_mut().unwrap();
}
Some(last_but_one)
}
}
pub struct Menu<T> {
default_item_style: Style,
highlight_item_style: Style,
drop_down_width: u16,
drop_down_style: Style,
_priv: PhantomData<T>,
}
impl<T> Menu<T> {
pub fn new() -> Self {
Self {
highlight_item_style: Style::default().fg(Color::White).bg(Color::LightBlue),
default_item_style: Style::default().fg(Color::White),
drop_down_width: 20,
drop_down_style: Style::default().bg(Color::DarkGray),
_priv: Default::default(),
}
}
pub fn default_style(mut self, style: Style) -> Self {
self.default_item_style = style;
self
}
pub fn highlight(mut self, style: Style) -> Self {
self.highlight_item_style = style;
self
}
pub fn dropdown_width(mut self, width: u16) -> Self {
self.drop_down_width = width;
self
}
pub fn dropdown_style(mut self, style: Style) -> Self {
self.drop_down_style = style;
self
}
fn render_dropdown(
&self,
x: u16,
y: u16,
group: &[MenuItem<T>],
buf: &mut Buffer,
dropdown_count_to_go: u16, ) {
let child_max_width = group
.iter()
.map(|menu_item| Span::from(menu_item.name.clone()).width())
.max()
.unwrap_or(0) as u16;
let min_drop_down_width: u16 = child_max_width + 3 + 3;
let min_drop_down_height: u16 = (group.len() as u16) + 1 + 1;
let drop_down_width = self.drop_down_width.min(buf.area.width);
let b_plus_c = dropdown_count_to_go * drop_down_width;
let x_max = buf.area().right().saturating_sub(b_plus_c);
let x = x.min(x_max);
let area = Rect::new(x, y, min_drop_down_width, min_drop_down_height);
let area = area.clamp(*buf.area());
Clear.render(area, buf);
buf.set_style(area, self.default_item_style);
let border = Block::default()
.borders(Borders::ALL)
.style(self.default_item_style);
border.render(
area.inner(Margin {
vertical: 0,
horizontal: 1,
}),
buf,
);
let mut active_group: Option<_> = None;
for (idx, item) in group.iter().enumerate() {
let item_x = x + 2;
let item_y = y + 1 + idx as u16;
let is_active = item.is_highlight;
let item_name = item.name();
let mut item_name =
format!(" {: <width$} ", item_name, width = child_max_width as usize);
if !item.children.is_empty() {
item_name.pop();
item_name.push('>');
}
buf.set_span(
item_x,
item_y,
&Span::styled(
item_name,
if is_active {
self.highlight_item_style
} else {
self.default_item_style
},
),
child_max_width + 2,
);
if is_active && !item.children.is_empty() {
active_group = Some((item_x + child_max_width, item_y, item));
}
}
if let Some((x, y, item)) = active_group {
self.render_dropdown(x, y, &item.children, buf, dropdown_count_to_go - 1);
}
}
}
impl<T> Default for Menu<T> {
fn default() -> Self {
Self::new()
}
}
impl<T: Clone> StatefulWidget for Menu<T> {
type State = MenuState<T>;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let area = area.clamp(*buf.area());
let mut spans = vec![];
let mut x_pos = area.x;
let y_pos = area.y;
let dropdown_count = state.dropdown_count();
spans.push(Span::raw(" ").style(self.default_item_style));
for item in state.root_item.children.iter() {
let is_highlight = item.is_highlight;
let item_style = if is_highlight {
self.highlight_item_style
} else {
self.default_item_style
};
let has_children = !item.children.is_empty();
let group_x_pos = x_pos;
let span = Span::styled(format!(" {} ", item.name()), item_style);
x_pos += span.width() as u16;
spans.push(span);
if has_children && is_highlight {
self.render_dropdown(group_x_pos, y_pos + 1, &item.children, buf, dropdown_count);
}
}
buf.set_line(area.x, area.y, &Line::from(spans), area.width);
}
}
#[cfg(test)]
mod tests {
use crate::MenuState;
type MenuItem = super::MenuItem<i32>;
#[test]
fn test_active_depth() {
{
let menu_state = MenuState::new(vec![MenuItem::item("item1", 0)]);
assert_eq!(menu_state.active_depth(), 0);
}
{
let menu_state = MenuState::new(vec![MenuItem::item("item1", 0).with_highlight(true)]);
assert_eq!(menu_state.active_depth(), 1);
}
{
let menu_state = MenuState::new(vec![MenuItem::group("layer1", vec![])]);
assert_eq!(menu_state.active_depth(), 0);
}
{
let menu_state =
MenuState::new(vec![MenuItem::group("layer1", vec![]).with_highlight(true)]);
assert_eq!(menu_state.active_depth(), 1);
}
{
let menu_state = MenuState::new(vec![MenuItem::group(
"layer_1",
vec![MenuItem::item("item_layer_2", 0)],
)
.with_highlight(true)]);
assert_eq!(menu_state.active_depth(), 1);
}
{
let menu_state = MenuState::new(vec![MenuItem::group(
"layer_1",
vec![MenuItem::item("item_layer_2", 0).with_highlight(true)],
)
.with_highlight(true)]);
assert_eq!(menu_state.active_depth(), 2);
}
}
#[test]
fn test_dropdown_count() {
{
let menu_state = MenuState::new(vec![MenuItem::item("item1", 0)]);
assert_eq!(menu_state.dropdown_count(), 0);
}
{
let menu_state = MenuState::new(vec![MenuItem::group(
"menu bar",
vec![MenuItem::item("item layer 1", 0)],
)
.with_highlight(true)]);
assert_eq!(menu_state.dropdown_count(), 1);
}
{
let menu_state = MenuState::new(vec![MenuItem::group(
"menu bar 1",
vec![
MenuItem::group("dropdown 1", vec![MenuItem::item("item layer 2", 0)])
.with_highlight(true),
MenuItem::item("item layer 1", 0),
],
)
.with_highlight(true)]);
assert_eq!(menu_state.dropdown_count(), 2);
}
{
let menu_state = MenuState::new(vec![MenuItem::group(
"menu bar 1",
vec![
MenuItem::group(
"dropdown 1",
vec![
MenuItem::item("item layer 2", 0),
MenuItem::group(
"group layer 2",
vec![MenuItem::item("item layer 3", 0)],
),
],
)
.with_highlight(true),
MenuItem::item("item layer 1", 0),
],
)
.with_highlight(true)]);
assert_eq!(menu_state.dropdown_count(), 2);
}
{
let menu_state = MenuState::new(vec![MenuItem::group(
"menu bar 1",
vec![
MenuItem::group(
"dropdown 1",
vec![
MenuItem::item("item layer 2", 0).with_highlight(true),
MenuItem::group(
"group layer 2",
vec![MenuItem::item("item layer 3", 0)],
),
],
)
.with_highlight(true),
MenuItem::item("item layer 1", 0),
],
)
.with_highlight(true)]);
assert_eq!(menu_state.dropdown_count(), 3);
}
}
}