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