tui_vision/menus/
render.rs

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
11/// Rendering implementation for the MenuBar widget.
12///
13/// This implementation renders the menu bar as a horizontal strip across the top
14/// of the terminal, with menus displayed as clickable titles. When a menu is open,
15/// it displays a dropdown with the menu items.
16impl 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        // First render the dropdown if a menu is open (before the menu bar)
25        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        // Then render the menu bar on top
33        self.render_menu_bar(area, buf);
34    }
35}
36
37impl MenuBar {
38    /// Clears an area in the buffer with the specified style.
39    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    /// Renders the horizontal menu bar.
48    ///
49    /// Only affects the first line of the given area, leaving the rest of the buffer unchanged.
50    fn render_menu_bar(&self, area: Rect, buf: &mut Buffer) {
51        let menu_bar_style = self.theme.menu_bar;
52
53        // Only clear and render the first line of the area
54        let menu_bar_area = Rect {
55            x: area.x,
56            y: area.y,
57            width: area.width,
58            height: 1,
59        };
60
61        // Clear only the menu bar line
62        self.clear_area(menu_bar_area, buf, menu_bar_style);
63
64        let mut x_offset = area.x + 1; // Start with padding from left edge
65
66        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            // Render menu title with padding
75            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            // Add space between menus
82            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    /// Calculates the area for the dropdown menu.
90    fn calculate_dropdown_area(&self, area: Rect, menu_index: usize) -> Rect {
91        let mut x_offset = area.x + 1;
92
93        // Calculate x position based on menu titles
94        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        // Calculate dropdown dimensions with proper width accounting for shortcuts and arrows
104        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 // +2 for arrow and space
115                }
116                MenuItem::Separator(_) => 0, // Separators use full width
117            })
118            .max()
119            .unwrap_or(10) as u16;
120
121        let dropdown_width = (max_item_width + 4).min(40); // Padding + max width
122        let dropdown_height = menu.items.len() as u16 + 2; // Items + borders
123
124        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    /// Calculates the area for a submenu dropdown.
133    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        // Calculate submenu dimensions
141        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 // +2 for arrow and space
152                }
153                MenuItem::Separator(_) => 0,
154            })
155            .max()
156            .unwrap_or(10) as u16;
157
158        let submenu_width = (max_item_width + 4).min(30); // Padding + max width (smaller than main menu)
159        let submenu_height = submenu.items.len() as u16 + 2; // Items + borders
160
161        // Position submenu to the right of the parent menu item
162        // Don't constrain by parent area height - let submenu extend as needed
163        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    /// Renders the dropdown menu.
172    ///
173    /// The dropdown renders on top of existing buffer content, clearing only its own area.
174    /// This allows menus to overlay other content in the terminal.
175    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        // Use Clear widget to clear the dropdown area
180        Clear.render(area, buf);
181
182        // Use Block widget for the border
183        let block = Block::bordered()
184            .border_style(border_style)
185            .style(dropdown_style);
186
187        // Calculate content area before rendering the block
188        let content_area = block.inner(area);
189        block.render(area, buf);
190
191        // Render menu items
192        for (index, item) in menu.items.iter().enumerate() {
193            if index as u16 >= content_area.height {
194                break; // Don't render beyond dropdown height
195            }
196
197            let y = content_area.y + index as u16;
198            let is_focused = menu.focused_item == Some(index);
199
200            // Special handling for separators - they span the full dropdown width
201            if matches!(item, MenuItem::Separator(_)) {
202                let separator_style = self.theme.separator;
203
204                // Render separator line across the full dropdown width
205                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 this is an open submenu, render its dropdown
214                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    /// Renders a submenu dropdown.
226    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        // Use Clear widget to clear the submenu area
231        Clear.render(area, buf);
232
233        // Use Block widget for the border
234        let block = Block::bordered()
235            .border_style(border_style)
236            .style(dropdown_style);
237
238        // Calculate content area before rendering the block
239        let content_area = block.inner(area);
240        block.render(area, buf);
241
242        // Render submenu items
243        for (index, item) in submenu.items.iter().enumerate() {
244            if index as u16 >= content_area.height {
245                break; // Don't render beyond submenu height
246            }
247
248            let y = content_area.y + index as u16;
249            let is_focused = submenu.focused_item == Some(index);
250
251            // Special handling for separators - they span the full submenu width
252            if matches!(item, MenuItem::Separator(_)) {
253                let separator_style = self.theme.separator;
254
255                // Render separator line across the full submenu width
256                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    /// Renders a single menu item.
268    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                // Separators are handled separately in the main render loop
291                // to allow them to span the full dropdown width
292            }
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    /// Renders an action menu item with proper hotkey underlining.
308    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        // First, fill the entire line with the background color
321        for i in 0..width {
322            buf.set_string(x + i, y, " ", style);
323        }
324
325        // Render label with hotkey underlining
326        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                // Render text before hotkey
333                let before = &label[..pos];
334                buf.set_string(current_x, y, before, style);
335                current_x += before.len() as u16;
336
337                // Render hotkey with underline
338                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                // Render text after hotkey
344                let after = &label[pos + 1..];
345                buf.set_string(current_x, y, after, style);
346                current_x += after.len() as u16;
347            } else {
348                // Hotkey not found in label, render normally
349                buf.set_string(current_x, y, label, style);
350                current_x += label.len() as u16;
351            }
352        } else {
353            // No hotkey, render normally
354            buf.set_string(current_x, y, label, style);
355            current_x += label.len() as u16;
356        }
357
358        // Render shortcut if available, positioned from the right
359        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    /// Renders a submenu item with proper hotkey underlining.
368    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        // First, fill the entire line with the background color
380        for i in 0..width {
381            buf.set_string(x + i, y, " ", style);
382        }
383
384        // Reserve space for arrow (positioned 1 cell from the right edge)
385        let arrow = "►";
386        let arrow_x = x + width.saturating_sub(2); // 2 positions from right edge
387
388        // Render label with hotkey underlining
389        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                // Render text before hotkey
396                let before = &label[..pos];
397                buf.set_string(current_x, y, before, style);
398                current_x += before.len() as u16;
399
400                // Render hotkey with underline
401                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                // Render text after hotkey
407                let after = &label[pos + 1..];
408                buf.set_string(current_x, y, after, style);
409                current_x += after.len() as u16;
410            } else {
411                // Hotkey not found in label, render normally
412                buf.set_string(current_x, y, label, style);
413                current_x += label.len() as u16;
414            }
415        } else {
416            // No hotkey, render normally
417            buf.set_string(current_x, y, label, style);
418            current_x += label.len() as u16;
419        }
420
421        // Render arrow indicator (positioned 1 cell from the right edge)
422        if arrow_x > current_x {
423            buf.set_string(arrow_x, y, arrow, style);
424        }
425    }
426}
427
428/// Stateful widget implementation for cases where you need to pass state.
429impl 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        // An empty menu bar should render as all spaces with menu bar style
455        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        // Menu bar should render "  File   Edit  " (with proper spacing)
472        let mut expected = Buffer::with_lines(["  File   Edit  "]);
473        // Apply the menu bar style to the entire line
474        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        // Use larger area to accommodate full dropdown
497        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        // Create expected buffer with the actual rendered content
503        let mut expected = Buffer::with_lines([
504            "  File   Edit       ", // Menu bar (with padding)
505            " ┌──────┐           ", // Dropdown top border
506            " │New   │           ", // First menu item
507            " │Open  │           ", // Second menu item
508            " ├──────┤           ", // Separator spanning full width
509            " │Save  │           ", // Third menu item
510            " │Exit  │           ", // Fourth menu item
511            " └──────┘           ", // Dropdown bottom border
512        ]);
513
514        // Reset styles on both buffers to focus on content only
515        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        // Test action item display
528        assert_eq!(action_item.label(), Some("New"));
529        assert!(action_item.is_selectable());
530
531        // Test separator item display
532        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        // Edit menu should be positioned after "File" menu
544        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        // Test that menu width affects dropdown positioning
556        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        // Second dropdown should be positioned after the first menu
563        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        // Initially buffer should be empty
574        let initial_content = buffer[(0, 0)].symbol();
575        assert_eq!(initial_content, " ");
576
577        // Clear the area with a specific style
578        menu_bar.clear_area(area, &mut buffer, style);
579
580        // Verify the area is cleared with spaces and proper style
581        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        // Create a larger area with some existing content
598        let area = Rect::new(0, 0, 20, 5);
599        let mut buffer = Buffer::empty(area);
600
601        // Fill the buffer with some existing content
602        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        // Render the menu bar
610        Widget::render(menu_bar, area, &mut buffer);
611
612        // First line should be styled as menu bar
613        for x in 0..20 {
614            let cell = &buffer[(x, 0)];
615            // Content should be menu bar content or spaces, not the original "X"
616            assert_ne!(cell.symbol(), "X");
617        }
618
619        // Lines below should retain original content and style
620        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        // Create area with existing content
643        let area = Rect::new(0, 0, 15, 6);
644        let mut buffer = Buffer::empty(area);
645
646        // Fill with background content
647        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        // Render the menu bar
655        Widget::render(menu_bar, area, &mut buffer);
656
657        // First line should be menu bar (overwriting background)
658        for x in 0..15 {
659            let cell = &buffer[(x, 0)];
660            assert_ne!(cell.symbol(), "Z");
661        }
662
663        // Dropdown area should be cleared and contain menu content
664        // (approximately x=1-7, y=1-4 based on dropdown size)
665        for y in 1..4 {
666            for x in 1..8 {
667                let cell = &buffer[(x, y)];
668                // Should not be the background "Z" in dropdown area
669                assert_ne!(cell.symbol(), "Z");
670            }
671        }
672
673        // Areas outside dropdown should retain background content
674        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        // Check that the separator line exists and has proper styling
704        // The separator should be on line 3 (0=menu bar, 1=border, 2=Cut, 3=separator)
705        let separator_y = 3;
706
707        // The separator now spans the full dropdown width, starting at dropdown's x position
708        // Find the dropdown area first
709        let mut dropdown_x = 0;
710        let mut dropdown_width = 0;
711
712        // Look for the dropdown border to determine its position and width
713        for x in 0..20 {
714            if buffer[(x, 1)].symbol() == "┌" {
715                // Top-left corner of dropdown
716                dropdown_x = x;
717                break;
718            }
719        }
720
721        // Find the width by looking for the top-right corner
722        for x in dropdown_x + 1..20 {
723            if buffer[(x, 1)].symbol() == "┐" {
724                // Top-right corner
725                dropdown_width = x - dropdown_x + 1;
726                break;
727            }
728        }
729
730        // Check the left connector of separator
731        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        // Check the horizontal line
737        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        // Check the right connector
743        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        // Check that hotkeys are underlined
769        // "New File" should have 'N' underlined
770        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        // "Open" should have 'O' underlined
786        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        // Focus the first item manually by setting focused_item
818        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        // Check that the focused item has white background (selection highlighting)
827        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        // Check that non-focused areas have blue background
843        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        // Open the View menu and the Theme submenu
871        menu_bar.open_menu(0);
872        if let Some(menu) = menu_bar.opened_menu_mut() {
873            menu.focused_item = Some(0); // Focus the Theme submenu
874            if let Some(MenuItem::SubMenu(submenu)) = menu.items.get_mut(0) {
875                submenu.is_open = true;
876                submenu.focused_item = Some(0); // Focus Light theme
877            }
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        // Check that the submenu items are rendered
884        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}