1use ratatui_core::{
2 buffer::Buffer,
3 layout::Rect,
4 style::{Modifier, Style},
5 widgets::{StatefulWidget, Widget},
6};
7use ratatui_widgets::{block::Block, clear::Clear};
8
9use super::{Menu, MenuBar, MenuItem};
10
11impl Widget for MenuBar {
17 fn render(self, area: Rect, buf: &mut Buffer) {
18 (&self).render(area, buf);
19 }
20}
21
22impl Widget for &MenuBar {
23 fn render(self, area: Rect, buf: &mut Buffer) {
24 if let Some(menu_index) = self.opened_menu {
26 if let Some(menu) = self.menus.get(menu_index) {
27 let dropdown_area = self.calculate_dropdown_area(area, menu_index);
28 self.render_dropdown(menu, dropdown_area, buf);
29 }
30 }
31
32 self.render_menu_bar(area, buf);
34 }
35}
36
37impl MenuBar {
38 fn clear_area(&self, area: Rect, buf: &mut Buffer, style: Style) {
40 for y in area.y..area.y + area.height {
41 for x in area.x..area.x + area.width {
42 buf.set_string(x, y, " ", style);
43 }
44 }
45 }
46
47 fn render_menu_bar(&self, area: Rect, buf: &mut Buffer) {
51 let menu_bar_style = self.theme.menu_bar;
52
53 let menu_bar_area = Rect {
55 x: area.x,
56 y: area.y,
57 width: area.width,
58 height: 1,
59 };
60
61 self.clear_area(menu_bar_area, buf, menu_bar_style);
63
64 let mut x_offset = area.x + 1; for (index, menu) in self.menus.iter().enumerate() {
67 let is_open = self.opened_menu == Some(index);
68 let menu_style = if is_open {
69 self.theme.menu_bar_focused
70 } else {
71 self.theme.menu_bar
72 };
73
74 let menu_text = format!(" {menu_title} ", menu_title = &menu.title);
76 if x_offset + menu_text.len() as u16 <= area.x + area.width {
77 buf.set_string(x_offset, area.y, &menu_text, menu_style);
78 x_offset += menu_text.len() as u16;
79 }
80
81 if index < self.menus.len() - 1 {
83 buf.set_string(x_offset, area.y, " ", menu_bar_style);
84 x_offset += 1;
85 }
86 }
87 }
88
89 fn calculate_dropdown_area(&self, area: Rect, menu_index: usize) -> Rect {
91 let mut x_offset = area.x + 1;
92
93 for (index, menu) in self.menus.iter().enumerate() {
95 if index == menu_index {
96 break;
97 }
98 x_offset += format!(" {menu_title} ", menu_title = &menu.title).len() as u16 + 1;
99 }
100
101 let menu = &self.menus[menu_index];
102
103 let max_item_width = menu
105 .items
106 .iter()
107 .map(|item| match item {
108 MenuItem::Action(action) => {
109 let label_len = action.label.len();
110 let shortcut_len = action.shortcut.as_ref().map_or(0, |s| s.len() + 1);
111 label_len + shortcut_len
112 }
113 MenuItem::SubMenu(submenu) => {
114 submenu.label.len() + 2 }
116 MenuItem::Separator(_) => 0, })
118 .max()
119 .unwrap_or(10) as u16;
120
121 let dropdown_width = (max_item_width + 4).min(40); let dropdown_height = menu.items.len() as u16 + 2; Rect {
125 x: x_offset,
126 y: area.y + 1,
127 width: dropdown_width,
128 height: dropdown_height.min(area.height - 1),
129 }
130 }
131
132 fn calculate_submenu_area(
134 &self,
135 parent_area: Rect,
136 _parent_x: u16,
137 parent_y: u16,
138 submenu: &super::SubMenuItem,
139 ) -> Rect {
140 let max_item_width = submenu
142 .items
143 .iter()
144 .map(|item| match item {
145 MenuItem::Action(action) => {
146 let label_len = action.label.len();
147 let shortcut_len = action.shortcut.as_ref().map_or(0, |s| s.len() + 1);
148 label_len + shortcut_len
149 }
150 MenuItem::SubMenu(sub) => {
151 sub.label.len() + 2 }
153 MenuItem::Separator(_) => 0,
154 })
155 .max()
156 .unwrap_or(10) as u16;
157
158 let submenu_width = (max_item_width + 4).min(30); let submenu_height = submenu.items.len() as u16 + 2; Rect {
164 x: parent_area.x + parent_area.width,
165 y: parent_y,
166 width: submenu_width,
167 height: submenu_height,
168 }
169 }
170
171 fn render_dropdown(&self, menu: &Menu, area: Rect, buf: &mut Buffer) {
176 let dropdown_style = self.theme.dropdown;
177 let border_style = self.theme.dropdown_border;
178
179 Clear.render(area, buf);
181
182 let block = Block::bordered()
184 .border_style(border_style)
185 .style(dropdown_style);
186
187 let content_area = block.inner(area);
189 block.render(area, buf);
190
191 for (index, item) in menu.items.iter().enumerate() {
193 if index as u16 >= content_area.height {
194 break; }
196
197 let y = content_area.y + index as u16;
198 let is_focused = menu.focused_item == Some(index);
199
200 if matches!(item, MenuItem::Separator(_)) {
202 let separator_style = self.theme.separator;
203
204 buf.set_string(area.x, y, "├", separator_style);
206 for x in area.x + 1..area.x + area.width - 1 {
207 buf.set_string(x, y, "─", separator_style);
208 }
209 buf.set_string(area.x + area.width - 1, y, "┤", separator_style);
210 } else {
211 self.render_menu_item(item, content_area.x, y, content_area.width, is_focused, buf);
212
213 if let MenuItem::SubMenu(submenu) = item {
215 if submenu.is_open {
216 let submenu_area =
217 self.calculate_submenu_area(area, content_area.x, y, submenu);
218 self.render_submenu_dropdown(submenu, submenu_area, buf);
219 }
220 }
221 }
222 }
223 }
224
225 fn render_submenu_dropdown(&self, submenu: &super::SubMenuItem, area: Rect, buf: &mut Buffer) {
227 let dropdown_style = self.theme.dropdown;
228 let border_style = self.theme.dropdown_border;
229
230 Clear.render(area, buf);
232
233 let block = Block::bordered()
235 .border_style(border_style)
236 .style(dropdown_style);
237
238 let content_area = block.inner(area);
240 block.render(area, buf);
241
242 for (index, item) in submenu.items.iter().enumerate() {
244 if index as u16 >= content_area.height {
245 break; }
247
248 let y = content_area.y + index as u16;
249 let is_focused = submenu.focused_item == Some(index);
250
251 if matches!(item, MenuItem::Separator(_)) {
253 let separator_style = self.theme.separator;
254
255 buf.set_string(area.x, y, "├", separator_style);
257 for x in area.x + 1..area.x + area.width - 1 {
258 buf.set_string(x, y, "─", separator_style);
259 }
260 buf.set_string(area.x + area.width - 1, y, "┤", separator_style);
261 } else {
262 self.render_menu_item(item, content_area.x, y, content_area.width, is_focused, buf);
263 }
264 }
265 }
266
267 fn render_menu_item(
269 &self,
270 item: &MenuItem,
271 x: u16,
272 y: u16,
273 width: u16,
274 is_focused: bool,
275 buf: &mut Buffer,
276 ) {
277 match item {
278 MenuItem::Action(action) => {
279 let base_style = if is_focused {
280 self.theme.item_focused
281 } else if action.enabled {
282 self.theme.item
283 } else {
284 self.theme.item_disabled
285 };
286
287 self.render_action_item(action, x, y, width, base_style, buf);
288 }
289 MenuItem::Separator(_) => {
290 }
293 MenuItem::SubMenu(submenu) => {
294 let base_style = if is_focused {
295 self.theme.item_focused
296 } else if submenu.enabled {
297 self.theme.item
298 } else {
299 self.theme.item_disabled
300 };
301
302 self.render_submenu_item(submenu, x, y, width, base_style, buf);
303 }
304 }
305 }
306
307 fn render_action_item(
309 &self,
310 action: &super::ActionItem,
311 x: u16,
312 y: u16,
313 width: u16,
314 style: Style,
315 buf: &mut Buffer,
316 ) {
317 let label = &action.label;
318 let shortcut = &action.shortcut;
319
320 for i in 0..width {
322 buf.set_string(x + i, y, " ", style);
323 }
324
325 let mut current_x = x;
327 if let Some(hotkey) = action.hotkey {
328 if let Some(pos) = label
329 .to_lowercase()
330 .find(&hotkey.to_lowercase().to_string())
331 {
332 let before = &label[..pos];
334 buf.set_string(current_x, y, before, style);
335 current_x += before.len() as u16;
336
337 let hotkey_char = &label[pos..pos + 1];
339 let hotkey_style = style.add_modifier(Modifier::UNDERLINED);
340 buf.set_string(current_x, y, hotkey_char, hotkey_style);
341 current_x += 1;
342
343 let after = &label[pos + 1..];
345 buf.set_string(current_x, y, after, style);
346 current_x += after.len() as u16;
347 } else {
348 buf.set_string(current_x, y, label, style);
350 current_x += label.len() as u16;
351 }
352 } else {
353 buf.set_string(current_x, y, label, style);
355 current_x += label.len() as u16;
356 }
357
358 if let Some(shortcut) = shortcut {
360 let shortcut_x = x + width.saturating_sub(shortcut.len() as u16);
361 if shortcut_x > current_x {
362 buf.set_string(shortcut_x, y, shortcut, style);
363 }
364 }
365 }
366
367 fn render_submenu_item(
369 &self,
370 submenu: &super::SubMenuItem,
371 x: u16,
372 y: u16,
373 width: u16,
374 style: Style,
375 buf: &mut Buffer,
376 ) {
377 let label = &submenu.label;
378
379 for i in 0..width {
381 buf.set_string(x + i, y, " ", style);
382 }
383
384 let arrow = "►";
386 let arrow_x = x + width.saturating_sub(2); let mut current_x = x;
390 if let Some(hotkey) = submenu.hotkey {
391 if let Some(pos) = label
392 .to_lowercase()
393 .find(&hotkey.to_lowercase().to_string())
394 {
395 let before = &label[..pos];
397 buf.set_string(current_x, y, before, style);
398 current_x += before.len() as u16;
399
400 let hotkey_char = &label[pos..pos + 1];
402 let hotkey_style = style.add_modifier(Modifier::UNDERLINED);
403 buf.set_string(current_x, y, hotkey_char, hotkey_style);
404 current_x += 1;
405
406 let after = &label[pos + 1..];
408 buf.set_string(current_x, y, after, style);
409 current_x += after.len() as u16;
410 } else {
411 buf.set_string(current_x, y, label, style);
413 current_x += label.len() as u16;
414 }
415 } else {
416 buf.set_string(current_x, y, label, style);
418 current_x += label.len() as u16;
419 }
420
421 if arrow_x > current_x {
423 buf.set_string(arrow_x, y, arrow, style);
424 }
425 }
426}
427
428impl StatefulWidget for MenuBar {
430 type State = ();
431
432 fn render(self, area: Rect, buf: &mut Buffer, _state: &mut Self::State) {
433 Widget::render(self, area, buf);
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use crate::{item, menu, menu_bar};
441 use ratatui::style::{Color, Style};
442 use ratatui_core::buffer::Buffer;
443 use ratatui_core::layout::Rect;
444
445 #[test]
446 fn empty_menu_bar_rendering() {
447 let menu_bar = MenuBar::new();
448 let area = Rect::new(0, 0, 20, 1);
449 let mut buffer = Buffer::empty(area);
450
451 let menu_bar_style = menu_bar.theme.menu_bar;
452 Widget::render(menu_bar, area, &mut buffer);
453
454 let mut expected = Buffer::with_lines([" "]);
456 for x in 0..20 {
457 expected[(x, 0)].set_style(menu_bar_style);
458 }
459 assert_eq!(buffer, expected);
460 }
461
462 #[test]
463 fn menu_bar_with_menus_rendering() {
464 let menu_bar = menu_bar![menu!["File", 'F',], menu!["Edit", 'E',],];
465 let area = Rect::new(0, 0, 15, 1);
466 let mut buffer = Buffer::empty(area);
467
468 let menu_bar_style = menu_bar.theme.menu_bar;
469 Widget::render(menu_bar, area, &mut buffer);
470
471 let mut expected = Buffer::with_lines([" File Edit "]);
473 for x in 0..15 {
475 expected[(x, 0)].set_style(menu_bar_style);
476 }
477 assert_eq!(buffer, expected);
478 }
479
480 #[test]
481 fn menu_bar_with_opened_dropdown() {
482 let mut menu_bar = menu_bar![
483 menu![
484 "File",
485 'F',
486 item![action: "New", command: "file.new"],
487 item![action: "Open", command: "file.open"],
488 item![separator],
489 item![action: "Save", command: "file.save"],
490 item![action: "Exit", command: "file.exit"],
491 ],
492 menu!["Edit", 'E',],
493 ];
494 menu_bar.open_menu(0);
495
496 let area = Rect::new(0, 0, 20, 8);
498 let mut buffer = Buffer::empty(area);
499
500 Widget::render(menu_bar, area, &mut buffer);
501
502 let mut expected = Buffer::with_lines([
504 " File Edit ", " ┌──────┐ ", " │New │ ", " │Open │ ", " ├──────┤ ", " │Save │ ", " │Exit │ ", " └──────┘ ", ]);
513
514 use ratatui_core::style::Style;
516 buffer.set_style(area, Style::reset());
517 expected.set_style(area, Style::reset());
518
519 assert_eq!(buffer, expected);
520 }
521
522 #[test]
523 fn menu_item_display() {
524 let action_item = item![action: "New", command: "file.new"];
525 let separator_item = item![separator];
526
527 assert_eq!(action_item.label(), Some("New"));
529 assert!(action_item.is_selectable());
530
531 assert_eq!(separator_item.label(), None);
533 assert!(!separator_item.is_selectable());
534 }
535
536 #[test]
537 fn dropdown_area_calculation() {
538 let menu_bar = menu_bar![menu!["File", 'F',], menu!["Edit", 'E',],];
539
540 let area = Rect::new(0, 0, 80, 25);
541 let dropdown_area = menu_bar.calculate_dropdown_area(area, 1);
542
543 assert!(dropdown_area.x > 1);
545 assert_eq!(dropdown_area.y, 1);
546 }
547
548 #[test]
549 fn menu_width_calculation() {
550 let short_menu = Menu::new("Hi");
551 let long_menu = Menu::new("File Operations");
552
553 assert!(short_menu.title.len() < long_menu.title.len());
554
555 let menu_bar = menu_bar![Menu::new("Short"), Menu::new("VeryLongMenuTitle"),];
557
558 let area = Rect::new(0, 0, 80, 25);
559 let first_dropdown = menu_bar.calculate_dropdown_area(area, 0);
560 let second_dropdown = menu_bar.calculate_dropdown_area(area, 1);
561
562 assert!(second_dropdown.x > first_dropdown.x);
564 }
565
566 #[test]
567 fn clear_area_functionality() {
568 let menu_bar = MenuBar::new();
569 let area = Rect::new(0, 0, 5, 3);
570 let mut buffer = Buffer::empty(area);
571 let style = Style::default().bg(Color::Red).fg(Color::White);
572
573 let initial_content = buffer[(0, 0)].symbol();
575 assert_eq!(initial_content, " ");
576
577 menu_bar.clear_area(area, &mut buffer, style);
579
580 for y in 0..3 {
582 for x in 0..5 {
583 let cell = &buffer[(x, y)];
584 assert_eq!(cell.symbol(), " ");
585 assert_eq!(cell.bg, Color::Red);
586 assert_eq!(cell.fg, Color::White);
587 }
588 }
589 }
590
591 #[test]
592 fn menu_bar_only_affects_first_line() {
593 use ratatui_core::style::{Color, Style};
594
595 let menu_bar = menu_bar![menu!["File", 'F',], menu!["Edit", 'E',],];
596
597 let area = Rect::new(0, 0, 20, 5);
599 let mut buffer = Buffer::empty(area);
600
601 let existing_style = Style::default().bg(Color::Blue).fg(Color::Yellow);
603 for y in 0..5 {
604 for x in 0..20 {
605 buffer.set_string(x, y, "X", existing_style);
606 }
607 }
608
609 Widget::render(menu_bar, area, &mut buffer);
611
612 for x in 0..20 {
614 let cell = &buffer[(x, 0)];
615 assert_ne!(cell.symbol(), "X");
617 }
618
619 for y in 1..5 {
621 for x in 0..20 {
622 let cell = &buffer[(x, y)];
623 assert_eq!(cell.symbol(), "X");
624 assert_eq!(cell.bg, Color::Blue);
625 assert_eq!(cell.fg, Color::Yellow);
626 }
627 }
628 }
629
630 #[test]
631 fn dropdown_renders_on_top_of_content() {
632 use ratatui_core::style::{Color, Style};
633
634 let mut menu_bar = menu_bar![menu![
635 "File",
636 'F',
637 item![action: "New", command: "file.new"],
638 item![action: "Open", command: "file.open"],
639 ],];
640 menu_bar.open_menu(0);
641
642 let area = Rect::new(0, 0, 15, 6);
644 let mut buffer = Buffer::empty(area);
645
646 let bg_style = Style::default().bg(Color::Red).fg(Color::White);
648 for y in 0..6 {
649 for x in 0..15 {
650 buffer.set_string(x, y, "Z", bg_style);
651 }
652 }
653
654 Widget::render(menu_bar, area, &mut buffer);
656
657 for x in 0..15 {
659 let cell = &buffer[(x, 0)];
660 assert_ne!(cell.symbol(), "Z");
661 }
662
663 for y in 1..4 {
666 for x in 1..8 {
667 let cell = &buffer[(x, y)];
668 assert_ne!(cell.symbol(), "Z");
670 }
671 }
672
673 for x in 10..15 {
675 for y in 1..6 {
676 let cell = &buffer[(x, y)];
677 assert_eq!(cell.symbol(), "Z");
678 assert_eq!(cell.bg, Color::Red);
679 }
680 }
681 }
682
683 #[test]
684 fn separator_styling_test() {
685 use ratatui_core::buffer::Buffer;
686 use ratatui_core::layout::Rect;
687 use ratatui_core::style::Color;
688 use ratatui_core::widgets::Widget;
689
690 let mut menu_bar = menu_bar![menu![
691 "Edit",
692 'E',
693 item![action: "Cut", command: "edit.cut"],
694 item![separator],
695 item![action: "Paste", command: "edit.paste"],
696 ],];
697 menu_bar.open_menu(0);
698
699 let area = Rect::new(0, 0, 20, 8);
700 let mut buffer = Buffer::empty(area);
701 Widget::render(menu_bar, area, &mut buffer);
702
703 let separator_y = 3;
706
707 let mut dropdown_x = 0;
710 let mut dropdown_width = 0;
711
712 for x in 0..20 {
714 if buffer[(x, 1)].symbol() == "┌" {
715 dropdown_x = x;
717 break;
718 }
719 }
720
721 for x in dropdown_x + 1..20 {
723 if buffer[(x, 1)].symbol() == "┐" {
724 dropdown_width = x - dropdown_x + 1;
726 break;
727 }
728 }
729
730 let left_cell = &buffer[(dropdown_x, separator_y)];
732 assert_eq!(left_cell.symbol(), "├");
733 assert_eq!(left_cell.bg, Color::Blue);
734 assert_eq!(left_cell.fg, Color::White);
735
736 let middle_cell = &buffer[(dropdown_x + 1, separator_y)];
738 assert_eq!(middle_cell.symbol(), "─");
739 assert_eq!(middle_cell.bg, Color::Blue);
740 assert_eq!(middle_cell.fg, Color::White);
741
742 let right_cell = &buffer[(dropdown_x + dropdown_width - 1, separator_y)];
744 assert_eq!(right_cell.symbol(), "┤");
745 assert_eq!(right_cell.bg, Color::Blue);
746 assert_eq!(right_cell.fg, Color::White);
747 }
748
749 #[test]
750 fn hotkey_underlining_test() {
751 use ratatui_core::buffer::Buffer;
752 use ratatui_core::layout::Rect;
753 use ratatui_core::style::Modifier;
754 use ratatui_core::widgets::Widget;
755
756 let mut menu_bar = menu_bar![menu![
757 "File",
758 'F',
759 item![action: "New File", command: "file.new", hotkey: 'N'],
760 item![action: "Open", command: "file.open", hotkey: 'O'],
761 ],];
762 menu_bar.open_menu(0);
763
764 let area = Rect::new(0, 0, 20, 6);
765 let mut buffer = Buffer::empty(area);
766 Widget::render(menu_bar, area, &mut buffer);
767
768 let mut found_underlined_n = false;
771 for y in 0..6 {
772 for x in 0..20 {
773 let cell = &buffer[(x, y)];
774 if cell.symbol() == "N" && cell.modifier.contains(Modifier::UNDERLINED) {
775 found_underlined_n = true;
776 break;
777 }
778 }
779 }
780 assert!(
781 found_underlined_n,
782 "Expected to find underlined 'N' in 'New File'"
783 );
784
785 let mut found_underlined_o = false;
787 for y in 0..6 {
788 for x in 0..20 {
789 let cell = &buffer[(x, y)];
790 if cell.symbol() == "O" && cell.modifier.contains(Modifier::UNDERLINED) {
791 found_underlined_o = true;
792 break;
793 }
794 }
795 }
796 assert!(
797 found_underlined_o,
798 "Expected to find underlined 'O' in 'Open'"
799 );
800 }
801
802 #[test]
803 fn selection_visibility_test() {
804 use ratatui_core::buffer::Buffer;
805 use ratatui_core::layout::Rect;
806 use ratatui_core::style::Color;
807 use ratatui_core::widgets::Widget;
808
809 let mut menu_bar = menu_bar![menu![
810 "File",
811 'F',
812 item![action: "New", command: "file.new"],
813 item![action: "Open", command: "file.open"],
814 ],];
815 menu_bar.open_menu(0);
816
817 if let Some(menu) = menu_bar.menus.get_mut(0) {
819 menu.focused_item = Some(0);
820 }
821
822 let area = Rect::new(0, 0, 15, 6);
823 let mut buffer = Buffer::empty(area);
824 Widget::render(menu_bar, area, &mut buffer);
825
826 let mut found_white_bg = false;
828 for y in 0..6 {
829 for x in 0..15 {
830 let cell = &buffer[(x, y)];
831 if cell.bg == Color::White && cell.symbol() != " " {
832 found_white_bg = true;
833 break;
834 }
835 }
836 }
837 assert!(
838 found_white_bg,
839 "Expected to find white background for focused item"
840 );
841
842 let mut found_blue_bg = false;
844 for y in 0..6 {
845 for x in 0..15 {
846 let cell = &buffer[(x, y)];
847 if cell.bg == Color::Blue {
848 found_blue_bg = true;
849 break;
850 }
851 }
852 }
853 assert!(
854 found_blue_bg,
855 "Expected to find blue background for non-focused areas"
856 );
857 }
858
859 #[test]
860 fn submenu_rendering_test() {
861 let mut menu_bar = menu_bar![menu![
862 "View",
863 'V',
864 item![submenu: "Theme", items: [
865 item![action: "Light", command: "light", hotkey: 'L'],
866 item![action: "Dark", command: "dark", hotkey: 'D']
867 ], hotkey: 'T']
868 ]];
869
870 menu_bar.open_menu(0);
872 if let Some(menu) = menu_bar.opened_menu_mut() {
873 menu.focused_item = Some(0); if let Some(MenuItem::SubMenu(submenu)) = menu.items.get_mut(0) {
875 submenu.is_open = true;
876 submenu.focused_item = Some(0); }
878 }
879
880 let mut buffer = Buffer::empty(Rect::new(0, 0, 40, 10));
881 Widget::render(&menu_bar, Rect::new(0, 0, 40, 10), &mut buffer);
882
883 let content = buffer.content();
885 let rendered = content.iter().map(|cell| cell.symbol()).collect::<String>();
886
887 assert!(
888 rendered.contains("Light"),
889 "Should contain 'Light' theme option"
890 );
891 assert!(
892 rendered.contains("Dark"),
893 "Should contain 'Dark' theme option"
894 );
895 }
896}