Skip to main content

ratatui_interact/components/
tab_view.rs

1//! Tab view layout component
2//!
3//! A tab bar with content area that switches based on selected tab.
4//! Supports tabs on any side (top, bottom, left, right) with full keyboard
5//! and mouse interaction.
6//!
7//! # Example
8//!
9//! ```rust
10//! use ratatui_interact::components::{Tab, TabView, TabViewState, TabViewStyle, TabPosition};
11//! use ratatui::{buffer::Buffer, layout::Rect, widgets::Paragraph};
12//!
13//! // Create tabs
14//! let tabs = vec![
15//!     Tab::new("General").icon("⚙"),
16//!     Tab::new("Network").icon("🌐").badge("3"),
17//!     Tab::new("Security"),
18//! ];
19//!
20//! // Create state
21//! let mut state = TabViewState::new(tabs.len());
22//!
23//! // Create style with tabs on the left
24//! let style = TabViewStyle::left().tab_width(20);
25//!
26//! // Create tab view with content renderer
27//! let tab_view = TabView::new(&tabs, &state)
28//!     .style(style)
29//!     .content(|idx, area, buf| {
30//!         let text = match idx {
31//!             0 => "General settings",
32//!             1 => "Network configuration",
33//!             _ => "Security options",
34//!         };
35//!         use ratatui::widgets::Widget;
36//!         Paragraph::new(text).render(area, buf);
37//!     });
38//! ```
39
40use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
41use ratatui::{
42    buffer::Buffer,
43    layout::{Constraint, Direction, Layout, Rect},
44    style::{Color, Modifier, Style},
45    widgets::{Block, Borders, Widget},
46};
47use unicode_width::UnicodeWidthStr;
48
49use crate::traits::{ClickRegionRegistry, FocusId, Focusable};
50
51/// Position of the tab bar relative to content
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum TabPosition {
54    /// Tabs above content (default)
55    #[default]
56    Top,
57    /// Tabs below content
58    Bottom,
59    /// Tabs on left side of content
60    Left,
61    /// Tabs on right side of content
62    Right,
63}
64
65impl TabPosition {
66    /// Whether this position has horizontal tabs
67    pub fn is_horizontal(&self) -> bool {
68        matches!(self, TabPosition::Top | TabPosition::Bottom)
69    }
70
71    /// Whether this position has vertical tabs
72    pub fn is_vertical(&self) -> bool {
73        matches!(self, TabPosition::Left | TabPosition::Right)
74    }
75}
76
77/// A single tab item
78#[derive(Debug, Clone)]
79pub struct Tab<'a> {
80    /// Tab label text
81    pub label: &'a str,
82    /// Optional icon before label
83    pub icon: Option<&'a str>,
84    /// Optional badge (e.g., notification count)
85    pub badge: Option<&'a str>,
86    /// Whether this tab is enabled
87    pub enabled: bool,
88}
89
90impl<'a> Tab<'a> {
91    /// Create a new tab with the given label
92    pub fn new(label: &'a str) -> Self {
93        Self {
94            label,
95            icon: None,
96            badge: None,
97            enabled: true,
98        }
99    }
100
101    /// Set an icon for the tab
102    pub fn icon(mut self, icon: &'a str) -> Self {
103        self.icon = Some(icon);
104        self
105    }
106
107    /// Set a badge for the tab
108    pub fn badge(mut self, badge: &'a str) -> Self {
109        self.badge = Some(badge);
110        self
111    }
112
113    /// Set whether the tab is enabled
114    pub fn enabled(mut self, enabled: bool) -> Self {
115        self.enabled = enabled;
116        self
117    }
118
119    /// Calculate the display width of this tab
120    pub fn display_width(&self) -> usize {
121        let mut width = self.label.width();
122        if let Some(icon) = self.icon {
123            width += icon.width() + 1; // icon + space
124        }
125        if let Some(badge) = self.badge {
126            width += badge.width() + 2; // space + badge + padding
127        }
128        width + 2 // padding on both sides
129    }
130}
131
132/// State for the tab view component
133#[derive(Debug, Clone)]
134pub struct TabViewState {
135    /// Currently selected tab index
136    pub selected_index: usize,
137    /// Total number of tabs
138    pub total_tabs: usize,
139    /// Scroll offset for overflow tabs
140    pub scroll_offset: usize,
141    /// Whether the tab bar has focus (vs content)
142    pub tab_bar_focused: bool,
143    /// Focus ID for focus management
144    pub focus_id: FocusId,
145    /// Whether this component has focus
146    pub focused: bool,
147}
148
149impl TabViewState {
150    /// Create a new tab view state
151    pub fn new(total_tabs: usize) -> Self {
152        Self {
153            selected_index: 0,
154            total_tabs,
155            scroll_offset: 0,
156            tab_bar_focused: true,
157            focus_id: FocusId::default(),
158            focused: false,
159        }
160    }
161
162    /// Create a new tab view state with a specific focus ID
163    pub fn with_focus_id(total_tabs: usize, focus_id: FocusId) -> Self {
164        Self {
165            selected_index: 0,
166            total_tabs,
167            scroll_offset: 0,
168            tab_bar_focused: true,
169            focus_id,
170            focused: false,
171        }
172    }
173
174    /// Select the next tab
175    pub fn select_next(&mut self) {
176        if self.selected_index + 1 < self.total_tabs {
177            self.selected_index += 1;
178        }
179    }
180
181    /// Select the previous tab
182    pub fn select_prev(&mut self) {
183        if self.selected_index > 0 {
184            self.selected_index -= 1;
185        }
186    }
187
188    /// Select a specific tab by index
189    pub fn select(&mut self, index: usize) {
190        if index < self.total_tabs {
191            self.selected_index = index;
192        }
193    }
194
195    /// Select the first tab
196    pub fn select_first(&mut self) {
197        self.selected_index = 0;
198    }
199
200    /// Select the last tab
201    pub fn select_last(&mut self) {
202        if self.total_tabs > 0 {
203            self.selected_index = self.total_tabs - 1;
204        }
205    }
206
207    /// Toggle focus between tab bar and content
208    pub fn toggle_focus(&mut self) {
209        self.tab_bar_focused = !self.tab_bar_focused;
210    }
211
212    /// Ensure the selected tab is visible within the viewport
213    pub fn ensure_visible(&mut self, visible_count: usize) {
214        if visible_count == 0 {
215            return;
216        }
217
218        if self.selected_index < self.scroll_offset {
219            self.scroll_offset = self.selected_index;
220        } else if self.selected_index >= self.scroll_offset + visible_count {
221            self.scroll_offset = self.selected_index - visible_count + 1;
222        }
223    }
224
225    /// Update the total number of tabs
226    pub fn set_total(&mut self, total: usize) {
227        self.total_tabs = total;
228        if self.selected_index >= total && total > 0 {
229            self.selected_index = total - 1;
230        }
231    }
232}
233
234impl Default for TabViewState {
235    fn default() -> Self {
236        Self::new(0)
237    }
238}
239
240impl Focusable for TabViewState {
241    fn focus_id(&self) -> FocusId {
242        self.focus_id
243    }
244
245    fn is_focused(&self) -> bool {
246        self.focused
247    }
248
249    fn set_focused(&mut self, focused: bool) {
250        self.focused = focused;
251    }
252}
253
254/// Style configuration for the tab view
255#[derive(Debug, Clone)]
256pub struct TabViewStyle {
257    /// Position of the tab bar
258    pub position: TabPosition,
259    /// Style for selected tab
260    pub selected_style: Style,
261    /// Style for normal (unselected) tabs
262    pub normal_style: Style,
263    /// Style for focused tab (when component has focus)
264    pub focused_style: Style,
265    /// Style for disabled tabs
266    pub disabled_style: Style,
267    /// Style for badge text
268    pub badge_style: Style,
269    /// Style for the content area border
270    pub content_border_style: Style,
271    /// Tab divider character(s)
272    pub divider: &'static str,
273    /// Fixed width for vertical tabs (None = auto)
274    pub tab_width: Option<u16>,
275    /// Height for horizontal tabs
276    pub tab_height: u16,
277    /// Whether to show border around content
278    pub bordered_content: bool,
279    /// Whether to show selection indicator
280    pub show_indicator: bool,
281    /// Selection indicator character
282    pub indicator: &'static str,
283    /// Scroll left indicator
284    pub scroll_left: &'static str,
285    /// Scroll right indicator
286    pub scroll_right: &'static str,
287    /// Scroll up indicator
288    pub scroll_up: &'static str,
289    /// Scroll down indicator
290    pub scroll_down: &'static str,
291}
292
293impl Default for TabViewStyle {
294    fn default() -> Self {
295        Self {
296            position: TabPosition::Top,
297            selected_style: Style::default()
298                .fg(Color::Yellow)
299                .add_modifier(Modifier::BOLD),
300            normal_style: Style::default().fg(Color::White),
301            focused_style: Style::default()
302                .fg(Color::Yellow)
303                .bg(Color::DarkGray)
304                .add_modifier(Modifier::BOLD),
305            disabled_style: Style::default().fg(Color::DarkGray),
306            badge_style: Style::default()
307                .fg(Color::Black)
308                .bg(Color::Red)
309                .add_modifier(Modifier::BOLD),
310            content_border_style: Style::default().fg(Color::Cyan),
311            divider: " │ ",
312            tab_width: None,
313            tab_height: 1,
314            bordered_content: true,
315            show_indicator: true,
316            indicator: "▸",
317            scroll_left: "◀",
318            scroll_right: "▶",
319            scroll_up: "▲",
320            scroll_down: "▼",
321        }
322    }
323}
324
325impl From<&crate::theme::Theme> for TabViewStyle {
326    fn from(theme: &crate::theme::Theme) -> Self {
327        let p = &theme.palette;
328        Self {
329            position: TabPosition::Top,
330            selected_style: Style::default().fg(p.primary).add_modifier(Modifier::BOLD),
331            normal_style: Style::default().fg(p.text),
332            focused_style: Style::default()
333                .fg(p.primary)
334                .bg(Color::DarkGray)
335                .add_modifier(Modifier::BOLD),
336            disabled_style: Style::default().fg(p.text_disabled),
337            badge_style: Style::default()
338                .fg(p.highlight_fg)
339                .bg(p.error)
340                .add_modifier(Modifier::BOLD),
341            content_border_style: Style::default().fg(p.border_accent),
342            divider: " │ ",
343            tab_width: None,
344            tab_height: 1,
345            bordered_content: true,
346            show_indicator: true,
347            indicator: "▸",
348            scroll_left: "◀",
349            scroll_right: "▶",
350            scroll_up: "▲",
351            scroll_down: "▼",
352        }
353    }
354}
355
356impl TabViewStyle {
357    /// Create a style with tabs on top
358    pub fn top() -> Self {
359        Self::default()
360    }
361
362    /// Create a style with tabs on bottom
363    pub fn bottom() -> Self {
364        Self {
365            position: TabPosition::Bottom,
366            ..Default::default()
367        }
368    }
369
370    /// Create a style with tabs on left
371    pub fn left() -> Self {
372        Self {
373            position: TabPosition::Left,
374            tab_width: Some(16),
375            divider: "",
376            ..Default::default()
377        }
378    }
379
380    /// Create a style with tabs on right
381    pub fn right() -> Self {
382        Self {
383            position: TabPosition::Right,
384            tab_width: Some(16),
385            divider: "",
386            ..Default::default()
387        }
388    }
389
390    /// Create a minimal style (no borders, simple)
391    pub fn minimal() -> Self {
392        Self {
393            bordered_content: false,
394            show_indicator: false,
395            divider: "  ",
396            ..Default::default()
397        }
398    }
399
400    /// Set the tab position
401    pub fn position(mut self, position: TabPosition) -> Self {
402        self.position = position;
403        self
404    }
405
406    /// Set the tab width for vertical tabs
407    pub fn tab_width(mut self, width: u16) -> Self {
408        self.tab_width = Some(width);
409        self
410    }
411
412    /// Set the tab height for horizontal tabs
413    pub fn tab_height(mut self, height: u16) -> Self {
414        self.tab_height = height;
415        self
416    }
417
418    /// Set whether content is bordered
419    pub fn bordered_content(mut self, bordered: bool) -> Self {
420        self.bordered_content = bordered;
421        self
422    }
423
424    /// Set selected tab style
425    pub fn selected_style(mut self, style: Style) -> Self {
426        self.selected_style = style;
427        self
428    }
429
430    /// Set normal tab style
431    pub fn normal_style(mut self, style: Style) -> Self {
432        self.normal_style = style;
433        self
434    }
435
436    /// Set the divider between tabs
437    pub fn divider(mut self, divider: &'static str) -> Self {
438        self.divider = divider;
439        self
440    }
441}
442
443/// Actions that can be triggered by clicking on the tab view
444#[derive(Debug, Clone, Copy, PartialEq, Eq)]
445pub enum TabViewAction {
446    /// A specific tab was clicked
447    TabClick(usize),
448    /// Scroll to previous tabs
449    ScrollPrev,
450    /// Scroll to next tabs
451    ScrollNext,
452}
453
454/// Default content renderer type
455type DefaultContentRenderer = fn(usize, Rect, &mut Buffer);
456
457/// Tab view widget
458///
459/// A layout component with a tab bar and content area.
460pub struct TabView<'a, F = DefaultContentRenderer>
461where
462    F: Fn(usize, Rect, &mut Buffer),
463{
464    tabs: &'a [Tab<'a>],
465    state: &'a TabViewState,
466    style: TabViewStyle,
467    content_renderer: Option<F>,
468}
469
470impl<'a> TabView<'a, DefaultContentRenderer> {
471    /// Create a new tab view with the given tabs and state
472    pub fn new(tabs: &'a [Tab<'a>], state: &'a TabViewState) -> Self {
473        Self {
474            tabs,
475            state,
476            style: TabViewStyle::default(),
477            content_renderer: None,
478        }
479    }
480}
481
482impl<'a, F> TabView<'a, F>
483where
484    F: Fn(usize, Rect, &mut Buffer),
485{
486    /// Set the style for the tab view
487    pub fn style(mut self, style: TabViewStyle) -> Self {
488        self.style = style;
489        self
490    }
491
492    /// Apply a theme to derive the style
493    pub fn theme(self, theme: &crate::theme::Theme) -> Self {
494        self.style(TabViewStyle::from(theme))
495    }
496
497    /// Set the content renderer
498    ///
499    /// The function receives: (selected_index, content_area, buffer)
500    pub fn content<G>(self, renderer: G) -> TabView<'a, G>
501    where
502        G: Fn(usize, Rect, &mut Buffer),
503    {
504        TabView {
505            tabs: self.tabs,
506            state: self.state,
507            style: self.style,
508            content_renderer: Some(renderer),
509        }
510    }
511
512    /// Calculate the layout areas for tab bar and content
513    fn calculate_layout(&self, area: Rect) -> (Rect, Rect) {
514        let (direction, constraints) = match self.style.position {
515            TabPosition::Top => (
516                Direction::Vertical,
517                [
518                    Constraint::Length(self.style.tab_height),
519                    Constraint::Min(1),
520                ],
521            ),
522            TabPosition::Bottom => (
523                Direction::Vertical,
524                [
525                    Constraint::Min(1),
526                    Constraint::Length(self.style.tab_height),
527                ],
528            ),
529            TabPosition::Left => {
530                let width = self.style.tab_width.unwrap_or(16);
531                (
532                    Direction::Horizontal,
533                    [Constraint::Length(width), Constraint::Min(1)],
534                )
535            }
536            TabPosition::Right => {
537                let width = self.style.tab_width.unwrap_or(16);
538                (
539                    Direction::Horizontal,
540                    [Constraint::Min(1), Constraint::Length(width)],
541                )
542            }
543        };
544
545        let chunks = Layout::default()
546            .direction(direction)
547            .constraints(constraints)
548            .split(area);
549
550        match self.style.position {
551            TabPosition::Top | TabPosition::Left => (chunks[0], chunks[1]),
552            TabPosition::Bottom | TabPosition::Right => (chunks[1], chunks[0]),
553        }
554    }
555
556    /// Render the tab bar and return click regions
557    fn render_tab_bar(&self, area: Rect, buf: &mut Buffer) -> Vec<(Rect, TabViewAction)> {
558        let mut click_regions = Vec::new();
559
560        if self.style.position.is_horizontal() {
561            self.render_horizontal_tabs(area, buf, &mut click_regions);
562        } else {
563            self.render_vertical_tabs(area, buf, &mut click_regions);
564        }
565
566        click_regions
567    }
568
569    /// Render horizontal tabs (top/bottom)
570    fn render_horizontal_tabs(
571        &self,
572        area: Rect,
573        buf: &mut Buffer,
574        click_regions: &mut Vec<(Rect, TabViewAction)>,
575    ) {
576        let mut x = area.x;
577        let y = area.y;
578
579        // Check if we need scroll indicators
580        let has_overflow = self.calculate_overflow_horizontal(area.width);
581        let show_prev = self.state.scroll_offset > 0;
582        let show_next = has_overflow
583            && self.state.scroll_offset + self.visible_tabs_horizontal(area.width)
584                < self.tabs.len();
585
586        // Render scroll-left indicator
587        if show_prev {
588            let indicator = self.style.scroll_left;
589            let indicator_area = Rect::new(x, y, 2, 1);
590            buf.set_string(x, y, indicator, Style::default().fg(Color::Yellow));
591            click_regions.push((indicator_area, TabViewAction::ScrollPrev));
592            x += 2;
593        }
594
595        // Render visible tabs
596        let visible_start = self.state.scroll_offset;
597        let visible_count = self.visible_tabs_horizontal(
598            area.width
599                .saturating_sub(if show_prev { 2 } else { 0 })
600                .saturating_sub(if show_next { 2 } else { 0 }),
601        );
602
603        for (idx, tab) in self
604            .tabs
605            .iter()
606            .enumerate()
607            .skip(visible_start)
608            .take(visible_count)
609        {
610            // Remember start position for click region
611            let tab_start_x = x;
612
613            // Build tab text
614            let mut text = String::new();
615            if let Some(icon) = tab.icon {
616                text.push_str(icon);
617                text.push(' ');
618            }
619            text.push_str(tab.label);
620
621            // Determine style
622            let style = self.get_tab_style(idx, tab.enabled);
623
624            // Render indicator if selected and enabled
625            let text_with_padding = if self.state.selected_index == idx && self.style.show_indicator
626            {
627                format!("{} {} ", self.style.indicator, text)
628            } else {
629                format!(" {} ", text)
630            };
631
632            // Render the text using unicode width for proper calculation
633            let text_width = text_with_padding.width() as u16;
634            buf.set_string(x, y, &text_with_padding, style);
635            x += text_width;
636
637            // Render badge if present (included in click region)
638            if let Some(badge) = tab.badge {
639                let badge_text = format!(" {} ", badge);
640                let badge_width = badge_text.width() as u16;
641                buf.set_string(x, y, &badge_text, self.style.badge_style);
642                x += badge_width;
643            }
644
645            // Calculate actual tab width and register click region
646            let tab_width = x - tab_start_x;
647            if tab_width > 0 {
648                let tab_area = Rect::new(tab_start_x, y, tab_width, 1);
649                click_regions.push((tab_area, TabViewAction::TabClick(idx)));
650            }
651
652            // Render divider (if not last visible) - not part of click region
653            if idx + 1 < visible_start + visible_count && idx + 1 < self.tabs.len() {
654                let divider_width = self.style.divider.width() as u16;
655                buf.set_string(
656                    x,
657                    y,
658                    self.style.divider,
659                    Style::default().fg(Color::DarkGray),
660                );
661                x += divider_width;
662            }
663        }
664
665        // Render scroll-right indicator
666        if show_next {
667            let indicator = self.style.scroll_right;
668            let indicator_x = area.x + area.width - 2;
669            let indicator_area = Rect::new(indicator_x, y, 2, 1);
670            buf.set_string(
671                indicator_x,
672                y,
673                indicator,
674                Style::default().fg(Color::Yellow),
675            );
676            click_regions.push((indicator_area, TabViewAction::ScrollNext));
677        }
678    }
679
680    /// Render vertical tabs (left/right)
681    fn render_vertical_tabs(
682        &self,
683        area: Rect,
684        buf: &mut Buffer,
685        click_regions: &mut Vec<(Rect, TabViewAction)>,
686    ) {
687        let x = area.x;
688        let mut y = area.y;
689        let width = area.width;
690
691        // Check if we need scroll indicators
692        let visible_count = (area.height as usize).min(self.tabs.len());
693        let show_prev = self.state.scroll_offset > 0;
694        let show_next = self.state.scroll_offset + visible_count < self.tabs.len();
695
696        // Render scroll-up indicator
697        if show_prev {
698            let indicator = format!("{:^width$}", self.style.scroll_up, width = width as usize);
699            buf.set_string(x, y, &indicator, Style::default().fg(Color::Yellow));
700            click_regions.push((Rect::new(x, y, width, 1), TabViewAction::ScrollPrev));
701            y += 1;
702        }
703
704        // Render visible tabs
705        let available_height = area
706            .height
707            .saturating_sub(if show_prev { 1 } else { 0 })
708            .saturating_sub(if show_next { 1 } else { 0 });
709        let visible_start = self.state.scroll_offset;
710        let visible_count = (available_height as usize).min(self.tabs.len() - visible_start);
711
712        for (idx, tab) in self
713            .tabs
714            .iter()
715            .enumerate()
716            .skip(visible_start)
717            .take(visible_count)
718        {
719            if y >= area.y + area.height - if show_next { 1 } else { 0 } {
720                break;
721            }
722
723            // Build tab text
724            let mut text = String::new();
725            if self.state.selected_index == idx && self.style.show_indicator {
726                text.push_str(self.style.indicator);
727                text.push(' ');
728            } else {
729                text.push_str("  ");
730            }
731            if let Some(icon) = tab.icon {
732                text.push_str(icon);
733                text.push(' ');
734            }
735            text.push_str(tab.label);
736
737            // Add badge
738            if let Some(badge) = tab.badge {
739                text.push_str(&format!(" ({})", badge));
740            }
741
742            // Truncate if too long
743            let max_len = width as usize;
744            let display_text = if text.chars().count() > max_len {
745                let truncated: String = text.chars().take(max_len - 1).collect();
746                format!("{}…", truncated)
747            } else {
748                format!("{:width$}", text, width = max_len)
749            };
750
751            // Determine style
752            let style = self.get_tab_style(idx, tab.enabled);
753
754            let tab_area = Rect::new(x, y, width, 1);
755            buf.set_string(x, y, &display_text, style);
756            click_regions.push((tab_area, TabViewAction::TabClick(idx)));
757
758            y += 1;
759        }
760
761        // Render scroll-down indicator
762        if show_next {
763            let indicator_y = area.y + area.height - 1;
764            let indicator = format!("{:^width$}", self.style.scroll_down, width = width as usize);
765            buf.set_string(
766                x,
767                indicator_y,
768                &indicator,
769                Style::default().fg(Color::Yellow),
770            );
771            click_regions.push((
772                Rect::new(x, indicator_y, width, 1),
773                TabViewAction::ScrollNext,
774            ));
775        }
776    }
777
778    /// Get the appropriate style for a tab
779    fn get_tab_style(&self, idx: usize, enabled: bool) -> Style {
780        if !enabled {
781            self.style.disabled_style
782        } else if idx == self.state.selected_index
783            && self.state.focused
784            && self.state.tab_bar_focused
785        {
786            self.style.focused_style
787        } else if idx == self.state.selected_index {
788            self.style.selected_style
789        } else {
790            self.style.normal_style
791        }
792    }
793
794    /// Calculate if horizontal tabs overflow
795    fn calculate_overflow_horizontal(&self, available_width: u16) -> bool {
796        let total_width: u16 = self
797            .tabs
798            .iter()
799            .map(|t| t.display_width() as u16 + self.style.divider.width() as u16)
800            .sum();
801        total_width > available_width
802    }
803
804    /// Calculate how many tabs fit horizontally
805    fn visible_tabs_horizontal(&self, available_width: u16) -> usize {
806        let mut width = 0u16;
807        let mut count = 0;
808        for tab in self.tabs.iter().skip(self.state.scroll_offset) {
809            let tab_width = tab.display_width() as u16 + self.style.divider.width() as u16;
810            if width + tab_width > available_width {
811                break;
812            }
813            width += tab_width;
814            count += 1;
815        }
816        count.max(1)
817    }
818
819    /// Render content area
820    fn render_content(&self, area: Rect, buf: &mut Buffer) {
821        let inner = if self.style.bordered_content {
822            let block = Block::default()
823                .borders(Borders::ALL)
824                .border_style(self.style.content_border_style);
825            let inner = block.inner(area);
826            block.render(area, buf);
827            inner
828        } else {
829            area
830        };
831
832        if let Some(ref renderer) = self.content_renderer {
833            renderer(self.state.selected_index, inner, buf);
834        }
835    }
836
837    /// Render the tab view and return click regions
838    pub fn render_stateful(self, area: Rect, buf: &mut Buffer) -> Vec<(Rect, TabViewAction)> {
839        let (tab_area, content_area) = self.calculate_layout(area);
840
841        // Render tab bar
842        let click_regions = self.render_tab_bar(tab_area, buf);
843
844        // Render content
845        self.render_content(content_area, buf);
846
847        click_regions
848    }
849
850    /// Render and register click regions
851    pub fn render_with_registry(
852        self,
853        area: Rect,
854        buf: &mut Buffer,
855        registry: &mut ClickRegionRegistry<TabViewAction>,
856    ) {
857        let regions = self.render_stateful(area, buf);
858        for (rect, action) in regions {
859            registry.register(rect, action);
860        }
861    }
862}
863
864impl<'a, F> Widget for TabView<'a, F>
865where
866    F: Fn(usize, Rect, &mut Buffer),
867{
868    fn render(self, area: Rect, buf: &mut Buffer) {
869        let _ = self.render_stateful(area, buf);
870    }
871}
872
873/// Handle keyboard events for the tab view
874///
875/// Returns true if the event was handled.
876pub fn handle_tab_view_key(
877    state: &mut TabViewState,
878    key: &KeyEvent,
879    position: TabPosition,
880) -> bool {
881    // Handle tab bar navigation based on position
882    if state.tab_bar_focused {
883        match key.code {
884            // Horizontal navigation for horizontal tabs
885            KeyCode::Left if position.is_horizontal() => {
886                state.select_prev();
887                true
888            }
889            KeyCode::Right if position.is_horizontal() => {
890                state.select_next();
891                true
892            }
893            // Vertical navigation for vertical tabs
894            KeyCode::Up if position.is_vertical() => {
895                state.select_prev();
896                true
897            }
898            KeyCode::Down if position.is_vertical() => {
899                state.select_next();
900                true
901            }
902            // Home/End
903            KeyCode::Home => {
904                state.select_first();
905                true
906            }
907            KeyCode::End => {
908                state.select_last();
909                true
910            }
911            // Number keys for direct selection (1-9)
912            KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => {
913                let idx = (c as usize) - ('1' as usize);
914                if idx < state.total_tabs {
915                    state.select(idx);
916                }
917                true
918            }
919            // Enter to focus content
920            KeyCode::Enter => {
921                state.toggle_focus();
922                true
923            }
924            _ => false,
925        }
926    } else {
927        // Content focused - Escape to go back to tab bar
928        match key.code {
929            KeyCode::Esc => {
930                state.toggle_focus();
931                true
932            }
933            _ => false,
934        }
935    }
936}
937
938/// Handle mouse events for the tab view
939///
940/// Returns the action if a click was handled.
941pub fn handle_tab_view_mouse(
942    state: &mut TabViewState,
943    registry: &ClickRegionRegistry<TabViewAction>,
944    mouse: &MouseEvent,
945) -> Option<TabViewAction> {
946    use crossterm::event::{MouseButton, MouseEventKind};
947
948    if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
949        if let Some(action) = registry.handle_click(mouse.column, mouse.row) {
950            match action {
951                TabViewAction::TabClick(idx) => {
952                    state.select(*idx);
953                    state.tab_bar_focused = true;
954                    return Some(*action);
955                }
956                TabViewAction::ScrollPrev => {
957                    if state.scroll_offset > 0 {
958                        state.scroll_offset -= 1;
959                    }
960                    return Some(*action);
961                }
962                TabViewAction::ScrollNext => {
963                    state.scroll_offset += 1;
964                    return Some(*action);
965                }
966            }
967        }
968    }
969
970    None
971}
972
973#[cfg(test)]
974mod tests {
975    use super::*;
976
977    #[test]
978    fn test_tab_creation() {
979        let tab = Tab::new("Test").icon("🔧").badge("5").enabled(true);
980
981        assert_eq!(tab.label, "Test");
982        assert_eq!(tab.icon, Some("🔧"));
983        assert_eq!(tab.badge, Some("5"));
984        assert!(tab.enabled);
985    }
986
987    #[test]
988    fn test_tab_display_width() {
989        let simple = Tab::new("Test");
990        // " Test " = 6 chars
991        assert_eq!(simple.display_width(), 6);
992
993        let with_icon = Tab::new("Test").icon("⚙");
994        // " ⚙ Test " = 8 chars
995        assert_eq!(with_icon.display_width(), 8);
996
997        let with_badge = Tab::new("Test").badge("3");
998        // " Test " + " 3 " = 6 + 3 = 9 chars
999        assert_eq!(with_badge.display_width(), 9);
1000    }
1001
1002    #[test]
1003    fn test_state_navigation() {
1004        let mut state = TabViewState::new(5);
1005        assert_eq!(state.selected_index, 0);
1006
1007        state.select_next();
1008        assert_eq!(state.selected_index, 1);
1009
1010        state.select_prev();
1011        assert_eq!(state.selected_index, 0);
1012
1013        state.select_prev(); // Should not go below 0
1014        assert_eq!(state.selected_index, 0);
1015
1016        state.select_last();
1017        assert_eq!(state.selected_index, 4);
1018
1019        state.select_next(); // Should not go above total
1020        assert_eq!(state.selected_index, 4);
1021
1022        state.select_first();
1023        assert_eq!(state.selected_index, 0);
1024    }
1025
1026    #[test]
1027    fn test_state_direct_select() {
1028        let mut state = TabViewState::new(5);
1029
1030        state.select(3);
1031        assert_eq!(state.selected_index, 3);
1032
1033        state.select(10); // Out of range - should not change
1034        assert_eq!(state.selected_index, 3);
1035    }
1036
1037    #[test]
1038    fn test_state_focus_toggle() {
1039        let mut state = TabViewState::new(3);
1040        assert!(state.tab_bar_focused);
1041
1042        state.toggle_focus();
1043        assert!(!state.tab_bar_focused);
1044
1045        state.toggle_focus();
1046        assert!(state.tab_bar_focused);
1047    }
1048
1049    #[test]
1050    fn test_ensure_visible() {
1051        let mut state = TabViewState::new(20);
1052        state.selected_index = 15;
1053        state.ensure_visible(10);
1054        assert!(state.scroll_offset >= 6); // 15 - 10 + 1 = 6
1055    }
1056
1057    #[test]
1058    fn test_tab_position() {
1059        assert!(TabPosition::Top.is_horizontal());
1060        assert!(TabPosition::Bottom.is_horizontal());
1061        assert!(TabPosition::Left.is_vertical());
1062        assert!(TabPosition::Right.is_vertical());
1063
1064        assert!(!TabPosition::Top.is_vertical());
1065        assert!(!TabPosition::Left.is_horizontal());
1066    }
1067
1068    #[test]
1069    fn test_style_presets() {
1070        let top = TabViewStyle::top();
1071        assert_eq!(top.position, TabPosition::Top);
1072
1073        let bottom = TabViewStyle::bottom();
1074        assert_eq!(bottom.position, TabPosition::Bottom);
1075
1076        let left = TabViewStyle::left();
1077        assert_eq!(left.position, TabPosition::Left);
1078        assert!(left.tab_width.is_some());
1079
1080        let right = TabViewStyle::right();
1081        assert_eq!(right.position, TabPosition::Right);
1082    }
1083
1084    #[test]
1085    fn test_focusable_impl() {
1086        let mut state = TabViewState::with_focus_id(3, FocusId::new(42));
1087
1088        assert_eq!(state.focus_id().id(), 42);
1089        assert!(!state.is_focused());
1090
1091        state.set_focused(true);
1092        assert!(state.is_focused());
1093    }
1094
1095    #[test]
1096    fn test_tab_view_render() {
1097        let tabs = vec![Tab::new("Tab 1"), Tab::new("Tab 2"), Tab::new("Tab 3")];
1098        let state = TabViewState::new(tabs.len());
1099        let tab_view = TabView::new(&tabs, &state);
1100
1101        let mut buf = Buffer::empty(Rect::new(0, 0, 50, 10));
1102        tab_view.render(Rect::new(0, 0, 50, 10), &mut buf);
1103        // Just verify it doesn't panic
1104    }
1105
1106    #[test]
1107    fn test_key_handling_horizontal() {
1108        let mut state = TabViewState::new(5);
1109
1110        // Right arrow moves next
1111        let key = KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::NONE);
1112        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1113        assert_eq!(state.selected_index, 1);
1114
1115        // Left arrow moves prev
1116        let key = KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::NONE);
1117        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1118        assert_eq!(state.selected_index, 0);
1119
1120        // Home goes to first
1121        let key = KeyEvent::new(KeyCode::Home, crossterm::event::KeyModifiers::NONE);
1122        state.select(3);
1123        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1124        assert_eq!(state.selected_index, 0);
1125
1126        // End goes to last
1127        let key = KeyEvent::new(KeyCode::End, crossterm::event::KeyModifiers::NONE);
1128        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1129        assert_eq!(state.selected_index, 4);
1130    }
1131
1132    #[test]
1133    fn test_key_handling_vertical() {
1134        let mut state = TabViewState::new(5);
1135
1136        // Down arrow moves next in vertical mode
1137        let key = KeyEvent::new(KeyCode::Down, crossterm::event::KeyModifiers::NONE);
1138        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Left));
1139        assert_eq!(state.selected_index, 1);
1140
1141        // Up arrow moves prev
1142        let key = KeyEvent::new(KeyCode::Up, crossterm::event::KeyModifiers::NONE);
1143        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Left));
1144        assert_eq!(state.selected_index, 0);
1145    }
1146
1147    #[test]
1148    fn test_number_key_selection() {
1149        let mut state = TabViewState::new(5);
1150
1151        // Press '3' to select tab 3 (index 2)
1152        let key = KeyEvent::new(KeyCode::Char('3'), crossterm::event::KeyModifiers::NONE);
1153        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1154        assert_eq!(state.selected_index, 2);
1155
1156        // Press '1' to select tab 1 (index 0)
1157        let key = KeyEvent::new(KeyCode::Char('1'), crossterm::event::KeyModifiers::NONE);
1158        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1159        assert_eq!(state.selected_index, 0);
1160    }
1161
1162    #[test]
1163    fn test_focus_toggle_with_enter() {
1164        let mut state = TabViewState::new(3);
1165        assert!(state.tab_bar_focused);
1166
1167        // Enter toggles to content
1168        let key = KeyEvent::new(KeyCode::Enter, crossterm::event::KeyModifiers::NONE);
1169        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1170        assert!(!state.tab_bar_focused);
1171
1172        // Escape goes back to tab bar
1173        let key = KeyEvent::new(KeyCode::Esc, crossterm::event::KeyModifiers::NONE);
1174        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1175        assert!(state.tab_bar_focused);
1176    }
1177}