Skip to main content

ratatui_toolkit/
menu_bar.rs

1use ratatui::layout::Rect;
2use ratatui::style::{Color, Modifier, Style};
3use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
4use ratatui::Frame;
5
6/// Calculate the actual display width of a string, accounting for Nerd Font icons
7/// Nerd Font icons (U+F000-U+F8FF private use area) are rendered as 2 cells wide
8fn display_width(s: &str) -> usize {
9    s.chars()
10        .map(|c| {
11            let code = c as u32;
12            if (0xF000..=0xF8FF).contains(&code) {
13                // Nerd Font icon - 2 cells wide
14                2
15            } else {
16                // Use unicode-width for everything else
17                unicode_width::UnicodeWidthChar::width(c).unwrap_or(1)
18            }
19        })
20        .sum()
21}
22
23/// A single menu item
24#[derive(Debug, Clone)]
25pub struct MenuItem {
26    /// The display name of the menu item
27    pub name: String,
28    /// Optional icon to display on the left (can be Nerd Font icon or emoji)
29    pub icon: Option<String>,
30    /// Internal index/value
31    pub value: usize,
32    /// Whether this item is currently selected
33    pub selected: bool,
34    /// Whether the mouse is hovering over this item
35    pub hovered: bool,
36    /// The rendered area of this item
37    pub area: Option<Rect>,
38}
39
40impl MenuItem {
41    /// Create a new menu item with just a name
42    pub fn new(name: impl Into<String>, value: usize) -> Self {
43        Self {
44            name: name.into(),
45            icon: None,
46            value,
47            selected: false,
48            hovered: false,
49            area: None,
50        }
51    }
52
53    /// Create a new menu item with an icon
54    pub fn with_icon(name: impl Into<String>, icon: impl Into<String>, value: usize) -> Self {
55        Self {
56            name: name.into(),
57            icon: Some(icon.into()),
58            value,
59            selected: false,
60            hovered: false,
61            area: None,
62        }
63    }
64
65    /// Get the full display label (icon + name)
66    pub fn display_label(&self) -> String {
67        if let Some(ref icon) = self.icon {
68            format!("{} {}", icon, self.name)
69        } else {
70            self.name.clone()
71        }
72    }
73}
74
75/// A horizontal menu bar with selectable items
76#[derive(Debug, Clone)]
77pub struct MenuBar {
78    pub items: Vec<MenuItem>,
79    pub area: Option<Rect>,
80
81    // Styling
82    pub normal_style: Style,
83    pub selected_style: Style,
84    pub hover_style: Style,
85    pub selected_hover_style: Style,
86}
87
88impl MenuBar {
89    /// Create a new menu bar with menu items
90    pub fn new(items: Vec<MenuItem>) -> Self {
91        Self {
92            items,
93            area: None,
94            normal_style: Style::default().fg(Color::White),
95            selected_style: Style::default()
96                .fg(Color::Cyan)
97                .add_modifier(Modifier::BOLD),
98            hover_style: Style::default().fg(Color::Cyan),
99            selected_hover_style: Style::default()
100                .fg(Color::Cyan)
101                .add_modifier(Modifier::BOLD),
102        }
103    }
104
105    /// Set initial selected button (by index)
106    pub fn with_selected(mut self, index: usize) -> Self {
107        if index < self.items.len() {
108            self.items[index].selected = true;
109        }
110        self
111    }
112
113    /// Set custom normal style
114    pub fn normal_style(mut self, style: Style) -> Self {
115        self.normal_style = style;
116        self
117    }
118
119    /// Set custom selected style
120    pub fn selected_style(mut self, style: Style) -> Self {
121        self.selected_style = style;
122        self
123    }
124
125    /// Set custom hover style
126    pub fn hover_style(mut self, style: Style) -> Self {
127        self.hover_style = style;
128        self
129    }
130
131    /// Set custom selected hover style
132    pub fn selected_hover_style(mut self, style: Style) -> Self {
133        self.selected_hover_style = style;
134        self
135    }
136
137    /// Update hover state based on mouse position
138    pub fn update_hover(&mut self, column: u16, row: u16) {
139        for item in &mut self.items {
140            item.hovered = if let Some(area) = item.area {
141                column >= area.x
142                    && column < area.x + area.width
143                    && row >= area.y
144                    && row < area.y + area.height
145            } else {
146                false
147            };
148        }
149    }
150
151    /// Handle click at position, returns the index of clicked menu item if any
152    pub fn handle_click(&mut self, column: u16, row: u16) -> Option<usize> {
153        // Find which menu item was clicked
154        let clicked_index = self.items.iter().enumerate().find_map(|(i, item)| {
155            if let Some(area) = item.area {
156                if column >= area.x
157                    && column < area.x + area.width
158                    && row >= area.y
159                    && row < area.y + area.height
160                {
161                    return Some(i);
162                }
163            }
164            None
165        });
166
167        // Update selection state (menu bar is always single selection)
168        if let Some(clicked) = clicked_index {
169            // Deselect all others, select the clicked one
170            for (i, item) in self.items.iter_mut().enumerate() {
171                item.selected = i == clicked;
172            }
173        }
174
175        clicked_index
176    }
177
178    /// Get currently selected menu item index
179    pub fn selected(&self) -> Option<usize> {
180        self.items.iter().position(|item| item.selected)
181    }
182
183    /// Render the menu bar as a connected series of items with rounded border
184    pub fn render(&mut self, frame: &mut Frame, area: Rect) {
185        self.render_with_offset(frame, area, 0);
186    }
187
188    /// Render the menu bar with a left offset to make room for other components
189    pub fn render_with_offset(&mut self, frame: &mut Frame, area: Rect, left_offset: u16) {
190        if self.items.is_empty() {
191            return;
192        }
193
194        // Calculate required width based on menu items using proper display width
195        // Items have no padding, separators have spacing, 1 space at start and end
196        let total_label_width: usize = self
197            .items
198            .iter()
199            .map(|item| display_width(&item.display_label()))
200            .sum();
201        let separators = (self.items.len() - 1) * 3; // " │ " between items (1 space + separator + 1 space)
202        let needed_width = (total_label_width + separators + 4) as u16; // +2 for borders + 2 for start/end spaces
203
204        // Adjust area to account for left offset
205        let available_width = area.width.saturating_sub(left_offset);
206
207        // Create a fixed-width area for the button group, shifted by the offset
208        let button_group_area = Rect {
209            x: area.x + left_offset,
210            y: area.y,
211            width: needed_width.min(available_width),
212            height: area.height,
213        };
214
215        self.area = Some(button_group_area);
216
217        // Create a block with rounded border around the entire group
218        let block = Block::default()
219            .borders(Borders::ALL)
220            .border_type(BorderType::Rounded);
221
222        let inner_area = block.inner(button_group_area);
223        frame.render_widget(block, button_group_area);
224
225        // Start with 1 space padding from the left border
226        let mut x_offset = inner_area.x + 1;
227        let button_count = self.items.len();
228
229        for (i, item) in self.items.iter_mut().enumerate() {
230            // No padding on menu item - use custom display width for Nerd Font icons
231            let label = item.display_label();
232            let item_width = display_width(&label) as u16;
233
234            // Check if we have space left to render this item
235            let available_width = (inner_area.x + inner_area.width).saturating_sub(x_offset);
236            if available_width == 0 {
237                break; // No space left
238            }
239
240            // Limit item width to available space
241            let actual_item_width = item_width.min(available_width);
242
243            let item_area = Rect {
244                x: x_offset,
245                y: inner_area.y,
246                width: actual_item_width,
247                height: inner_area.height,
248            };
249
250            item.area = Some(item_area);
251
252            // Determine style based on state
253            let style = match (item.selected, item.hovered) {
254                (true, true) => self.selected_hover_style,
255                (true, false) => self.selected_style,
256                (false, true) => self.hover_style,
257                (false, false) => self.normal_style,
258            };
259
260            // Create menu item text with no padding
261            // Truncate label if needed to fit available space
262            let display_label = if actual_item_width < item_width {
263                // Truncate the label to fit
264                label
265                    .chars()
266                    .take(actual_item_width as usize)
267                    .collect::<String>()
268            } else {
269                label
270            };
271            let paragraph = Paragraph::new(display_label).style(style);
272            frame.render_widget(paragraph, item_area);
273
274            x_offset += actual_item_width;
275
276            // Render separator after item (except for last item)
277            // Check if there's enough space left before rendering
278            if i < button_count - 1 && x_offset + 3 <= inner_area.x + inner_area.width {
279                let separator_area = Rect {
280                    x: x_offset,
281                    y: inner_area.y,
282                    width: 3, // " │ " (1 space + separator + 1 space)
283                    height: inner_area.height,
284                };
285                let separator = Paragraph::new(" │ ");
286                frame.render_widget(separator, separator_area);
287                x_offset += 3;
288            }
289        }
290    }
291
292    /// Render with a centered layout (useful for menu bars)
293    pub fn render_centered(&mut self, frame: &mut Frame, area: Rect) {
294        use ratatui::layout::{Constraint, Direction, Layout};
295
296        // Calculate total width needed for all menu items
297        let total_chars: usize = self
298            .items
299            .iter()
300            .map(|item| display_width(&item.display_label()) + 4)
301            .sum(); // +4 for borders/padding
302        let needed_width = total_chars as u16;
303
304        // Create a centered layout
305        let chunks = Layout::default()
306            .direction(Direction::Horizontal)
307            .constraints([
308                Constraint::Length((area.width.saturating_sub(needed_width)) / 2),
309                Constraint::Length(needed_width.min(area.width)),
310                Constraint::Min(0),
311            ])
312            .split(area);
313
314        self.render(frame, chunks[1]);
315    }
316}
317
318impl Default for MenuBar {
319    fn default() -> Self {
320        Self::new(vec![MenuItem::new("Menu Item", 0)])
321    }
322}