Skip to main content

ratatui_toolkit/master_layout/
navigation_bar.rs

1//! Navigation bar for tab management
2
3use ratatui::{
4    buffer::Buffer,
5    layout::Rect,
6    style::{Color, Modifier, Style},
7    text::Span,
8    widgets::{Block, Borders, Widget},
9};
10
11/// A button in the navigation bar representing a tab
12#[derive(Debug, Clone)]
13pub struct TabButton {
14    pub label: String,
15    pub area: Rect,
16}
17
18impl TabButton {
19    pub fn new(label: impl Into<String>) -> Self {
20        Self {
21            label: label.into(),
22            area: Rect::default(),
23        }
24    }
25
26    /// Check if coordinates are within this button
27    pub fn contains(&self, x: u16, y: u16) -> bool {
28        x >= self.area.x
29            && x < self.area.x + self.area.width
30            && y >= self.area.y
31            && y < self.area.y + self.area.height
32    }
33}
34
35/// Navigation bar component for displaying tabs
36pub struct NavigationBar {
37    buttons: Vec<TabButton>,
38    active_index: usize,
39}
40
41impl NavigationBar {
42    /// Create a new navigation bar with tab labels
43    pub fn new(labels: Vec<String>) -> Self {
44        let buttons = labels.into_iter().map(TabButton::new).collect();
45        Self {
46            buttons,
47            active_index: 0,
48        }
49    }
50
51    /// Set the active tab index
52    pub fn set_active(&mut self, index: usize) {
53        if index < self.buttons.len() {
54            self.active_index = index;
55        }
56    }
57
58    /// Get the active tab index
59    pub fn active_index(&self) -> usize {
60        self.active_index
61    }
62
63    /// Get number of tabs
64    pub fn tab_count(&self) -> usize {
65        self.buttons.len()
66    }
67
68    /// Handle a mouse click, returns the clicked tab index if any
69    pub fn handle_click(&self, x: u16, y: u16) -> Option<usize> {
70        self.buttons.iter().position(|button| button.contains(x, y))
71    }
72
73    /// Render the navigation bar with explicit active index
74    pub fn render_with_active(&mut self, area: Rect, buf: &mut Buffer, active_index: usize) {
75        self.render_with_active_and_offset(area, buf, active_index, 0);
76    }
77
78    /// Render the navigation bar with explicit active index and left offset
79    pub fn render_with_active_and_offset(
80        &mut self,
81        area: Rect,
82        buf: &mut Buffer,
83        active_index: usize,
84        left_offset: u16,
85    ) {
86        // Update active index
87        self.active_index = active_index;
88
89        // Calculate button areas
90        let button_count = self.buttons.len();
91        let available_width = area.width.saturating_sub(2).saturating_sub(left_offset);
92        let button_width = if button_count > 0 {
93            available_width / button_count as u16
94        } else {
95            0
96        };
97
98        let mut current_x = area.x + 1 + left_offset;
99
100        for (i, button) in self.buttons.iter_mut().enumerate() {
101            // Calculate button area
102            let width = if i == button_count - 1 {
103                // Last button takes remaining space
104                area.width
105                    .saturating_sub(current_x - area.x)
106                    .saturating_sub(1)
107            } else {
108                button_width
109            };
110
111            button.area = Rect::new(current_x, area.y, width, 1);
112
113            // Render button
114            let is_active = i == self.active_index;
115            let style = if is_active {
116                Style::default()
117                    .fg(Color::Black)
118                    .bg(Color::Cyan)
119                    .add_modifier(Modifier::BOLD)
120            } else {
121                Style::default().fg(Color::White)
122            };
123
124            let label = format!(" {} ", button.label);
125            let span = Span::styled(label, style);
126
127            // Render directly to buffer
128            let mut x = button.area.x;
129            for grapheme in span.content.chars() {
130                if x < button.area.x + button.area.width {
131                    buf[(x, button.area.y)]
132                        .set_char(grapheme)
133                        .set_style(span.style);
134                    x += 1;
135                }
136            }
137
138            current_x += width;
139        }
140
141        // Draw border around the nav bar
142        let block = Block::default()
143            .borders(Borders::ALL)
144            .border_style(Style::default().fg(Color::DarkGray));
145        block.render(area, buf);
146    }
147}
148
149impl Widget for NavigationBar {
150    fn render(mut self, area: Rect, buf: &mut Buffer) {
151        let active_index = self.active_index;
152        self.render_with_active(area, buf, active_index);
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn test_tab_button_creation() {
162        let button = TabButton::new("Test");
163        assert_eq!(button.label, "Test");
164        assert_eq!(button.area, Rect::default());
165    }
166
167    #[test]
168    fn test_tab_button_contains() {
169        let mut button = TabButton::new("Test");
170        button.area = Rect::new(10, 0, 20, 1);
171
172        assert!(button.contains(15, 0));
173        assert!(button.contains(10, 0)); // Left edge
174        assert!(button.contains(29, 0)); // Right edge - 1
175
176        assert!(!button.contains(5, 0)); // Before
177        assert!(!button.contains(30, 0)); // After
178        assert!(!button.contains(15, 1)); // Different row
179    }
180
181    #[test]
182    fn test_navigation_bar_creation() {
183        let labels = vec!["Tab 1".to_string(), "Tab 2".to_string()];
184        let nav_bar = NavigationBar::new(labels);
185
186        assert_eq!(nav_bar.tab_count(), 2);
187        assert_eq!(nav_bar.active_index(), 0);
188    }
189
190    #[test]
191    fn test_set_active() {
192        let labels = vec![
193            "Tab 1".to_string(),
194            "Tab 2".to_string(),
195            "Tab 3".to_string(),
196        ];
197        let mut nav_bar = NavigationBar::new(labels);
198
199        nav_bar.set_active(1);
200        assert_eq!(nav_bar.active_index(), 1);
201
202        nav_bar.set_active(2);
203        assert_eq!(nav_bar.active_index(), 2);
204
205        // Invalid index should be ignored
206        nav_bar.set_active(10);
207        assert_eq!(nav_bar.active_index(), 2);
208    }
209
210    #[test]
211    fn test_handle_click() {
212        let labels = vec!["Tab 1".to_string(), "Tab 2".to_string()];
213        let mut nav_bar = NavigationBar::new(labels);
214
215        // Setup button areas
216        nav_bar.buttons[0].area = Rect::new(1, 0, 10, 1);
217        nav_bar.buttons[1].area = Rect::new(11, 0, 10, 1);
218
219        assert_eq!(nav_bar.handle_click(5, 0), Some(0));
220        assert_eq!(nav_bar.handle_click(15, 0), Some(1));
221        assert_eq!(nav_bar.handle_click(25, 0), None);
222    }
223
224    #[test]
225    fn test_render_calculates_button_areas() {
226        let labels = vec!["Tab 1".to_string(), "Tab 2".to_string()];
227        let mut nav_bar = NavigationBar::new(labels);
228
229        let area = Rect::new(0, 0, 80, 1);
230        let mut buffer = Buffer::empty(area);
231
232        nav_bar.render_with_active(area, &mut buffer, 0);
233
234        // Check that button areas were calculated
235        assert_ne!(nav_bar.buttons[0].area, Rect::default());
236        assert_ne!(nav_bar.buttons[1].area, Rect::default());
237
238        // Buttons should span the width
239        let total_width: u16 = nav_bar.buttons.iter().map(|b| b.area.width).sum();
240        assert!(total_width <= area.width);
241    }
242
243    #[test]
244    fn test_empty_navigation_bar() {
245        let nav_bar = NavigationBar::new(vec![]);
246        assert_eq!(nav_bar.tab_count(), 0);
247        assert_eq!(nav_bar.handle_click(10, 0), None);
248    }
249
250    #[test]
251    fn test_single_tab() {
252        let labels = vec!["Only Tab".to_string()];
253        let nav_bar = NavigationBar::new(labels);
254        assert_eq!(nav_bar.tab_count(), 1);
255        assert_eq!(nav_bar.active_index(), 0);
256    }
257
258    #[test]
259    fn test_many_tabs() {
260        let labels = (1..=10).map(|i| format!("Tab {}", i)).collect();
261        let nav_bar = NavigationBar::new(labels);
262        assert_eq!(nav_bar.tab_count(), 10);
263    }
264}