tui_menu/
lib.rs

1use ratatui::{
2    layout::Rect,
3    style::{Color, Style},
4    text::{Line, Span},
5    widgets::{Clear, StatefulWidget, Widget},
6};
7use std::{borrow::Cow, marker::PhantomData};
8
9/// Events this widget produce
10/// Now only emit Selected, may add few in future
11#[derive(Debug)]
12pub enum MenuEvent<T> {
13    /// Item selected, with its data attached
14    Selected(T),
15}
16
17/// The state for menu, keep track of runtime info
18pub struct MenuState<T> {
19    /// stores the menu tree
20    root_item: MenuItem<T>,
21    /// stores events generated in one frame
22    events: Vec<MenuEvent<T>>,
23}
24
25impl<T: Clone> MenuState<T> {
26    /// create with items
27    /// # Example
28    ///
29    /// ```
30    /// use tui_menu::{MenuState, MenuItem};
31    ///
32    /// let state = MenuState::<&'static str>::new(vec![
33    ///     MenuItem::item("Foo", "label_foo"),
34    ///     MenuItem::group("Group", vec![
35    ///         MenuItem::item("Bar 1", "label_bar_1"),
36    ///         MenuItem::item("Bar 2", "label_bar_1"),
37    ///     ])
38    /// ]);
39    /// ```
40    pub fn new(items: Vec<MenuItem<T>>) -> Self {
41        let mut root_item = MenuItem::group("root", items);
42        // the root item marked as always highlight
43        // this makes highlight logic more consistent
44        root_item.is_highlight = true;
45
46        Self {
47            root_item,
48            events: Default::default(),
49        }
50    }
51
52    /// active the menu, this will select the first item
53    ///
54    /// # Example
55    ///
56    /// ```
57    /// use tui_menu::{MenuState, MenuItem};
58    ///
59    /// let mut state = MenuState::<&'static str>::new(vec![
60    ///     MenuItem::item("Foo", "label_foo"),
61    ///     MenuItem::group("Group", vec![
62    ///         MenuItem::item("Bar 1", "label_bar_1"),
63    ///         MenuItem::item("Bar 2", "label_bar_1"),
64    ///     ])
65    /// ]);
66    ///
67    /// state.activate();
68    ///
69    /// assert_eq!(state.highlight().unwrap().data.unwrap(), "label_foo");
70    ///
71    /// ```
72    ///
73    pub fn activate(&mut self) {
74        self.root_item.highlight_next();
75    }
76
77    /// trigger up movement
78    /// NOTE: this action tries to do intuitive movement,
79    /// which means logically it is not consistent, e.g:
80    /// case 1:
81    ///    group 1        group 2        group 3
82    ///                 > sub item 1
83    ///                   sub item 2
84    /// up is pop, which closes the group 2
85    ///
86    /// case 2:
87    ///    group 1        group 2        group 3
88    ///                   sub item 1
89    ///                 > sub item 2
90    /// up is move prev
91    ///
92    /// case 3:
93    ///
94    ///    group 1        group 2   
95    ///                   sub item 1
96    ///                 > sub item 2  > sub sub item 1
97    ///                                 sub sub item 2
98    ///
99    /// up does nothing
100    pub fn up(&mut self) {
101        match self.active_depth() {
102            0 | 1 => {
103                // do nothing
104            }
105            2 => match self
106                .root_item
107                .highlight_child()
108                .and_then(|child| child.highlight_child_index())
109            {
110                // case 1
111                Some(0) => {
112                    self.pop();
113                }
114                _ => {
115                    self.prev();
116                }
117            },
118            _ => {
119                self.prev();
120            }
121        }
122    }
123
124    /// trigger down movement
125    ///
126    /// NOTE: this action tries to do intuitive movement,
127    /// which means logicially it is not consistent, e.g:
128    /// case 1:
129    ///    group 1      > group 2        group 3
130    ///                   sub item 1
131    ///                   sub item 2
132    /// down is enter, which enter the sub group of group 2
133    ///
134    /// case 2:
135    ///    group 1        group 2        group 3
136    ///                   sub item 1
137    ///                 > sub item 2
138    /// down does nothing
139    ///
140    /// case 3:
141    ///    group 1        group 2   
142    ///                 > sub item 1
143    ///                   sub item 2
144    ///
145    /// down highlights "sub item 2"
146    pub fn down(&mut self) {
147        if self.active_depth() == 1 {
148            self.push();
149        } else {
150            self.next();
151        }
152    }
153
154    /// trigger left movement
155    ///
156    /// NOTE: this action tries to do intuitive movement,
157    /// which means logicially it is not consistent, e.g:
158    /// case 1:
159    ///    group 1      > group 2        group 3
160    ///                   sub item 1
161    ///                   sub item 2
162    /// left highlights "group 1"
163    ///
164    /// case 2:
165    ///    group 1        group 2        group 3
166    ///                   sub item 1
167    ///                 > sub item 2
168    /// left first pop "sub item group", then highlights "group 1"
169    ///
170    /// case 3:
171    ///    group 1        group 2   
172    ///                 > sub item 1    sub sub item 1
173    ///                   sub item 2  > sub sub item 2
174    ///
175    /// left pop "sub sub group"
176    pub fn left(&mut self) {
177        if self.active_depth() == 0 {
178            // do nothing
179        } else if self.active_depth() == 1 {
180            self.prev();
181        } else if self.active_depth() == 2 {
182            self.pop();
183            self.prev();
184        } else {
185            self.pop();
186        }
187    }
188
189    /// trigger right movement
190    ///
191    /// NOTE: this action tries to do intuitive movement,
192    /// which means logicially it is not consistent, e.g:
193    /// case 1:
194    ///    group 1      > group 2        group 3
195    ///                   sub item 1
196    ///                   sub item 2
197    /// right highlights "group 3"
198    ///
199    /// case 2:
200    ///    group 1        group 2        group 3
201    ///                   sub item 1
202    ///                 > sub item 2
203    /// right pop group "sub item *", then highlights "group 3"
204    ///
205    /// case 3:
206    ///    group 1        group 2        group 3
207    ///                   sub item 1
208    ///                 > sub item 2 +
209    /// right pushes "sub sub item 2". this differs from case 2 that
210    /// current highlighted item can be expanded
211    pub fn right(&mut self) {
212        if self.active_depth() == 0 {
213            // do nothing
214        } else if self.active_depth() == 1 {
215            self.next();
216        } else if self.active_depth() == 2 {
217            if self.push().is_none() {
218                // special handling, make menu navigation
219                // more productive
220                self.pop();
221                self.next();
222            }
223        } else {
224            self.push();
225        }
226    }
227
228    /// highlight the prev item in current group
229    /// if already the first, then do nothing
230    fn prev(&mut self) {
231        if let Some(item) = self.root_item.highlight_last_but_one() {
232            item.highlight_prev();
233        } else {
234            self.root_item.highlight_prev();
235        }
236    }
237
238    /// highlight the next item in current group
239    /// if already the last, then do nothing
240    fn next(&mut self) {
241        if let Some(item) = self.root_item.highlight_last_but_one() {
242            item.highlight_next();
243        } else {
244            self.root_item.highlight_next();
245        }
246    }
247
248    /// active depth, how many levels dropdown/sub menus expanded.
249    /// when no drop down, it is 1
250    /// one drop down, 2
251    fn active_depth(&self) -> usize {
252        let mut item = self.root_item.highlight_child();
253        let mut depth = 0;
254        while let Some(inner_item) = item {
255            depth += 1;
256            item = inner_item.highlight_child();
257        }
258        depth
259    }
260
261    /// How many dropdown to render, including preview
262    /// NOTE: If current group contains sub-group, in order to keep ui consistent,
263    ///   even the sub-group not selected, its space is counted
264    fn dropdown_count(&self) -> u16 {
265        let mut node = &self.root_item;
266        let mut count = 0;
267        loop {
268            match node.highlight_child() {
269                None => {
270                    return count;
271                }
272                Some(highlight_child) => {
273                    if highlight_child.is_group() {
274                        // highlighted child is a group, then it's children is previewed
275                        count += 1;
276                    } else if node.children.iter().any(|c| c.is_group()) {
277                        // if highlighted item is not a group, but if sibling contains group
278                        // in order to keep ui consistency, also count it
279                        count += 1;
280                    }
281
282                    node = highlight_child;
283                }
284            }
285        }
286    }
287
288    /// select current highlight item, if it has children
289    /// then push
290    pub fn select(&mut self) {
291        if let Some(item) = self.root_item.highlight_mut() {
292            if !item.children.is_empty() {
293                self.push();
294            } else if let Some(ref data) = item.data {
295                self.events.push(MenuEvent::Selected(data.clone()));
296            }
297        }
298    }
299
300    /// dive into sub menu if applicable.
301    /// Return: Some if entered deeper level
302    ///         None if nothing happen
303    pub fn push(&mut self) -> Option<()> {
304        self.root_item.highlight_mut()?.highlight_first_child()
305    }
306
307    /// pop the current menu group. move one layer up
308    pub fn pop(&mut self) {
309        if let Some(item) = self.root_item.highlight_mut() {
310            item.clear_highlight();
311        }
312    }
313
314    /// clear all highlighted items. This is useful
315    /// when the menu bar lose focus
316    pub fn reset(&mut self) {
317        self.root_item
318            .children
319            .iter_mut()
320            .for_each(|c| c.clear_highlight());
321    }
322
323    /// client should drain events each frame, otherwise user action
324    /// will feel laggy
325    pub fn drain_events(&mut self) -> impl Iterator<Item = MenuEvent<T>> {
326        std::mem::take(&mut self.events).into_iter()
327    }
328
329    /// return current highlighted item's reference
330    pub fn highlight(&self) -> Option<&MenuItem<T>> {
331        self.root_item.highlight()
332    }
333}
334
335/// MenuItem is the node in menu tree. If children is not
336/// empty, then this item is the group item.
337pub struct MenuItem<T> {
338    name: Cow<'static, str>,
339    pub data: Option<T>,
340    children: Vec<MenuItem<T>>,
341    is_highlight: bool,
342}
343
344impl<T> MenuItem<T> {
345    /// helper function to create a non group item.
346    pub fn item(name: impl Into<Cow<'static, str>>, data: T) -> Self {
347        Self {
348            name: name.into(),
349            data: Some(data),
350            is_highlight: false,
351            children: vec![],
352        }
353    }
354
355    /// helper function to create a group item.
356    ///
357    /// # Example
358    ///
359    /// ```
360    /// use tui_menu::MenuItem;
361    ///
362    /// let item = MenuItem::<&'static str>::group("group", vec![
363    ///     MenuItem::item("foo", "label_foo"),
364    /// ]);
365    ///
366    /// assert!(item.is_group());
367    ///
368    /// ```
369    pub fn group(name: impl Into<Cow<'static, str>>, children: Vec<Self>) -> Self {
370        Self {
371            name: name.into(),
372            data: None,
373            is_highlight: false,
374            children,
375        }
376    }
377
378    #[cfg(test)]
379    fn with_highlight(mut self, highlight: bool) -> Self {
380        self.is_highlight = highlight;
381        self
382    }
383
384    /// whether this item is group
385    pub fn is_group(&self) -> bool {
386        !self.children.is_empty()
387    }
388
389    /// get current item's name
390    fn name(&self) -> &str {
391        &self.name
392    }
393
394    /// highlight first child
395    fn highlight_first_child(&mut self) -> Option<()> {
396        if !self.children.is_empty() {
397            if let Some(it) = self.children.get_mut(0) {
398                it.is_highlight = true;
399            }
400            Some(())
401        } else {
402            None
403        }
404    }
405
406    /// highlight prev item in this node
407    fn highlight_prev(&mut self) {
408        // if no child selected, then
409        let Some(current_index) = self.highlight_child_index() else {
410            self.highlight_first_child();
411            return;
412        };
413
414        let index_to_highlight = if current_index > 0 {
415            current_index - 1
416        } else {
417            0
418        };
419
420        self.children[current_index].clear_highlight();
421        self.children[index_to_highlight].is_highlight = true;
422    }
423
424    /// highlight prev item in this node
425    fn highlight_next(&mut self) {
426        // if no child selected, then
427        let Some(current_index) = self.highlight_child_index() else {
428            self.highlight_first_child();
429            return;
430        };
431
432        let index_to_highlight = (current_index + 1).min(self.children.len() - 1);
433        self.children[current_index].clear_highlight();
434        self.children[index_to_highlight].is_highlight = true;
435    }
436
437    /// return highlighted child index
438    fn highlight_child_index(&self) -> Option<usize> {
439        for (idx, child) in self.children.iter().enumerate() {
440            if child.is_highlight {
441                return Some(idx);
442            }
443        }
444
445        None
446    }
447
448    /// if any child highlighted, then return its reference
449    fn highlight_child(&self) -> Option<&Self> {
450        self.children.iter().filter(|i| i.is_highlight).nth(0)
451    }
452
453    /// if any child highlighted, then return its reference
454    fn highlight_child_mut(&mut self) -> Option<&mut Self> {
455        self.children.iter_mut().filter(|i| i.is_highlight).nth(0)
456    }
457
458    /// clear is_highlight flag recursively.
459    fn clear_highlight(&mut self) {
460        self.is_highlight = false;
461        for child in self.children.iter_mut() {
462            child.clear_highlight();
463        }
464    }
465
466    /// return deepest highlight item's reference
467    pub fn highlight(&self) -> Option<&Self> {
468        if !self.is_highlight {
469            return None;
470        }
471
472        let mut highlight_item = self;
473        while highlight_item.highlight_child().is_some() {
474            highlight_item = highlight_item.highlight_child().unwrap();
475        }
476
477        Some(highlight_item)
478    }
479
480    /// mut version of highlight
481    fn highlight_mut(&mut self) -> Option<&mut Self> {
482        if !self.is_highlight {
483            return None;
484        }
485
486        let mut highlight_item = self;
487        while highlight_item.highlight_child_mut().is_some() {
488            highlight_item = highlight_item.highlight_child_mut().unwrap();
489        }
490
491        Some(highlight_item)
492    }
493
494    /// last but one layer in highlight
495    fn highlight_last_but_one(&mut self) -> Option<&mut Self> {
496        // if self is not highlighted or there is no highlighted child, return None
497        if !self.is_highlight || self.highlight_child_mut().is_none() {
498            return None;
499        }
500
501        let mut last_but_one = self;
502        while last_but_one
503            .highlight_child_mut()
504            .and_then(|x| x.highlight_child_mut())
505            .is_some()
506        {
507            last_but_one = last_but_one.highlight_child_mut().unwrap();
508        }
509        Some(last_but_one)
510    }
511}
512
513/// Widget focus on display/render
514pub struct Menu<T> {
515    /// style for default item style
516    default_item_style: Style,
517    /// style for highlighted item
518    highlight_item_style: Style,
519    /// width for drop down panel
520    drop_down_width: u16,
521    /// style for drop down panel
522    drop_down_style: Style,
523    _priv: PhantomData<T>,
524}
525
526impl<T> Menu<T> {
527    pub fn new() -> Self {
528        Self {
529            highlight_item_style: Style::default().fg(Color::White).bg(Color::LightBlue),
530            default_item_style: Style::default().fg(Color::White),
531            drop_down_width: 20,
532            drop_down_style: Style::default().bg(Color::DarkGray),
533            _priv: Default::default(),
534        }
535    }
536
537    /// update with highlight style
538    pub fn default_style(mut self, style: Style) -> Self {
539        self.default_item_style = style;
540        self
541    }
542
543    /// update with highlight style
544    pub fn highlight(mut self, style: Style) -> Self {
545        self.highlight_item_style = style;
546        self
547    }
548
549    /// update drop_down_width
550    pub fn dropdown_width(mut self, width: u16) -> Self {
551        self.drop_down_width = width;
552        self
553    }
554
555    /// update drop_down fill style
556    pub fn dropdown_style(mut self, style: Style) -> Self {
557        self.drop_down_style = style;
558        self
559    }
560
561    /// render an item group in drop down
562    fn render_dropdown(
563        &self,
564        x: u16,
565        y: u16,
566        group: &[MenuItem<T>],
567        buf: &mut ratatui::buffer::Buffer,
568        dropdown_count_to_go: u16, // including current, it is not drawn yet
569    ) {
570        // prevent calculation issue if canvas is narrow
571        let drop_down_width = self.drop_down_width.min(buf.area.width);
572
573        // calculate the maximum x, leaving enough space for deeper items
574        // drawing area:
575        // |  a |  b   |            c                |        d       |
576        // | .. |  me  |  child_1  |  child_of_child |  nothing here  |
577        // x_max is the x when d is 0
578        let b_plus_c = dropdown_count_to_go * drop_down_width;
579        let x_max = buf.area().right().saturating_sub(b_plus_c);
580
581        let x = x.min(x_max);
582
583        let area = Rect::new(x, y, drop_down_width, group.len() as u16);
584
585        // clamp to ensure we draw in areas
586        let area = area.clamp(*buf.area());
587
588        Clear.render(area, buf);
589
590        buf.set_style(area, self.drop_down_style);
591
592        let mut active_group: Option<_> = None;
593        for (idx, item) in group.iter().enumerate() {
594            let item_y = y + idx as u16;
595            let is_active = item.is_highlight;
596
597            let item_name = item.name();
598
599            // make style apply to whole line by make name whole line
600            let mut item_name = format!("{: <width$}", item_name, width = drop_down_width as usize);
601
602            if !item.children.is_empty() {
603                item_name.pop();
604                item_name.push('>');
605            }
606
607            buf.set_span(
608                x,
609                item_y,
610                &Span::styled(
611                    item_name,
612                    if is_active {
613                        self.highlight_item_style
614                    } else {
615                        self.default_item_style
616                    },
617                ),
618                drop_down_width,
619            );
620
621            if is_active && !item.children.is_empty() {
622                active_group = Some((x + drop_down_width, item_y, item));
623            }
624        }
625
626        // draw at the end to ensure its content above all items in current level
627        if let Some((x, y, item)) = active_group {
628            self.render_dropdown(x, y, &item.children, buf, dropdown_count_to_go - 1);
629        }
630    }
631}
632
633impl<T> Default for Menu<T> {
634    fn default() -> Self {
635        Self::new()
636    }
637}
638
639impl<T: Clone> StatefulWidget for Menu<T> {
640    type State = MenuState<T>;
641
642    fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer, state: &mut Self::State) {
643        let area = area.clamp(*buf.area());
644
645        let mut spans = vec![];
646        let mut x_pos = area.x;
647        let y_pos = area.y;
648
649        let dropdown_count = state.dropdown_count();
650
651        for (idx, item) in state.root_item.children.iter().enumerate() {
652            let is_highlight = item.is_highlight;
653            let item_style = if is_highlight {
654                self.highlight_item_style
655            } else {
656                self.default_item_style
657            };
658            let has_children = !item.children.is_empty();
659
660            let group_x_pos = x_pos;
661            let span = Span::styled(item.name(), item_style);
662            x_pos += span.width() as u16;
663            spans.push(span);
664
665            if has_children && is_highlight {
666                self.render_dropdown(group_x_pos, y_pos + 1, &item.children, buf, dropdown_count);
667            }
668
669            if idx < state.root_item.children.len() - 1 {
670                let span = Span::raw(" | ");
671                x_pos += span.width() as u16;
672                spans.push(span);
673            }
674        }
675        buf.set_line(area.x, area.y, &Line::from(spans), x_pos);
676    }
677}
678
679#[cfg(test)]
680mod tests {
681    use crate::MenuState;
682
683    type MenuItem = super::MenuItem<i32>;
684
685    #[test]
686    fn test_active_depth() {
687        {
688            let menu_state = MenuState::new(vec![MenuItem::item("item1", 0)]);
689            assert_eq!(menu_state.active_depth(), 0);
690        }
691
692        {
693            let menu_state = MenuState::new(vec![MenuItem::item("item1", 0).with_highlight(true)]);
694            assert_eq!(menu_state.active_depth(), 1);
695        }
696
697        {
698            let menu_state = MenuState::new(vec![MenuItem::group("layer1", vec![])]);
699            assert_eq!(menu_state.active_depth(), 0);
700        }
701
702        {
703            let menu_state =
704                MenuState::new(vec![MenuItem::group("layer1", vec![]).with_highlight(true)]);
705            assert_eq!(menu_state.active_depth(), 1);
706        }
707
708        {
709            let menu_state = MenuState::new(vec![MenuItem::group(
710                "layer_1",
711                vec![MenuItem::item("item_layer_2", 0)],
712            )
713            .with_highlight(true)]);
714            assert_eq!(menu_state.active_depth(), 1);
715        }
716
717        {
718            let menu_state = MenuState::new(vec![MenuItem::group(
719                "layer_1",
720                vec![MenuItem::item("item_layer_2", 0).with_highlight(true)],
721            )
722            .with_highlight(true)]);
723            assert_eq!(menu_state.active_depth(), 2);
724        }
725    }
726
727    #[test]
728    fn test_dropdown_count() {
729        {
730            // only item in menu bar
731            let menu_state = MenuState::new(vec![MenuItem::item("item1", 0)]);
732            assert_eq!(menu_state.dropdown_count(), 0);
733        }
734
735        {
736            // group in menu bar,
737            let menu_state = MenuState::new(vec![MenuItem::group(
738                "menu bar",
739                vec![MenuItem::item("item layer 1", 0)],
740            )
741            .with_highlight(true)]);
742            assert_eq!(menu_state.dropdown_count(), 1);
743        }
744
745        {
746            // group in menu bar,
747            let menu_state = MenuState::new(vec![MenuItem::group(
748                "menu bar 1",
749                vec![
750                    MenuItem::group("dropdown 1", vec![MenuItem::item("item layer 2", 0)])
751                        .with_highlight(true),
752                    MenuItem::item("item layer 1", 0),
753                ],
754            )
755            .with_highlight(true)]);
756            assert_eq!(menu_state.dropdown_count(), 2);
757        }
758
759        {
760            // *menu bar 1
761            // *dropdown 1   >  item layer 2
762            // item layer 1    group layer 2 >
763            let menu_state = MenuState::new(vec![MenuItem::group(
764                "menu bar 1",
765                vec![
766                    MenuItem::group(
767                        "dropdown 1",
768                        vec![
769                            MenuItem::item("item layer 2", 0),
770                            MenuItem::group(
771                                "group layer 2",
772                                vec![MenuItem::item("item layer 3", 0)],
773                            ),
774                        ],
775                    )
776                    .with_highlight(true),
777                    MenuItem::item("item layer 1", 0),
778                ],
779            )
780            .with_highlight(true)]);
781            assert_eq!(menu_state.dropdown_count(), 2);
782        }
783
784        {
785            // *menu bar 1
786            // *dropdown 1   >  *item layer 2
787            // item layer 1    group layer 2 > item layer 3
788            let menu_state = MenuState::new(vec![MenuItem::group(
789                "menu bar 1",
790                vec![
791                    MenuItem::group(
792                        "dropdown 1",
793                        vec![
794                            MenuItem::item("item layer 2", 0).with_highlight(true),
795                            MenuItem::group(
796                                "group layer 2",
797                                vec![MenuItem::item("item layer 3", 0)],
798                            ),
799                        ],
800                    )
801                    .with_highlight(true),
802                    MenuItem::item("item layer 1", 0),
803                ],
804            )
805            .with_highlight(true)]);
806            assert_eq!(menu_state.dropdown_count(), 3);
807        }
808    }
809}