1use crate::buffer::ScreenBuffer;
8use crate::cell::Cell;
9use crate::event::{Event, KeyCode, KeyEvent, Modifiers};
10use crate::geometry::Rect;
11use crate::segment::Segment;
12use crate::style::Style;
13use crate::text::{string_display_width, truncate_to_display_width};
14use unicode_width::UnicodeWidthStr;
15
16use super::{BorderStyle, EventResult, InteractiveWidget, Widget};
17
18#[derive(Clone, Debug)]
20pub struct Tab {
21 pub label: String,
23 pub content: Vec<Vec<Segment>>,
25 pub closable: bool,
27}
28
29impl Tab {
30 pub fn new(label: &str) -> Self {
32 Self {
33 label: label.to_string(),
34 content: Vec::new(),
35 closable: false,
36 }
37 }
38
39 #[must_use]
41 pub fn with_content(mut self, content: Vec<Vec<Segment>>) -> Self {
42 self.content = content;
43 self
44 }
45
46 #[must_use]
48 pub fn with_closable(mut self, closable: bool) -> Self {
49 self.closable = closable;
50 self
51 }
52}
53
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum TabBarPosition {
57 Top,
59 Bottom,
61}
62
63pub struct Tabs {
69 tabs: Vec<Tab>,
71 active_tab: usize,
73 tab_bar_style: Style,
75 active_tab_style: Style,
77 inactive_tab_style: Style,
79 content_style: Style,
81 border: BorderStyle,
83 tab_bar_position: TabBarPosition,
85}
86
87impl Tabs {
88 pub fn new(tabs: Vec<Tab>) -> Self {
90 Self {
91 tabs,
92 active_tab: 0,
93 tab_bar_style: Style::default(),
94 active_tab_style: Style::default().reverse(true),
95 inactive_tab_style: Style::default(),
96 content_style: Style::default(),
97 border: BorderStyle::None,
98 tab_bar_position: TabBarPosition::Top,
99 }
100 }
101
102 #[must_use]
104 pub fn with_tab_bar_style(mut self, style: Style) -> Self {
105 self.tab_bar_style = style;
106 self
107 }
108
109 #[must_use]
111 pub fn with_active_tab_style(mut self, style: Style) -> Self {
112 self.active_tab_style = style;
113 self
114 }
115
116 #[must_use]
118 pub fn with_inactive_tab_style(mut self, style: Style) -> Self {
119 self.inactive_tab_style = style;
120 self
121 }
122
123 #[must_use]
125 pub fn with_content_style(mut self, style: Style) -> Self {
126 self.content_style = style;
127 self
128 }
129
130 #[must_use]
132 pub fn with_border(mut self, border: BorderStyle) -> Self {
133 self.border = border;
134 self
135 }
136
137 #[must_use]
139 pub fn with_tab_bar_position(mut self, pos: TabBarPosition) -> Self {
140 self.tab_bar_position = pos;
141 self
142 }
143
144 pub fn add_tab(&mut self, tab: Tab) {
146 self.tabs.push(tab);
147 }
148
149 pub fn active_tab(&self) -> usize {
151 self.active_tab
152 }
153
154 pub fn set_active_tab(&mut self, idx: usize) {
156 if self.tabs.is_empty() {
157 self.active_tab = 0;
158 } else {
159 self.active_tab = idx.min(self.tabs.len().saturating_sub(1));
160 }
161 }
162
163 pub fn active_content(&self) -> Option<&[Vec<Segment>]> {
165 self.tabs.get(self.active_tab).map(|t| t.content.as_slice())
166 }
167
168 pub fn close_tab(&mut self, idx: usize) -> bool {
172 if let Some(tab) = self.tabs.get(idx) {
173 if !tab.closable {
174 return false;
175 }
176 } else {
177 return false;
178 }
179
180 self.tabs.remove(idx);
181
182 if self.tabs.is_empty() {
184 self.active_tab = 0;
185 } else if self.active_tab >= self.tabs.len() {
186 self.active_tab = self.tabs.len().saturating_sub(1);
187 }
188
189 true
190 }
191
192 pub fn tab_count(&self) -> usize {
194 self.tabs.len()
195 }
196
197 fn next_tab(&mut self) {
199 if !self.tabs.is_empty() {
200 self.active_tab = (self.active_tab + 1) % self.tabs.len();
201 }
202 }
203
204 fn prev_tab(&mut self) {
206 if !self.tabs.is_empty() {
207 if self.active_tab == 0 {
208 self.active_tab = self.tabs.len().saturating_sub(1);
209 } else {
210 self.active_tab -= 1;
211 }
212 }
213 }
214
215 fn render_tab_bar(&self, area_x: u16, area_y: u16, width: u16, buf: &mut ScreenBuffer) {
217 if width == 0 {
218 return;
219 }
220
221 for x in 0..width {
223 buf.set(
224 area_x + x,
225 area_y,
226 Cell::new(" ", self.tab_bar_style.clone()),
227 );
228 }
229
230 let mut col: u16 = 0;
231 let w = width as usize;
232
233 for (i, tab) in self.tabs.iter().enumerate() {
234 if col as usize >= w {
235 break;
236 }
237
238 if i > 0 && (col as usize) < w {
240 buf.set(
241 area_x + col,
242 area_y,
243 Cell::new("│", self.tab_bar_style.clone()),
244 );
245 col += 1;
246 if col as usize >= w {
247 break;
248 }
249 }
250
251 let style = if i == self.active_tab {
252 self.active_tab_style.clone()
253 } else {
254 self.inactive_tab_style.clone()
255 };
256
257 let close_suffix = if tab.closable { " ×" } else { "" };
259 let label_with_padding = format!(" {}{} ", tab.label, close_suffix);
260
261 let remaining = w.saturating_sub(col as usize);
262 let truncated = truncate_to_display_width(&label_with_padding, remaining);
263 let display_w = string_display_width(truncated);
264
265 for ch in truncated.chars() {
266 if col as usize >= w {
267 break;
268 }
269 let char_w = UnicodeWidthStr::width(ch.encode_utf8(&mut [0; 4]) as &str);
270 if col as usize + char_w > w {
271 break;
272 }
273 buf.set(
274 area_x + col,
275 area_y,
276 Cell::new(ch.to_string(), style.clone()),
277 );
278 col += char_w as u16;
279 }
280
281 let _ = display_w;
283 }
284 }
285
286 fn render_content(
288 &self,
289 area_x: u16,
290 area_y: u16,
291 width: u16,
292 height: u16,
293 buf: &mut ScreenBuffer,
294 ) {
295 let content = match self.active_content() {
296 Some(c) => c,
297 None => return,
298 };
299
300 let w = width as usize;
301
302 for (row, line) in content.iter().enumerate() {
303 if row >= height as usize {
304 break;
305 }
306 let y = area_y + row as u16;
307 let mut col: u16 = 0;
308
309 for segment in line {
310 if col as usize >= w {
311 break;
312 }
313 let remaining = w.saturating_sub(col as usize);
314 let truncated = truncate_to_display_width(&segment.text, remaining);
315
316 for ch in truncated.chars() {
317 if col as usize >= w {
318 break;
319 }
320 let char_w = UnicodeWidthStr::width(ch.encode_utf8(&mut [0; 4]) as &str);
321 if col as usize + char_w > w {
322 break;
323 }
324 let x = area_x + col;
325 buf.set(x, y, Cell::new(ch.to_string(), segment.style.clone()));
326 col += char_w as u16;
327 }
328 }
329 }
330 }
331}
332
333impl Widget for Tabs {
334 fn render(&self, area: Rect, buf: &mut ScreenBuffer) {
335 if area.size.width == 0 || area.size.height == 0 {
336 return;
337 }
338
339 super::border::render_border(area, self.border, self.tab_bar_style.clone(), buf);
341
342 let inner = super::border::inner_area(area, self.border);
343 if inner.size.width == 0 || inner.size.height == 0 {
344 return;
345 }
346
347 let w = inner.size.width;
348 let h = inner.size.height;
349
350 if h == 0 {
351 return;
352 }
353
354 match self.tab_bar_position {
355 TabBarPosition::Top => {
356 self.render_tab_bar(inner.position.x, inner.position.y, w, buf);
358 if h > 1 {
360 self.render_content(inner.position.x, inner.position.y + 1, w, h - 1, buf);
361 }
362 }
363 TabBarPosition::Bottom => {
364 if h > 1 {
366 self.render_content(inner.position.x, inner.position.y, w, h - 1, buf);
367 }
368 let bar_y = inner.position.y + h - 1;
370 self.render_tab_bar(inner.position.x, bar_y, w, buf);
371 }
372 }
373 }
374}
375
376impl InteractiveWidget for Tabs {
377 fn handle_event(&mut self, event: &Event) -> EventResult {
378 let Event::Key(KeyEvent {
379 code, modifiers, ..
380 }) = event
381 else {
382 return EventResult::Ignored;
383 };
384
385 match code {
386 KeyCode::Left => {
387 self.prev_tab();
388 EventResult::Consumed
389 }
390 KeyCode::Right => {
391 self.next_tab();
392 EventResult::Consumed
393 }
394 KeyCode::Tab if !modifiers.contains(Modifiers::SHIFT) => {
395 self.next_tab();
396 EventResult::Consumed
397 }
398 KeyCode::Tab if modifiers.contains(Modifiers::SHIFT) => {
399 self.prev_tab();
400 EventResult::Consumed
401 }
402 KeyCode::Char('w') if modifiers.contains(Modifiers::CTRL) => {
404 self.close_tab(self.active_tab);
405 EventResult::Consumed
406 }
407 _ => EventResult::Ignored,
408 }
409 }
410}
411
412#[cfg(test)]
413#[allow(clippy::unwrap_used)]
414mod tests {
415 use super::*;
416 use crate::geometry::Size;
417
418 fn make_tab(label: &str, lines: &[&str]) -> Tab {
419 Tab {
420 label: label.to_string(),
421 content: lines.iter().map(|l| vec![Segment::new(*l)]).collect(),
422 closable: false,
423 }
424 }
425
426 fn make_closable_tab(label: &str) -> Tab {
427 Tab {
428 label: label.to_string(),
429 content: vec![vec![Segment::new("content")]],
430 closable: true,
431 }
432 }
433
434 #[test]
435 fn create_with_multiple_tabs() {
436 let tabs = Tabs::new(vec![
437 make_tab("Tab1", &["line1"]),
438 make_tab("Tab2", &["line2"]),
439 make_tab("Tab3", &["line3"]),
440 ]);
441 assert_eq!(tabs.tab_count(), 3);
442 assert_eq!(tabs.active_tab(), 0);
443 }
444
445 #[test]
446 fn render_tab_bar_at_top() {
447 let tabs = Tabs::new(vec![
448 make_tab("Alpha", &["content A"]),
449 make_tab("Beta", &["content B"]),
450 ]);
451 let mut buf = ScreenBuffer::new(Size::new(40, 5));
452 tabs.render(Rect::new(0, 0, 40, 5), &mut buf);
453
454 let row0: String = (0..40)
456 .map(|x| buf.get(x, 0).map(|c| c.grapheme.as_str()).unwrap_or(" "))
457 .collect::<String>();
458 assert!(row0.contains("Alpha"));
459 assert!(row0.contains("Beta"));
460 }
461
462 #[test]
463 fn render_tab_bar_at_bottom() {
464 let tabs = Tabs::new(vec![make_tab("X", &["content"]), make_tab("Y", &["data"])])
465 .with_tab_bar_position(TabBarPosition::Bottom);
466
467 let mut buf = ScreenBuffer::new(Size::new(30, 4));
468 tabs.render(Rect::new(0, 0, 30, 4), &mut buf);
469
470 let last_row: String = (0..30)
472 .map(|x| buf.get(x, 3).map(|c| c.grapheme.as_str()).unwrap_or(" "))
473 .collect::<String>();
474 assert!(last_row.contains("X"));
475 assert!(last_row.contains("Y"));
476 }
477
478 #[test]
479 fn active_tab_content() {
480 let tabs = Tabs::new(vec![make_tab("A", &["line A"]), make_tab("B", &["line B"])]);
481
482 let content = tabs.active_content().unwrap();
483 assert_eq!(content.len(), 1);
484 assert_eq!(content[0][0].text, "line A");
485 }
486
487 #[test]
488 fn switch_tabs_left_right() {
489 let mut tabs = Tabs::new(vec![
490 make_tab("1", &[]),
491 make_tab("2", &[]),
492 make_tab("3", &[]),
493 ]);
494 assert_eq!(tabs.active_tab(), 0);
495
496 tabs.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Right)));
498 assert_eq!(tabs.active_tab(), 1);
499
500 tabs.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Right)));
502 assert_eq!(tabs.active_tab(), 2);
503
504 tabs.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Right)));
506 assert_eq!(tabs.active_tab(), 0);
507
508 tabs.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Left)));
510 assert_eq!(tabs.active_tab(), 2);
511 }
512
513 #[test]
514 fn tab_key_navigation() {
515 let mut tabs = Tabs::new(vec![make_tab("A", &[]), make_tab("B", &[])]);
516
517 let result = tabs.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Tab)));
519 assert_eq!(result, EventResult::Consumed);
520 assert_eq!(tabs.active_tab(), 1);
521
522 let result = tabs.handle_event(&Event::Key(KeyEvent::new(KeyCode::Tab, Modifiers::SHIFT)));
524 assert_eq!(result, EventResult::Consumed);
525 assert_eq!(tabs.active_tab(), 0);
526 }
527
528 #[test]
529 fn close_closable_tab() {
530 let mut tabs = Tabs::new(vec![
531 make_closable_tab("C1"),
532 make_closable_tab("C2"),
533 make_closable_tab("C3"),
534 ]);
535
536 tabs.set_active_tab(1);
537 assert!(tabs.close_tab(1));
538 assert_eq!(tabs.tab_count(), 2);
539 assert_eq!(tabs.active_tab(), 1);
541 }
542
543 #[test]
544 fn non_closable_tab_ignores_close() {
545 let mut tabs = Tabs::new(vec![make_tab("Fixed", &["data"])]);
546 assert!(!tabs.close_tab(0));
547 assert_eq!(tabs.tab_count(), 1);
548 }
549
550 #[test]
551 fn empty_tabs_list() {
552 let tabs = Tabs::new(vec![]);
553 assert_eq!(tabs.tab_count(), 0);
554 assert_eq!(tabs.active_tab(), 0);
555 assert!(tabs.active_content().is_none());
556
557 let mut buf = ScreenBuffer::new(Size::new(20, 5));
559 tabs.render(Rect::new(0, 0, 20, 5), &mut buf);
560 }
561
562 #[test]
563 fn single_tab() {
564 let tabs = Tabs::new(vec![make_tab("Only", &["data"])]);
565 assert_eq!(tabs.tab_count(), 1);
566 assert_eq!(tabs.active_tab(), 0);
567
568 let content = tabs.active_content().unwrap();
569 assert_eq!(content[0][0].text, "data");
570 }
571
572 #[test]
573 fn set_active_tab_clamping() {
574 let mut tabs = Tabs::new(vec![make_tab("A", &[]), make_tab("B", &[])]);
575
576 tabs.set_active_tab(100);
577 assert_eq!(tabs.active_tab(), 1); tabs.set_active_tab(0);
580 assert_eq!(tabs.active_tab(), 0);
581 }
582
583 #[test]
584 fn utf8_safe_tab_labels() {
585 let tabs = Tabs::new(vec![
586 make_tab("日本語", &["content"]),
587 make_tab("中文标签", &["data"]),
588 ]);
589
590 let mut buf = ScreenBuffer::new(Size::new(40, 3));
591 tabs.render(Rect::new(0, 0, 40, 3), &mut buf);
592
593 let row0: String = (0..40)
594 .map(|x| buf.get(x, 0).map(|c| c.grapheme.as_str()).unwrap_or(" "))
595 .collect::<String>();
596 assert!(row0.contains("日本語"));
597 }
598
599 #[test]
600 fn border_rendering() {
601 let tabs = Tabs::new(vec![make_tab("T", &["c"])]).with_border(BorderStyle::Single);
602
603 let mut buf = ScreenBuffer::new(Size::new(20, 5));
604 tabs.render(Rect::new(0, 0, 20, 5), &mut buf);
605
606 assert_eq!(buf.get(0, 0).unwrap().grapheme, "┌");
608 assert_eq!(buf.get(19, 0).unwrap().grapheme, "┐");
609 }
610
611 #[test]
612 fn add_tab() {
613 let mut tabs = Tabs::new(vec![make_tab("A", &[])]);
614 tabs.add_tab(make_tab("B", &[]));
615 assert_eq!(tabs.tab_count(), 2);
616 }
617
618 #[test]
619 fn ctrl_w_closes_active_tab() {
620 let mut tabs = Tabs::new(vec![make_closable_tab("X"), make_closable_tab("Y")]);
621 tabs.set_active_tab(0);
622
623 let result = tabs.handle_event(&Event::Key(KeyEvent::new(
624 KeyCode::Char('w'),
625 Modifiers::CTRL,
626 )));
627 assert_eq!(result, EventResult::Consumed);
628 assert_eq!(tabs.tab_count(), 1);
629 }
630
631 #[test]
632 fn unhandled_event_ignored() {
633 let mut tabs = Tabs::new(vec![make_tab("A", &[])]);
634 let result = tabs.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Char('z'))));
635 assert_eq!(result, EventResult::Ignored);
636 }
637}