ratatui_toolkit/master_layout/
navigation_bar.rs1use ratatui::{
4 buffer::Buffer,
5 layout::Rect,
6 style::{Color, Modifier, Style},
7 text::Span,
8 widgets::{Block, Borders, Widget},
9};
10
11#[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 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
35pub struct NavigationBar {
37 buttons: Vec<TabButton>,
38 active_index: usize,
39}
40
41impl NavigationBar {
42 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 pub fn set_active(&mut self, index: usize) {
53 if index < self.buttons.len() {
54 self.active_index = index;
55 }
56 }
57
58 pub fn active_index(&self) -> usize {
60 self.active_index
61 }
62
63 pub fn tab_count(&self) -> usize {
65 self.buttons.len()
66 }
67
68 pub fn handle_click(&self, x: u16, y: u16) -> Option<usize> {
70 self.buttons.iter().position(|button| button.contains(x, y))
71 }
72
73 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 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 self.active_index = active_index;
88
89 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 let width = if i == button_count - 1 {
103 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 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 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 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)); assert!(button.contains(29, 0)); assert!(!button.contains(5, 0)); assert!(!button.contains(30, 0)); assert!(!button.contains(15, 1)); }
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 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 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 assert_ne!(nav_bar.buttons[0].area, Rect::default());
236 assert_ne!(nav_bar.buttons[1].area, Rect::default());
237
238 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}