Skip to main content

saorsa_core/widget/
tabs.rs

1//! Tabbed content switcher widget.
2//!
3//! Displays a tab bar with selectable tabs and a content area showing
4//! the active tab's content. Supports keyboard navigation, closable tabs,
5//! and configurable tab bar positioning.
6
7use 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/// A single tab definition.
19#[derive(Clone, Debug)]
20pub struct Tab {
21    /// Tab label text.
22    pub label: String,
23    /// Tab content as styled Segment lines.
24    pub content: Vec<Vec<Segment>>,
25    /// Whether this tab can be closed.
26    pub closable: bool,
27}
28
29impl Tab {
30    /// Create a new tab with the given label.
31    pub fn new(label: &str) -> Self {
32        Self {
33            label: label.to_string(),
34            content: Vec::new(),
35            closable: false,
36        }
37    }
38
39    /// Set the tab content.
40    #[must_use]
41    pub fn with_content(mut self, content: Vec<Vec<Segment>>) -> Self {
42        self.content = content;
43        self
44    }
45
46    /// Set whether this tab is closable.
47    #[must_use]
48    pub fn with_closable(mut self, closable: bool) -> Self {
49        self.closable = closable;
50        self
51    }
52}
53
54/// Position of the tab bar relative to content.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum TabBarPosition {
57    /// Tab bar appears at the top.
58    Top,
59    /// Tab bar appears at the bottom.
60    Bottom,
61}
62
63/// Tabbed content switcher widget.
64///
65/// Displays a horizontal tab bar and the content of the active tab.
66/// Supports keyboard navigation with Left/Right arrows, Tab/Shift+Tab,
67/// and closing tabs with Ctrl+W.
68pub struct Tabs {
69    /// Tab definitions.
70    tabs: Vec<Tab>,
71    /// Active tab index.
72    active_tab: usize,
73    /// Style for the tab bar background.
74    tab_bar_style: Style,
75    /// Style for the active tab label.
76    active_tab_style: Style,
77    /// Style for inactive tab labels.
78    inactive_tab_style: Style,
79    /// Style for the content area.
80    content_style: Style,
81    /// Border style around the entire widget.
82    border: BorderStyle,
83    /// Tab bar position (top or bottom).
84    tab_bar_position: TabBarPosition,
85}
86
87impl Tabs {
88    /// Create a new tabbed widget with the given tabs.
89    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    /// Set the tab bar background style.
103    #[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    /// Set the active tab label style.
110    #[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    /// Set the inactive tab label style.
117    #[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    /// Set the content area style.
124    #[must_use]
125    pub fn with_content_style(mut self, style: Style) -> Self {
126        self.content_style = style;
127        self
128    }
129
130    /// Set the border style.
131    #[must_use]
132    pub fn with_border(mut self, border: BorderStyle) -> Self {
133        self.border = border;
134        self
135    }
136
137    /// Set the tab bar position.
138    #[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    /// Add a tab to the end of the tab list.
145    pub fn add_tab(&mut self, tab: Tab) {
146        self.tabs.push(tab);
147    }
148
149    /// Get the active tab index.
150    pub fn active_tab(&self) -> usize {
151        self.active_tab
152    }
153
154    /// Set the active tab index (clamped to valid range).
155    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    /// Get the content of the active tab.
164    pub fn active_content(&self) -> Option<&[Vec<Segment>]> {
165        self.tabs.get(self.active_tab).map(|t| t.content.as_slice())
166    }
167
168    /// Close the tab at the given index if it is closable.
169    ///
170    /// Returns `true` if the tab was closed.
171    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        // Adjust active tab if needed
183        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    /// Get the number of tabs.
193    pub fn tab_count(&self) -> usize {
194        self.tabs.len()
195    }
196
197    /// Move to the next tab (wraps around).
198    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    /// Move to the previous tab (wraps around).
205    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    /// Render the tab bar into the buffer at the given row.
216    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        // Fill background
222        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            // Separator between tabs
239            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            // Build label: " label " or " label × "
258            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            // Suppress unused warning — display_w used for accounting
282            let _ = display_w;
283        }
284    }
285
286    /// Render content lines into the buffer.
287    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        // Render border if any
340        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                // Tab bar at row 0
357                self.render_tab_bar(inner.position.x, inner.position.y, w, buf);
358                // Content below
359                if h > 1 {
360                    self.render_content(inner.position.x, inner.position.y + 1, w, h - 1, buf);
361                }
362            }
363            TabBarPosition::Bottom => {
364                // Content first
365                if h > 1 {
366                    self.render_content(inner.position.x, inner.position.y, w, h - 1, buf);
367                }
368                // Tab bar at last row
369                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            // Backtab often sent for Shift+Tab
403            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        // Tab bar at row 0 — active tab should have "Alpha" label
455        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        // Tab bar at last row (row 3)
471        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        // Right → tab 1
497        tabs.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Right)));
498        assert_eq!(tabs.active_tab(), 1);
499
500        // Right → tab 2
501        tabs.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Right)));
502        assert_eq!(tabs.active_tab(), 2);
503
504        // Right wraps → tab 0
505        tabs.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Right)));
506        assert_eq!(tabs.active_tab(), 0);
507
508        // Left wraps → tab 2
509        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        // Tab key → next
518        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        // Shift+Tab → previous
523        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        // active_tab adjusted to 1 (was pointing at C2, now C3 is at index 1)
540        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        // Render should not panic
558        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); // clamped to last
578
579        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        // Top-left corner should be single border
607        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}