1use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
42use ratatui::{
43 Frame,
44 layout::Rect,
45 style::{Color, Style},
46 text::{Line, Span},
47 widgets::{Block, Borders, Clear, Paragraph},
48};
49
50use crate::traits::ClickRegion;
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum MenuBarAction {
55 MenuOpen(usize),
57 MenuClose,
59 ItemSelect(String),
61 HighlightChange(usize, Option<usize>),
63 SubmenuOpen(usize, usize),
65 SubmenuClose,
67}
68
69#[derive(Debug, Clone)]
71pub enum MenuBarItem {
72 Action {
74 id: String,
76 label: String,
78 shortcut: Option<String>,
80 enabled: bool,
82 },
83 Separator,
85 Submenu {
87 label: String,
89 items: Vec<MenuBarItem>,
91 enabled: bool,
93 },
94}
95
96impl MenuBarItem {
97 pub fn action(id: impl Into<String>, label: impl Into<String>) -> Self {
99 Self::Action {
100 id: id.into(),
101 label: label.into(),
102 shortcut: None,
103 enabled: true,
104 }
105 }
106
107 pub fn separator() -> Self {
109 Self::Separator
110 }
111
112 pub fn submenu(label: impl Into<String>, items: Vec<MenuBarItem>) -> Self {
114 Self::Submenu {
115 label: label.into(),
116 items,
117 enabled: true,
118 }
119 }
120
121 pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
123 if let Self::Action { shortcut: s, .. } = &mut self {
124 *s = Some(shortcut.into());
125 }
126 self
127 }
128
129 pub fn enabled(mut self, enabled: bool) -> Self {
131 match &mut self {
132 Self::Action { enabled: e, .. } => *e = enabled,
133 Self::Submenu { enabled: e, .. } => *e = enabled,
134 Self::Separator => {}
135 }
136 self
137 }
138
139 pub fn is_selectable(&self) -> bool {
141 match self {
142 Self::Action { enabled, .. } => *enabled,
143 Self::Separator => false,
144 Self::Submenu { enabled, .. } => *enabled,
145 }
146 }
147
148 pub fn has_submenu(&self) -> bool {
150 matches!(self, Self::Submenu { .. })
151 }
152
153 pub fn id(&self) -> Option<&str> {
155 if let Self::Action { id, .. } = self {
156 Some(id)
157 } else {
158 None
159 }
160 }
161
162 pub fn label(&self) -> Option<&str> {
164 match self {
165 Self::Action { label, .. } => Some(label),
166 Self::Submenu { label, .. } => Some(label),
167 Self::Separator => None,
168 }
169 }
170
171 pub fn get_shortcut(&self) -> Option<&str> {
173 if let Self::Action { shortcut, .. } = self {
174 shortcut.as_deref()
175 } else {
176 None
177 }
178 }
179
180 pub fn is_enabled(&self) -> bool {
182 match self {
183 Self::Action { enabled, .. } => *enabled,
184 Self::Separator => false,
185 Self::Submenu { enabled, .. } => *enabled,
186 }
187 }
188
189 pub fn submenu_items(&self) -> Option<&[MenuBarItem]> {
191 if let Self::Submenu { items, .. } = self {
192 Some(items)
193 } else {
194 None
195 }
196 }
197}
198
199#[derive(Debug, Clone)]
201pub struct Menu {
202 pub label: String,
204 pub items: Vec<MenuBarItem>,
206 pub enabled: bool,
208}
209
210impl Menu {
211 pub fn new(label: impl Into<String>) -> Self {
213 Self {
214 label: label.into(),
215 items: Vec::new(),
216 enabled: true,
217 }
218 }
219
220 pub fn items(mut self, items: Vec<MenuBarItem>) -> Self {
222 self.items = items;
223 self
224 }
225
226 pub fn enabled(mut self, enabled: bool) -> Self {
228 self.enabled = enabled;
229 self
230 }
231}
232
233#[derive(Debug, Clone)]
235pub struct MenuBarState {
236 pub is_open: bool,
238 pub active_menu: usize,
240 pub highlighted_item: Option<usize>,
242 pub scroll_offset: u16,
244 pub focused: bool,
246 pub active_submenu: Option<usize>,
248 pub submenu_highlighted: Option<usize>,
250 pub submenu_scroll_offset: u16,
252}
253
254impl Default for MenuBarState {
255 fn default() -> Self {
256 Self::new()
257 }
258}
259
260impl MenuBarState {
261 pub fn new() -> Self {
263 Self {
264 is_open: false,
265 active_menu: 0,
266 highlighted_item: None,
267 scroll_offset: 0,
268 focused: false,
269 active_submenu: None,
270 submenu_highlighted: None,
271 submenu_scroll_offset: 0,
272 }
273 }
274
275 pub fn open_menu(&mut self, index: usize) {
277 self.is_open = true;
278 self.active_menu = index;
279 self.highlighted_item = None;
280 self.scroll_offset = 0;
281 self.close_submenu();
282 }
283
284 pub fn close_menu(&mut self) {
286 self.is_open = false;
287 self.highlighted_item = None;
288 self.scroll_offset = 0;
289 self.close_submenu();
290 }
291
292 pub fn toggle_menu(&mut self, index: usize) {
294 if self.is_open && self.active_menu == index {
295 self.close_menu();
296 } else {
297 self.open_menu(index);
298 }
299 }
300
301 pub fn next_menu(&mut self, menu_count: usize) {
303 if menu_count == 0 {
304 return;
305 }
306 self.active_menu = (self.active_menu + 1) % menu_count;
307 if self.is_open {
308 self.highlighted_item = None;
309 self.scroll_offset = 0;
310 self.close_submenu();
311 }
312 }
313
314 pub fn prev_menu(&mut self, menu_count: usize) {
316 if menu_count == 0 {
317 return;
318 }
319 if self.active_menu == 0 {
320 self.active_menu = menu_count - 1;
321 } else {
322 self.active_menu -= 1;
323 }
324 if self.is_open {
325 self.highlighted_item = None;
326 self.scroll_offset = 0;
327 self.close_submenu();
328 }
329 }
330
331 pub fn next_item(&mut self, items: &[MenuBarItem]) {
333 if items.is_empty() {
334 return;
335 }
336
337 let current = self.highlighted_item.unwrap_or(0);
338 let mut new_index = current;
339
340 loop {
341 new_index += 1;
342 if new_index >= items.len() {
343 new_index = 0;
345 }
346 if new_index == current {
347 break;
349 }
350 if items.get(new_index).is_some_and(|i| i.is_selectable()) {
351 self.highlighted_item = Some(new_index);
352 break;
353 }
354 }
355 }
356
357 pub fn prev_item(&mut self, items: &[MenuBarItem]) {
359 if items.is_empty() {
360 return;
361 }
362
363 let current = self.highlighted_item.unwrap_or(0);
364 let mut new_index = current;
365
366 loop {
367 if new_index == 0 {
368 new_index = items.len() - 1;
369 } else {
370 new_index -= 1;
371 }
372 if new_index == current {
373 break;
375 }
376 if items.get(new_index).is_some_and(|i| i.is_selectable()) {
377 self.highlighted_item = Some(new_index);
378 break;
379 }
380 }
381 }
382
383 pub fn highlight_first(&mut self, items: &[MenuBarItem]) {
385 for (i, item) in items.iter().enumerate() {
386 if item.is_selectable() {
387 self.highlighted_item = Some(i);
388 self.scroll_offset = 0;
389 break;
390 }
391 }
392 }
393
394 pub fn highlight_last(&mut self, items: &[MenuBarItem]) {
396 for (i, item) in items.iter().enumerate().rev() {
397 if item.is_selectable() {
398 self.highlighted_item = Some(i);
399 break;
400 }
401 }
402 }
403
404 pub fn select_item(&mut self, index: usize) {
406 self.highlighted_item = Some(index);
407 }
408
409 pub fn open_submenu(&mut self) {
411 if let Some(idx) = self.highlighted_item {
412 self.active_submenu = Some(idx);
413 self.submenu_highlighted = None;
414 self.submenu_scroll_offset = 0;
415 }
416 }
417
418 pub fn close_submenu(&mut self) {
420 self.active_submenu = None;
421 self.submenu_highlighted = None;
422 self.submenu_scroll_offset = 0;
423 }
424
425 pub fn has_open_submenu(&self) -> bool {
427 self.active_submenu.is_some()
428 }
429
430 pub fn next_submenu_item(&mut self, items: &[MenuBarItem]) {
432 if items.is_empty() {
433 return;
434 }
435
436 let current = self.submenu_highlighted.unwrap_or(0);
437 let mut new_index = current;
438
439 loop {
440 new_index += 1;
441 if new_index >= items.len() {
442 new_index = 0;
443 }
444 if new_index == current {
445 break;
446 }
447 if items.get(new_index).is_some_and(|i| i.is_selectable()) {
448 self.submenu_highlighted = Some(new_index);
449 break;
450 }
451 }
452 }
453
454 pub fn prev_submenu_item(&mut self, items: &[MenuBarItem]) {
456 if items.is_empty() {
457 return;
458 }
459
460 let current = self.submenu_highlighted.unwrap_or(0);
461 let mut new_index = current;
462
463 loop {
464 if new_index == 0 {
465 new_index = items.len() - 1;
466 } else {
467 new_index -= 1;
468 }
469 if new_index == current {
470 break;
471 }
472 if items.get(new_index).is_some_and(|i| i.is_selectable()) {
473 self.submenu_highlighted = Some(new_index);
474 break;
475 }
476 }
477 }
478
479 pub fn ensure_visible(&mut self, viewport_height: usize) {
481 if viewport_height == 0 {
482 return;
483 }
484 if let Some(idx) = self.highlighted_item {
485 if idx < self.scroll_offset as usize {
486 self.scroll_offset = idx as u16;
487 } else if idx >= self.scroll_offset as usize + viewport_height {
488 self.scroll_offset = (idx - viewport_height + 1) as u16;
489 }
490 }
491 }
492}
493
494#[derive(Debug, Clone)]
496pub struct MenuBarStyle {
497 pub bar_bg: Color,
499 pub bar_fg: Color,
501 pub bar_highlight_bg: Color,
503 pub bar_highlight_fg: Color,
505 pub dropdown_bg: Color,
507 pub dropdown_border: Color,
509 pub item_fg: Color,
511 pub item_highlight_bg: Color,
513 pub item_highlight_fg: Color,
515 pub shortcut_fg: Color,
517 pub disabled_fg: Color,
519 pub separator_fg: Color,
521 pub dropdown_min_width: u16,
523 pub dropdown_max_height: u16,
525 pub menu_padding: u16,
527 pub dropdown_padding: u16,
529 pub submenu_indicator: &'static str,
531 pub separator_char: char,
533}
534
535impl Default for MenuBarStyle {
536 fn default() -> Self {
537 Self {
538 bar_bg: Color::Rgb(50, 50, 50),
539 bar_fg: Color::White,
540 bar_highlight_bg: Color::Rgb(70, 70, 70),
541 bar_highlight_fg: Color::White,
542 dropdown_bg: Color::Rgb(40, 40, 40),
543 dropdown_border: Color::Rgb(80, 80, 80),
544 item_fg: Color::White,
545 item_highlight_bg: Color::Rgb(60, 100, 180),
546 item_highlight_fg: Color::White,
547 shortcut_fg: Color::Rgb(140, 140, 140),
548 disabled_fg: Color::DarkGray,
549 separator_fg: Color::Rgb(80, 80, 80),
550 dropdown_min_width: 15,
551 dropdown_max_height: 15,
552 menu_padding: 2,
553 dropdown_padding: 1,
554 submenu_indicator: "▶",
555 separator_char: '─',
556 }
557 }
558}
559
560impl MenuBarStyle {
561 pub fn light() -> Self {
563 Self {
564 bar_bg: Color::Rgb(240, 240, 240),
565 bar_fg: Color::Rgb(30, 30, 30),
566 bar_highlight_bg: Color::Rgb(200, 200, 200),
567 bar_highlight_fg: Color::Rgb(30, 30, 30),
568 dropdown_bg: Color::Rgb(250, 250, 250),
569 dropdown_border: Color::Rgb(180, 180, 180),
570 item_fg: Color::Rgb(30, 30, 30),
571 item_highlight_bg: Color::Rgb(0, 120, 215),
572 item_highlight_fg: Color::White,
573 shortcut_fg: Color::Rgb(100, 100, 100),
574 disabled_fg: Color::Rgb(160, 160, 160),
575 separator_fg: Color::Rgb(200, 200, 200),
576 ..Default::default()
577 }
578 }
579
580 pub fn minimal() -> Self {
582 Self {
583 bar_bg: Color::Reset,
584 bar_fg: Color::White,
585 bar_highlight_bg: Color::Blue,
586 bar_highlight_fg: Color::White,
587 dropdown_bg: Color::Reset,
588 dropdown_border: Color::Gray,
589 item_fg: Color::White,
590 item_highlight_bg: Color::Blue,
591 item_highlight_fg: Color::White,
592 shortcut_fg: Color::Gray,
593 disabled_fg: Color::DarkGray,
594 separator_fg: Color::DarkGray,
595 ..Default::default()
596 }
597 }
598
599 pub fn bar_colors(mut self, fg: Color, bg: Color) -> Self {
601 self.bar_fg = fg;
602 self.bar_bg = bg;
603 self
604 }
605
606 pub fn bar_highlight(mut self, fg: Color, bg: Color) -> Self {
608 self.bar_highlight_fg = fg;
609 self.bar_highlight_bg = bg;
610 self
611 }
612
613 pub fn dropdown_colors(mut self, fg: Color, bg: Color, border: Color) -> Self {
615 self.item_fg = fg;
616 self.dropdown_bg = bg;
617 self.dropdown_border = border;
618 self
619 }
620
621 pub fn item_highlight(mut self, fg: Color, bg: Color) -> Self {
623 self.item_highlight_fg = fg;
624 self.item_highlight_bg = bg;
625 self
626 }
627
628 pub fn dropdown_min_width(mut self, width: u16) -> Self {
630 self.dropdown_min_width = width;
631 self
632 }
633
634 pub fn dropdown_max_height(mut self, height: u16) -> Self {
636 self.dropdown_max_height = height;
637 self
638 }
639
640 pub fn submenu_indicator(mut self, indicator: &'static str) -> Self {
642 self.submenu_indicator = indicator;
643 self
644 }
645}
646
647#[derive(Debug, Clone, PartialEq, Eq)]
649pub enum MenuBarClickTarget {
650 MenuLabel(usize),
652 DropdownItem(usize),
654 SubmenuItem(usize),
656}
657
658pub struct MenuBar<'a> {
662 menus: &'a [Menu],
663 state: &'a MenuBarState,
664 style: MenuBarStyle,
665}
666
667impl<'a> MenuBar<'a> {
668 pub fn new(menus: &'a [Menu], state: &'a MenuBarState) -> Self {
670 Self {
671 menus,
672 state,
673 style: MenuBarStyle::default(),
674 }
675 }
676
677 pub fn style(mut self, style: MenuBarStyle) -> Self {
679 self.style = style;
680 self
681 }
682
683 fn calculate_dropdown_width(&self, items: &[MenuBarItem]) -> u16 {
685 let mut max_label_width = 0u16;
686 let mut max_shortcut_width = 0u16;
687
688 for item in items {
689 match item {
690 MenuBarItem::Action {
691 label, shortcut, ..
692 } => {
693 max_label_width = max_label_width.max(label.chars().count() as u16);
694 if let Some(s) = shortcut {
695 max_shortcut_width = max_shortcut_width.max(s.chars().count() as u16);
696 }
697 }
698 MenuBarItem::Submenu { label, .. } => {
699 let label_width = label.chars().count() as u16 + 2;
701 max_label_width = max_label_width.max(label_width);
702 }
703 MenuBarItem::Separator => {}
704 }
705 }
706
707 let content_width = self.style.dropdown_padding
709 + max_label_width
710 + if max_shortcut_width > 0 {
711 2 + max_shortcut_width
712 } else {
713 0
714 }
715 + self.style.dropdown_padding;
716
717 (content_width + 2).max(self.style.dropdown_min_width)
718 }
719
720 fn calculate_dropdown_height(&self, item_count: usize) -> u16 {
722 let visible = (item_count as u16).min(self.style.dropdown_max_height);
723 visible + 2 }
725
726 fn calculate_dropdown_area(&self, menu_x: u16, bar_bottom: u16, items: &[MenuBarItem], screen: Rect) -> Rect {
728 let width = self.calculate_dropdown_width(items);
729 let height = self.calculate_dropdown_height(items.len());
730
731 let y = bar_bottom;
733
734 let x = if menu_x + width <= screen.x + screen.width {
736 menu_x
737 } else {
738 screen.x + screen.width.saturating_sub(width)
739 };
740
741 let final_width = width.min(screen.width.saturating_sub(x.saturating_sub(screen.x)));
743 let final_height = height.min(screen.height.saturating_sub(y.saturating_sub(screen.y)));
744
745 Rect::new(x, y, final_width, final_height)
746 }
747
748 pub fn render_stateful(
752 &self,
753 frame: &mut Frame,
754 area: Rect,
755 ) -> (Rect, Option<Rect>, Vec<ClickRegion<MenuBarClickTarget>>) {
756 let mut regions = Vec::new();
757
758 if area.height == 0 || self.menus.is_empty() {
759 return (Rect::default(), None, regions);
760 }
761
762 let bar_area = Rect::new(area.x, area.y, area.width, 1);
764
765 let bar_style = Style::default().bg(self.style.bar_bg);
767 let bar_line = " ".repeat(bar_area.width as usize);
768 let bar_para = Paragraph::new(Span::styled(bar_line, bar_style));
769 frame.render_widget(bar_para, bar_area);
770
771 let mut x = bar_area.x;
773 let mut menu_positions: Vec<(u16, u16)> = Vec::new(); for (idx, menu) in self.menus.iter().enumerate() {
776 let label = format!(" {} ", menu.label);
777 let label_width = label.chars().count() as u16;
778
779 let is_active = self.state.focused && idx == self.state.active_menu;
780 let is_open = self.state.is_open && idx == self.state.active_menu;
781
782 let (fg, bg) = if !menu.enabled {
783 (self.style.disabled_fg, self.style.bar_bg)
784 } else if is_active || is_open {
785 (self.style.bar_highlight_fg, self.style.bar_highlight_bg)
786 } else {
787 (self.style.bar_fg, self.style.bar_bg)
788 };
789
790 let style = Style::default().fg(fg).bg(bg);
791 let label_area = Rect::new(x, bar_area.y, label_width, 1);
792
793 let para = Paragraph::new(Span::styled(label.clone(), style));
794 frame.render_widget(para, label_area);
795
796 menu_positions.push((x, label_width));
797
798 if menu.enabled {
800 regions.push(ClickRegion::new(label_area, MenuBarClickTarget::MenuLabel(idx)));
801 }
802
803 x += label_width + self.style.menu_padding;
804 }
805
806 let dropdown_area = if self.state.is_open {
808 if let Some(menu) = self.menus.get(self.state.active_menu) {
809 if let Some(&(menu_x, _)) = menu_positions.get(self.state.active_menu) {
810 let screen = frame.area();
811 let dropdown_area = self.calculate_dropdown_area(
812 menu_x,
813 bar_area.y + 1,
814 &menu.items,
815 screen,
816 );
817
818 frame.render_widget(Clear, dropdown_area);
820
821 let block = Block::default()
823 .borders(Borders::ALL)
824 .border_style(Style::default().fg(self.style.dropdown_border))
825 .style(Style::default().bg(self.style.dropdown_bg));
826
827 let inner = block.inner(dropdown_area);
828 frame.render_widget(block, dropdown_area);
829
830 let visible_count = inner.height as usize;
832 let scroll = self.state.scroll_offset as usize;
833
834 for (display_idx, (item_idx, item)) in menu
835 .items
836 .iter()
837 .enumerate()
838 .skip(scroll)
839 .take(visible_count)
840 .enumerate()
841 {
842 let y = inner.y + display_idx as u16;
843 let item_area = Rect::new(inner.x, y, inner.width, 1);
844
845 let is_highlighted = self.state.highlighted_item == Some(item_idx);
846
847 self.render_menu_item(
848 frame,
849 item,
850 item_area,
851 is_highlighted,
852 &mut regions,
853 item_idx,
854 false,
855 );
856 }
857
858 if let Some(submenu_idx) = self.state.active_submenu {
860 if let Some(MenuBarItem::Submenu { items, .. }) = menu.items.get(submenu_idx) {
861 let submenu_x = dropdown_area.x + dropdown_area.width;
862 let submenu_y = dropdown_area.y + 1 + (submenu_idx as u16).saturating_sub(self.state.scroll_offset);
863
864 let submenu_width = self.calculate_dropdown_width(items);
865 let submenu_height = self.calculate_dropdown_height(items.len());
866
867 let screen = frame.area();
868
869 let final_x = if submenu_x + submenu_width <= screen.x + screen.width {
871 submenu_x
872 } else {
873 dropdown_area.x.saturating_sub(submenu_width)
874 };
875
876 let submenu_area = Rect::new(
877 final_x,
878 submenu_y.min(screen.y + screen.height - submenu_height),
879 submenu_width,
880 submenu_height,
881 );
882
883 frame.render_widget(Clear, submenu_area);
885
886 let block = Block::default()
887 .borders(Borders::ALL)
888 .border_style(Style::default().fg(self.style.dropdown_border))
889 .style(Style::default().bg(self.style.dropdown_bg));
890
891 let sub_inner = block.inner(submenu_area);
892 frame.render_widget(block, submenu_area);
893
894 let sub_visible = sub_inner.height as usize;
895 let sub_scroll = self.state.submenu_scroll_offset as usize;
896
897 for (display_idx, (item_idx, item)) in items
898 .iter()
899 .enumerate()
900 .skip(sub_scroll)
901 .take(sub_visible)
902 .enumerate()
903 {
904 let y = sub_inner.y + display_idx as u16;
905 let item_area = Rect::new(sub_inner.x, y, sub_inner.width, 1);
906
907 let is_highlighted = self.state.submenu_highlighted == Some(item_idx);
908
909 self.render_menu_item(
910 frame,
911 item,
912 item_area,
913 is_highlighted,
914 &mut regions,
915 item_idx,
916 true,
917 );
918 }
919 }
920 }
921
922 Some(dropdown_area)
923 } else {
924 None
925 }
926 } else {
927 None
928 }
929 } else {
930 None
931 };
932
933 (bar_area, dropdown_area, regions)
934 }
935
936 #[allow(clippy::too_many_arguments)]
938 fn render_menu_item(
939 &self,
940 frame: &mut Frame,
941 item: &MenuBarItem,
942 item_area: Rect,
943 is_highlighted: bool,
944 regions: &mut Vec<ClickRegion<MenuBarClickTarget>>,
945 item_idx: usize,
946 is_submenu: bool,
947 ) {
948 match item {
949 MenuBarItem::Separator => {
950 let sep_line: String =
951 std::iter::repeat_n(self.style.separator_char, item_area.width as usize).collect();
952 let para = Paragraph::new(Span::styled(
953 sep_line,
954 Style::default().fg(self.style.separator_fg).bg(self.style.dropdown_bg),
955 ));
956 frame.render_widget(para, item_area);
957 }
958 MenuBarItem::Action {
959 label,
960 shortcut,
961 enabled,
962 id,
963 } => {
964 let (fg, bg) = if !enabled {
965 (self.style.disabled_fg, self.style.dropdown_bg)
966 } else if is_highlighted {
967 (self.style.item_highlight_fg, self.style.item_highlight_bg)
968 } else {
969 (self.style.item_fg, self.style.dropdown_bg)
970 };
971
972 let style = Style::default().fg(fg).bg(bg);
973 let shortcut_style = Style::default()
974 .fg(if *enabled {
975 self.style.shortcut_fg
976 } else {
977 self.style.disabled_fg
978 })
979 .bg(bg);
980
981 let mut spans = Vec::new();
982
983 spans.push(Span::styled(
985 " ".repeat(self.style.dropdown_padding as usize),
986 style,
987 ));
988
989 spans.push(Span::styled(label.clone(), style));
991
992 let current_len: usize = spans.iter().map(|s| s.content.chars().count()).sum();
994 let shortcut_len = shortcut.as_ref().map(|s| s.chars().count()).unwrap_or(0);
995 let fill_len = (item_area.width as usize)
996 .saturating_sub(current_len)
997 .saturating_sub(shortcut_len)
998 .saturating_sub(self.style.dropdown_padding as usize);
999
1000 if fill_len > 0 {
1001 spans.push(Span::styled(" ".repeat(fill_len), style));
1002 }
1003
1004 if let Some(sc) = shortcut {
1006 spans.push(Span::styled(sc.clone(), shortcut_style));
1007 }
1008
1009 spans.push(Span::styled(
1011 " ".repeat(self.style.dropdown_padding as usize),
1012 style,
1013 ));
1014
1015 let para = Paragraph::new(Line::from(spans));
1016 frame.render_widget(para, item_area);
1017
1018 if *enabled {
1020 let target = if is_submenu {
1021 MenuBarClickTarget::SubmenuItem(item_idx)
1022 } else {
1023 MenuBarClickTarget::DropdownItem(item_idx)
1024 };
1025 regions.push(ClickRegion::new(item_area, target));
1026 }
1027
1028 let _ = id;
1030 }
1031 MenuBarItem::Submenu { label, enabled, .. } => {
1032 let (fg, bg) = if !enabled {
1033 (self.style.disabled_fg, self.style.dropdown_bg)
1034 } else if is_highlighted {
1035 (self.style.item_highlight_fg, self.style.item_highlight_bg)
1036 } else {
1037 (self.style.item_fg, self.style.dropdown_bg)
1038 };
1039
1040 let style = Style::default().fg(fg).bg(bg);
1041
1042 let mut spans = Vec::new();
1043
1044 spans.push(Span::styled(
1046 " ".repeat(self.style.dropdown_padding as usize),
1047 style,
1048 ));
1049
1050 spans.push(Span::styled(label.clone(), style));
1052
1053 let current_len: usize = spans.iter().map(|s| s.content.chars().count()).sum();
1055 let indicator_len = self.style.submenu_indicator.chars().count();
1056 let fill_len = (item_area.width as usize)
1057 .saturating_sub(current_len)
1058 .saturating_sub(indicator_len)
1059 .saturating_sub(self.style.dropdown_padding as usize);
1060
1061 if fill_len > 0 {
1062 spans.push(Span::styled(" ".repeat(fill_len), style));
1063 }
1064
1065 spans.push(Span::styled(self.style.submenu_indicator, style));
1066
1067 spans.push(Span::styled(
1069 " ".repeat(self.style.dropdown_padding as usize),
1070 style,
1071 ));
1072
1073 let para = Paragraph::new(Line::from(spans));
1074 frame.render_widget(para, item_area);
1075
1076 if *enabled && !is_submenu {
1078 regions.push(ClickRegion::new(item_area, MenuBarClickTarget::DropdownItem(item_idx)));
1079 }
1080 }
1081 }
1082 }
1083}
1084
1085#[allow(clippy::collapsible_match)]
1098pub fn handle_menu_bar_key(
1099 key: &KeyEvent,
1100 state: &mut MenuBarState,
1101 menus: &[Menu],
1102) -> Option<MenuBarAction> {
1103 if menus.is_empty() {
1104 return None;
1105 }
1106
1107 if state.has_open_submenu() {
1109 if let Some(menu) = menus.get(state.active_menu) {
1110 if let Some(submenu_idx) = state.active_submenu {
1111 if let Some(MenuBarItem::Submenu { items, .. }) = menu.items.get(submenu_idx) {
1112 match key.code {
1113 KeyCode::Esc | KeyCode::Left => {
1114 state.close_submenu();
1115 return Some(MenuBarAction::SubmenuClose);
1116 }
1117 KeyCode::Up => {
1118 state.prev_submenu_item(items);
1119 return Some(MenuBarAction::HighlightChange(
1120 state.active_menu,
1121 state.submenu_highlighted,
1122 ));
1123 }
1124 KeyCode::Down => {
1125 state.next_submenu_item(items);
1126 return Some(MenuBarAction::HighlightChange(
1127 state.active_menu,
1128 state.submenu_highlighted,
1129 ));
1130 }
1131 KeyCode::Enter | KeyCode::Char(' ') => {
1132 if let Some(idx) = state.submenu_highlighted {
1133 if let Some(item) = items.get(idx) {
1134 if let MenuBarItem::Action { id, enabled, .. } = item {
1135 if *enabled {
1136 let action_id = id.clone();
1137 state.close_menu();
1138 return Some(MenuBarAction::ItemSelect(action_id));
1139 }
1140 }
1141 }
1142 }
1143 return None;
1144 }
1145 _ => return None,
1146 }
1147 }
1148 }
1149 }
1150 }
1151
1152 match key.code {
1153 KeyCode::Left => {
1154 state.prev_menu(menus.len());
1155 Some(MenuBarAction::HighlightChange(state.active_menu, None))
1156 }
1157 KeyCode::Right => {
1158 if state.is_open {
1160 if let Some(menu) = menus.get(state.active_menu) {
1161 if let Some(idx) = state.highlighted_item {
1162 if let Some(item) = menu.items.get(idx) {
1163 if item.has_submenu() && item.is_enabled() {
1164 state.open_submenu();
1165 return Some(MenuBarAction::SubmenuOpen(state.active_menu, idx));
1166 }
1167 }
1168 }
1169 }
1170 }
1171 state.next_menu(menus.len());
1172 Some(MenuBarAction::HighlightChange(state.active_menu, None))
1173 }
1174 KeyCode::Down => {
1175 if state.is_open {
1176 if let Some(menu) = menus.get(state.active_menu) {
1177 state.next_item(&menu.items);
1178 state.ensure_visible(8);
1179 Some(MenuBarAction::HighlightChange(
1180 state.active_menu,
1181 state.highlighted_item,
1182 ))
1183 } else {
1184 None
1185 }
1186 } else {
1187 state.open_menu(state.active_menu);
1188 if let Some(menu) = menus.get(state.active_menu) {
1189 state.highlight_first(&menu.items);
1190 }
1191 Some(MenuBarAction::MenuOpen(state.active_menu))
1192 }
1193 }
1194 KeyCode::Up => {
1195 if state.is_open {
1196 if let Some(menu) = menus.get(state.active_menu) {
1197 state.prev_item(&menu.items);
1198 state.ensure_visible(8);
1199 Some(MenuBarAction::HighlightChange(
1200 state.active_menu,
1201 state.highlighted_item,
1202 ))
1203 } else {
1204 None
1205 }
1206 } else {
1207 None
1208 }
1209 }
1210 KeyCode::Enter | KeyCode::Char(' ') => {
1211 if state.is_open {
1212 if let Some(menu) = menus.get(state.active_menu) {
1213 if let Some(idx) = state.highlighted_item {
1214 if let Some(item) = menu.items.get(idx) {
1215 match item {
1216 MenuBarItem::Action { id, enabled, .. } if *enabled => {
1217 let action_id = id.clone();
1218 state.close_menu();
1219 return Some(MenuBarAction::ItemSelect(action_id));
1220 }
1221 MenuBarItem::Submenu { enabled, .. } if *enabled => {
1222 state.open_submenu();
1223 return Some(MenuBarAction::SubmenuOpen(state.active_menu, idx));
1224 }
1225 _ => {}
1226 }
1227 }
1228 }
1229 }
1230 None
1231 } else {
1232 state.open_menu(state.active_menu);
1233 if let Some(menu) = menus.get(state.active_menu) {
1234 state.highlight_first(&menu.items);
1235 }
1236 Some(MenuBarAction::MenuOpen(state.active_menu))
1237 }
1238 }
1239 KeyCode::Esc => {
1240 if state.is_open {
1241 state.close_menu();
1242 Some(MenuBarAction::MenuClose)
1243 } else {
1244 None
1245 }
1246 }
1247 KeyCode::Home => {
1248 if state.is_open {
1249 if let Some(menu) = menus.get(state.active_menu) {
1250 state.highlight_first(&menu.items);
1251 Some(MenuBarAction::HighlightChange(
1252 state.active_menu,
1253 state.highlighted_item,
1254 ))
1255 } else {
1256 None
1257 }
1258 } else {
1259 state.active_menu = 0;
1260 Some(MenuBarAction::HighlightChange(0, None))
1261 }
1262 }
1263 KeyCode::End => {
1264 if state.is_open {
1265 if let Some(menu) = menus.get(state.active_menu) {
1266 state.highlight_last(&menu.items);
1267 state.ensure_visible(menu.items.len());
1268 Some(MenuBarAction::HighlightChange(
1269 state.active_menu,
1270 state.highlighted_item,
1271 ))
1272 } else {
1273 None
1274 }
1275 } else {
1276 state.active_menu = menus.len().saturating_sub(1);
1277 Some(MenuBarAction::HighlightChange(state.active_menu, None))
1278 }
1279 }
1280 _ => None,
1281 }
1282}
1283
1284#[allow(clippy::collapsible_match)]
1297pub fn handle_menu_bar_mouse(
1298 mouse: &MouseEvent,
1299 state: &mut MenuBarState,
1300 bar_area: Rect,
1301 dropdown_area: Option<Rect>,
1302 click_regions: &[ClickRegion<MenuBarClickTarget>],
1303 menus: &[Menu],
1304) -> Option<MenuBarAction> {
1305 let col = mouse.column;
1306 let row = mouse.row;
1307
1308 match mouse.kind {
1309 MouseEventKind::Down(MouseButton::Left) => {
1310 for region in click_regions {
1312 if region.contains(col, row) {
1313 match ®ion.data {
1314 MenuBarClickTarget::MenuLabel(idx) => {
1315 state.toggle_menu(*idx);
1316 if state.is_open {
1317 if let Some(menu) = menus.get(*idx) {
1318 state.highlight_first(&menu.items);
1319 }
1320 return Some(MenuBarAction::MenuOpen(*idx));
1321 } else {
1322 return Some(MenuBarAction::MenuClose);
1323 }
1324 }
1325 MenuBarClickTarget::DropdownItem(idx) => {
1326 if let Some(menu) = menus.get(state.active_menu) {
1327 if let Some(item) = menu.items.get(*idx) {
1328 match item {
1329 MenuBarItem::Action { id, enabled, .. } if *enabled => {
1330 let action_id = id.clone();
1331 state.close_menu();
1332 return Some(MenuBarAction::ItemSelect(action_id));
1333 }
1334 MenuBarItem::Submenu { enabled, .. } if *enabled => {
1335 state.highlighted_item = Some(*idx);
1336 state.open_submenu();
1337 return Some(MenuBarAction::SubmenuOpen(state.active_menu, *idx));
1338 }
1339 _ => {}
1340 }
1341 }
1342 }
1343 }
1344 MenuBarClickTarget::SubmenuItem(idx) => {
1345 if let Some(menu) = menus.get(state.active_menu) {
1346 if let Some(submenu_idx) = state.active_submenu {
1347 if let Some(MenuBarItem::Submenu { items, .. }) = menu.items.get(submenu_idx) {
1348 if let Some(item) = items.get(*idx) {
1349 if let MenuBarItem::Action { id, enabled, .. } = item {
1350 if *enabled {
1351 let action_id = id.clone();
1352 state.close_menu();
1353 return Some(MenuBarAction::ItemSelect(action_id));
1354 }
1355 }
1356 }
1357 }
1358 }
1359 }
1360 }
1361 }
1362 }
1363 }
1364
1365 let in_bar = bar_area.intersects(Rect::new(col, row, 1, 1));
1367 let in_dropdown = dropdown_area
1368 .map(|d| d.intersects(Rect::new(col, row, 1, 1)))
1369 .unwrap_or(false);
1370
1371 if state.is_open && !in_bar && !in_dropdown {
1372 state.close_menu();
1373 return Some(MenuBarAction::MenuClose);
1374 }
1375
1376 None
1377 }
1378 MouseEventKind::Moved => {
1379 for region in click_regions {
1381 if region.contains(col, row) {
1382 match ®ion.data {
1383 MenuBarClickTarget::MenuLabel(idx) => {
1384 if state.is_open && state.active_menu != *idx {
1386 state.open_menu(*idx);
1387 if let Some(menu) = menus.get(*idx) {
1388 state.highlight_first(&menu.items);
1389 }
1390 return Some(MenuBarAction::MenuOpen(*idx));
1391 }
1392 }
1393 MenuBarClickTarget::DropdownItem(idx) => {
1394 if state.highlighted_item != Some(*idx) {
1395 state.highlighted_item = Some(*idx);
1396 if state.active_submenu.is_some() && state.active_submenu != Some(*idx) {
1398 state.close_submenu();
1399 }
1400 return Some(MenuBarAction::HighlightChange(
1401 state.active_menu,
1402 Some(*idx),
1403 ));
1404 }
1405 }
1406 MenuBarClickTarget::SubmenuItem(idx) => {
1407 if state.submenu_highlighted != Some(*idx) {
1408 state.submenu_highlighted = Some(*idx);
1409 return Some(MenuBarAction::HighlightChange(
1410 state.active_menu,
1411 Some(*idx),
1412 ));
1413 }
1414 }
1415 }
1416 break;
1417 }
1418 }
1419 None
1420 }
1421 _ => None,
1422 }
1423}
1424
1425pub fn calculate_menu_bar_height() -> u16 {
1427 1
1428}
1429
1430pub fn calculate_dropdown_height(item_count: usize, max_visible: u16) -> u16 {
1432 let visible = (item_count as u16).min(max_visible);
1433 visible + 2 }
1435
1436#[cfg(test)]
1437mod tests {
1438 use super::*;
1439
1440 #[test]
1441 fn test_menu_bar_item_action() {
1442 let item = MenuBarItem::action("save", "Save").shortcut("Ctrl+S");
1443
1444 assert!(item.is_selectable());
1445 assert!(!item.has_submenu());
1446 assert_eq!(item.id(), Some("save"));
1447 assert_eq!(item.label(), Some("Save"));
1448 assert_eq!(item.get_shortcut(), Some("Ctrl+S"));
1449 }
1450
1451 #[test]
1452 fn test_menu_bar_item_separator() {
1453 let item = MenuBarItem::separator();
1454
1455 assert!(!item.is_selectable());
1456 assert!(!item.has_submenu());
1457 assert_eq!(item.label(), None);
1458 }
1459
1460 #[test]
1461 fn test_menu_bar_item_submenu() {
1462 let items = vec![MenuBarItem::action("sub1", "Sub Item 1")];
1463 let item = MenuBarItem::submenu("More", items);
1464
1465 assert!(item.is_selectable());
1466 assert!(item.has_submenu());
1467 assert_eq!(item.label(), Some("More"));
1468 assert!(item.submenu_items().is_some());
1469 }
1470
1471 #[test]
1472 fn test_menu_bar_item_disabled() {
1473 let item = MenuBarItem::action("delete", "Delete").enabled(false);
1474
1475 assert!(!item.is_selectable());
1476 assert!(!item.is_enabled());
1477 }
1478
1479 #[test]
1480 fn test_menu_creation() {
1481 let menu = Menu::new("File")
1482 .items(vec![
1483 MenuBarItem::action("new", "New"),
1484 MenuBarItem::separator(),
1485 MenuBarItem::action("quit", "Quit"),
1486 ])
1487 .enabled(true);
1488
1489 assert_eq!(menu.label, "File");
1490 assert_eq!(menu.items.len(), 3);
1491 assert!(menu.enabled);
1492 }
1493
1494 #[test]
1495 fn test_menu_bar_state_new() {
1496 let state = MenuBarState::new();
1497
1498 assert!(!state.is_open);
1499 assert_eq!(state.active_menu, 0);
1500 assert_eq!(state.highlighted_item, None);
1501 assert!(!state.focused);
1502 }
1503
1504 #[test]
1505 fn test_menu_bar_state_open_close() {
1506 let mut state = MenuBarState::new();
1507
1508 state.open_menu(1);
1509 assert!(state.is_open);
1510 assert_eq!(state.active_menu, 1);
1511 assert_eq!(state.highlighted_item, None);
1512
1513 state.close_menu();
1514 assert!(!state.is_open);
1515 }
1516
1517 #[test]
1518 fn test_menu_bar_state_toggle() {
1519 let mut state = MenuBarState::new();
1520
1521 state.toggle_menu(0);
1522 assert!(state.is_open);
1523 assert_eq!(state.active_menu, 0);
1524
1525 state.toggle_menu(0);
1526 assert!(!state.is_open);
1527
1528 state.toggle_menu(0);
1529 assert!(state.is_open);
1530
1531 state.toggle_menu(1);
1533 assert!(state.is_open);
1534 assert_eq!(state.active_menu, 1);
1535 }
1536
1537 #[test]
1538 fn test_menu_bar_state_navigation() {
1539 let mut state = MenuBarState::new();
1540 state.active_menu = 0;
1541
1542 state.next_menu(3);
1543 assert_eq!(state.active_menu, 1);
1544
1545 state.next_menu(3);
1546 assert_eq!(state.active_menu, 2);
1547
1548 state.next_menu(3);
1549 assert_eq!(state.active_menu, 0); state.prev_menu(3);
1552 assert_eq!(state.active_menu, 2); state.prev_menu(3);
1555 assert_eq!(state.active_menu, 1);
1556 }
1557
1558 #[test]
1559 fn test_menu_bar_state_item_navigation() {
1560 let mut state = MenuBarState::new();
1561 state.open_menu(0);
1562
1563 let items = vec![
1564 MenuBarItem::action("a", "A"),
1565 MenuBarItem::separator(),
1566 MenuBarItem::action("b", "B"),
1567 MenuBarItem::action("c", "C"),
1568 ];
1569
1570 state.next_item(&items);
1572 assert!(state.highlighted_item.is_some());
1574
1575 state.highlight_first(&items);
1576 assert_eq!(state.highlighted_item, Some(0));
1577
1578 state.next_item(&items);
1579 assert_eq!(state.highlighted_item, Some(2)); state.next_item(&items);
1582 assert_eq!(state.highlighted_item, Some(3));
1583
1584 state.prev_item(&items);
1585 assert_eq!(state.highlighted_item, Some(2));
1586
1587 state.prev_item(&items);
1588 assert_eq!(state.highlighted_item, Some(0));
1589 }
1590
1591 #[test]
1592 fn test_menu_bar_state_submenu() {
1593 let mut state = MenuBarState::new();
1594 state.open_menu(0);
1595 state.highlighted_item = Some(2);
1596
1597 assert!(!state.has_open_submenu());
1598
1599 state.open_submenu();
1600 assert!(state.has_open_submenu());
1601 assert_eq!(state.active_submenu, Some(2));
1602
1603 state.close_submenu();
1604 assert!(!state.has_open_submenu());
1605 }
1606
1607 #[test]
1608 fn test_menu_bar_style_default() {
1609 let style = MenuBarStyle::default();
1610 assert_eq!(style.dropdown_min_width, 15);
1611 assert_eq!(style.dropdown_max_height, 15);
1612 assert_eq!(style.submenu_indicator, "▶");
1613 }
1614
1615 #[test]
1616 fn test_menu_bar_style_builders() {
1617 let style = MenuBarStyle::default()
1618 .dropdown_min_width(20)
1619 .dropdown_max_height(10)
1620 .submenu_indicator("→");
1621
1622 assert_eq!(style.dropdown_min_width, 20);
1623 assert_eq!(style.dropdown_max_height, 10);
1624 assert_eq!(style.submenu_indicator, "→");
1625 }
1626
1627 #[test]
1628 fn test_menu_bar_style_presets() {
1629 let light = MenuBarStyle::light();
1630 assert_eq!(light.bar_bg, Color::Rgb(240, 240, 240));
1631
1632 let minimal = MenuBarStyle::minimal();
1633 assert_eq!(minimal.bar_bg, Color::Reset);
1634 }
1635
1636 #[test]
1637 fn test_handle_key_left_right() {
1638 let mut state = MenuBarState::new();
1639 state.focused = true;
1640
1641 let menus = vec![
1642 Menu::new("File").items(vec![]),
1643 Menu::new("Edit").items(vec![]),
1644 Menu::new("View").items(vec![]),
1645 ];
1646
1647 let key = KeyEvent::from(KeyCode::Right);
1648 let action = handle_menu_bar_key(&key, &mut state, &menus);
1649 assert_eq!(action, Some(MenuBarAction::HighlightChange(1, None)));
1650 assert_eq!(state.active_menu, 1);
1651
1652 let key = KeyEvent::from(KeyCode::Left);
1653 let action = handle_menu_bar_key(&key, &mut state, &menus);
1654 assert_eq!(action, Some(MenuBarAction::HighlightChange(0, None)));
1655 assert_eq!(state.active_menu, 0);
1656 }
1657
1658 #[test]
1659 fn test_handle_key_down_opens_menu() {
1660 let mut state = MenuBarState::new();
1661 state.focused = true;
1662
1663 let menus = vec![Menu::new("File").items(vec![
1664 MenuBarItem::action("new", "New"),
1665 ])];
1666
1667 let key = KeyEvent::from(KeyCode::Down);
1668 let action = handle_menu_bar_key(&key, &mut state, &menus);
1669
1670 assert_eq!(action, Some(MenuBarAction::MenuOpen(0)));
1671 assert!(state.is_open);
1672 }
1673
1674 #[test]
1675 fn test_handle_key_escape_closes() {
1676 let mut state = MenuBarState::new();
1677 state.open_menu(0);
1678
1679 let menus = vec![Menu::new("File").items(vec![])];
1680
1681 let key = KeyEvent::from(KeyCode::Esc);
1682 let action = handle_menu_bar_key(&key, &mut state, &menus);
1683
1684 assert_eq!(action, Some(MenuBarAction::MenuClose));
1685 assert!(!state.is_open);
1686 }
1687
1688 #[test]
1689 fn test_handle_key_enter_selects_item() {
1690 let mut state = MenuBarState::new();
1691 state.open_menu(0);
1692 state.highlighted_item = Some(0);
1693
1694 let menus = vec![Menu::new("File").items(vec![
1695 MenuBarItem::action("new", "New"),
1696 ])];
1697
1698 let key = KeyEvent::from(KeyCode::Enter);
1699 let action = handle_menu_bar_key(&key, &mut state, &menus);
1700
1701 assert_eq!(action, Some(MenuBarAction::ItemSelect("new".to_string())));
1702 assert!(!state.is_open);
1703 }
1704
1705 #[test]
1706 fn test_handle_key_enter_opens_submenu() {
1707 let mut state = MenuBarState::new();
1708 state.open_menu(0);
1709 state.highlighted_item = Some(0);
1710
1711 let menus = vec![Menu::new("File").items(vec![
1712 MenuBarItem::submenu("Recent", vec![
1713 MenuBarItem::action("file1", "File 1"),
1714 ]),
1715 ])];
1716
1717 let key = KeyEvent::from(KeyCode::Enter);
1718 let action = handle_menu_bar_key(&key, &mut state, &menus);
1719
1720 assert_eq!(action, Some(MenuBarAction::SubmenuOpen(0, 0)));
1721 assert!(state.has_open_submenu());
1722 }
1723
1724 #[test]
1725 fn test_handle_key_empty_menus() {
1726 let mut state = MenuBarState::new();
1727 let menus: Vec<Menu> = vec![];
1728
1729 let key = KeyEvent::from(KeyCode::Down);
1730 let action = handle_menu_bar_key(&key, &mut state, &menus);
1731
1732 assert!(action.is_none());
1733 }
1734
1735 #[test]
1736 fn test_menu_bar_action_equality() {
1737 assert_eq!(MenuBarAction::MenuOpen(0), MenuBarAction::MenuOpen(0));
1738 assert_ne!(MenuBarAction::MenuOpen(0), MenuBarAction::MenuOpen(1));
1739 assert_eq!(MenuBarAction::MenuClose, MenuBarAction::MenuClose);
1740 assert_eq!(
1741 MenuBarAction::ItemSelect("test".to_string()),
1742 MenuBarAction::ItemSelect("test".to_string())
1743 );
1744 assert_eq!(
1745 MenuBarAction::HighlightChange(0, Some(1)),
1746 MenuBarAction::HighlightChange(0, Some(1))
1747 );
1748 }
1749
1750 #[test]
1751 fn test_calculate_heights() {
1752 assert_eq!(calculate_menu_bar_height(), 1);
1753 assert_eq!(calculate_dropdown_height(5, 15), 7); assert_eq!(calculate_dropdown_height(20, 15), 17); }
1756
1757 #[test]
1758 fn test_menu_bar_widget_new() {
1759 let menus = vec![Menu::new("File").items(vec![])];
1760 let state = MenuBarState::new();
1761 let _menu_bar = MenuBar::new(&menus, &state);
1762 }
1763
1764 #[test]
1765 fn test_menu_bar_widget_style() {
1766 let menus = vec![Menu::new("File").items(vec![])];
1767 let state = MenuBarState::new();
1768 let style = MenuBarStyle::light();
1769 let _menu_bar = MenuBar::new(&menus, &state).style(style);
1770 }
1771
1772 #[test]
1773 fn test_click_target_equality() {
1774 assert_eq!(
1775 MenuBarClickTarget::MenuLabel(0),
1776 MenuBarClickTarget::MenuLabel(0)
1777 );
1778 assert_ne!(
1779 MenuBarClickTarget::MenuLabel(0),
1780 MenuBarClickTarget::MenuLabel(1)
1781 );
1782 assert_eq!(
1783 MenuBarClickTarget::DropdownItem(0),
1784 MenuBarClickTarget::DropdownItem(0)
1785 );
1786 assert_eq!(
1787 MenuBarClickTarget::SubmenuItem(0),
1788 MenuBarClickTarget::SubmenuItem(0)
1789 );
1790 }
1791
1792 #[test]
1793 fn test_menu_bar_state_ensure_visible() {
1794 let mut state = MenuBarState::new();
1795 state.highlighted_item = Some(15);
1796 state.scroll_offset = 0;
1797
1798 state.ensure_visible(10);
1799 assert!(state.scroll_offset >= 6);
1800
1801 state.highlighted_item = Some(3);
1802 state.ensure_visible(10);
1803 assert!(state.scroll_offset <= 3);
1804 }
1805
1806 #[test]
1807 fn test_menu_bar_state_highlight_first_last() {
1808 let mut state = MenuBarState::new();
1809 state.open_menu(0);
1810
1811 let items = vec![
1812 MenuBarItem::separator(),
1813 MenuBarItem::action("a", "A"),
1814 MenuBarItem::action("b", "B"),
1815 MenuBarItem::separator(),
1816 MenuBarItem::action("c", "C"),
1817 ];
1818
1819 state.highlight_first(&items);
1820 assert_eq!(state.highlighted_item, Some(1));
1821
1822 state.highlight_last(&items);
1823 assert_eq!(state.highlighted_item, Some(4));
1824 }
1825
1826 #[test]
1827 fn test_submenu_navigation() {
1828 let mut state = MenuBarState::new();
1829 state.open_menu(0);
1830 state.highlighted_item = Some(0);
1831 state.open_submenu();
1832
1833 let items = vec![
1834 MenuBarItem::action("a", "A"),
1835 MenuBarItem::separator(),
1836 MenuBarItem::action("b", "B"),
1837 ];
1838
1839 state.next_submenu_item(&items);
1840 assert!(state.submenu_highlighted.is_some());
1842
1843 state.prev_submenu_item(&items);
1844 assert!(state.submenu_highlighted.is_some());
1845 }
1846}