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 TabViewStyle {
326    /// Create a style with tabs on top
327    pub fn top() -> Self {
328        Self::default()
329    }
330
331    /// Create a style with tabs on bottom
332    pub fn bottom() -> Self {
333        Self {
334            position: TabPosition::Bottom,
335            ..Default::default()
336        }
337    }
338
339    /// Create a style with tabs on left
340    pub fn left() -> Self {
341        Self {
342            position: TabPosition::Left,
343            tab_width: Some(16),
344            divider: "",
345            ..Default::default()
346        }
347    }
348
349    /// Create a style with tabs on right
350    pub fn right() -> Self {
351        Self {
352            position: TabPosition::Right,
353            tab_width: Some(16),
354            divider: "",
355            ..Default::default()
356        }
357    }
358
359    /// Create a minimal style (no borders, simple)
360    pub fn minimal() -> Self {
361        Self {
362            bordered_content: false,
363            show_indicator: false,
364            divider: "  ",
365            ..Default::default()
366        }
367    }
368
369    /// Set the tab position
370    pub fn position(mut self, position: TabPosition) -> Self {
371        self.position = position;
372        self
373    }
374
375    /// Set the tab width for vertical tabs
376    pub fn tab_width(mut self, width: u16) -> Self {
377        self.tab_width = Some(width);
378        self
379    }
380
381    /// Set the tab height for horizontal tabs
382    pub fn tab_height(mut self, height: u16) -> Self {
383        self.tab_height = height;
384        self
385    }
386
387    /// Set whether content is bordered
388    pub fn bordered_content(mut self, bordered: bool) -> Self {
389        self.bordered_content = bordered;
390        self
391    }
392
393    /// Set selected tab style
394    pub fn selected_style(mut self, style: Style) -> Self {
395        self.selected_style = style;
396        self
397    }
398
399    /// Set normal tab style
400    pub fn normal_style(mut self, style: Style) -> Self {
401        self.normal_style = style;
402        self
403    }
404
405    /// Set the divider between tabs
406    pub fn divider(mut self, divider: &'static str) -> Self {
407        self.divider = divider;
408        self
409    }
410}
411
412/// Actions that can be triggered by clicking on the tab view
413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
414pub enum TabViewAction {
415    /// A specific tab was clicked
416    TabClick(usize),
417    /// Scroll to previous tabs
418    ScrollPrev,
419    /// Scroll to next tabs
420    ScrollNext,
421}
422
423/// Default content renderer type
424type DefaultContentRenderer = fn(usize, Rect, &mut Buffer);
425
426/// Tab view widget
427///
428/// A layout component with a tab bar and content area.
429pub struct TabView<'a, F = DefaultContentRenderer>
430where
431    F: Fn(usize, Rect, &mut Buffer),
432{
433    tabs: &'a [Tab<'a>],
434    state: &'a TabViewState,
435    style: TabViewStyle,
436    content_renderer: Option<F>,
437}
438
439impl<'a> TabView<'a, DefaultContentRenderer> {
440    /// Create a new tab view with the given tabs and state
441    pub fn new(tabs: &'a [Tab<'a>], state: &'a TabViewState) -> Self {
442        Self {
443            tabs,
444            state,
445            style: TabViewStyle::default(),
446            content_renderer: None,
447        }
448    }
449}
450
451impl<'a, F> TabView<'a, F>
452where
453    F: Fn(usize, Rect, &mut Buffer),
454{
455    /// Set the style for the tab view
456    pub fn style(mut self, style: TabViewStyle) -> Self {
457        self.style = style;
458        self
459    }
460
461    /// Set the content renderer
462    ///
463    /// The function receives: (selected_index, content_area, buffer)
464    pub fn content<G>(self, renderer: G) -> TabView<'a, G>
465    where
466        G: Fn(usize, Rect, &mut Buffer),
467    {
468        TabView {
469            tabs: self.tabs,
470            state: self.state,
471            style: self.style,
472            content_renderer: Some(renderer),
473        }
474    }
475
476    /// Calculate the layout areas for tab bar and content
477    fn calculate_layout(&self, area: Rect) -> (Rect, Rect) {
478        let (direction, constraints) = match self.style.position {
479            TabPosition::Top => (
480                Direction::Vertical,
481                [
482                    Constraint::Length(self.style.tab_height),
483                    Constraint::Min(1),
484                ],
485            ),
486            TabPosition::Bottom => (
487                Direction::Vertical,
488                [
489                    Constraint::Min(1),
490                    Constraint::Length(self.style.tab_height),
491                ],
492            ),
493            TabPosition::Left => {
494                let width = self.style.tab_width.unwrap_or(16);
495                (
496                    Direction::Horizontal,
497                    [Constraint::Length(width), Constraint::Min(1)],
498                )
499            }
500            TabPosition::Right => {
501                let width = self.style.tab_width.unwrap_or(16);
502                (
503                    Direction::Horizontal,
504                    [Constraint::Min(1), Constraint::Length(width)],
505                )
506            }
507        };
508
509        let chunks = Layout::default()
510            .direction(direction)
511            .constraints(constraints)
512            .split(area);
513
514        match self.style.position {
515            TabPosition::Top | TabPosition::Left => (chunks[0], chunks[1]),
516            TabPosition::Bottom | TabPosition::Right => (chunks[1], chunks[0]),
517        }
518    }
519
520    /// Render the tab bar and return click regions
521    fn render_tab_bar(&self, area: Rect, buf: &mut Buffer) -> Vec<(Rect, TabViewAction)> {
522        let mut click_regions = Vec::new();
523
524        if self.style.position.is_horizontal() {
525            self.render_horizontal_tabs(area, buf, &mut click_regions);
526        } else {
527            self.render_vertical_tabs(area, buf, &mut click_regions);
528        }
529
530        click_regions
531    }
532
533    /// Render horizontal tabs (top/bottom)
534    fn render_horizontal_tabs(
535        &self,
536        area: Rect,
537        buf: &mut Buffer,
538        click_regions: &mut Vec<(Rect, TabViewAction)>,
539    ) {
540        let mut x = area.x;
541        let y = area.y;
542
543        // Check if we need scroll indicators
544        let has_overflow = self.calculate_overflow_horizontal(area.width);
545        let show_prev = self.state.scroll_offset > 0;
546        let show_next = has_overflow
547            && self.state.scroll_offset + self.visible_tabs_horizontal(area.width)
548                < self.tabs.len();
549
550        // Render scroll-left indicator
551        if show_prev {
552            let indicator = self.style.scroll_left;
553            let indicator_area = Rect::new(x, y, 2, 1);
554            buf.set_string(x, y, indicator, Style::default().fg(Color::Yellow));
555            click_regions.push((indicator_area, TabViewAction::ScrollPrev));
556            x += 2;
557        }
558
559        // Render visible tabs
560        let visible_start = self.state.scroll_offset;
561        let visible_count = self.visible_tabs_horizontal(
562            area.width
563                .saturating_sub(if show_prev { 2 } else { 0 })
564                .saturating_sub(if show_next { 2 } else { 0 }),
565        );
566
567        for (idx, tab) in self
568            .tabs
569            .iter()
570            .enumerate()
571            .skip(visible_start)
572            .take(visible_count)
573        {
574            // Remember start position for click region
575            let tab_start_x = x;
576
577            // Build tab text
578            let mut text = String::new();
579            if let Some(icon) = tab.icon {
580                text.push_str(icon);
581                text.push(' ');
582            }
583            text.push_str(tab.label);
584
585            // Determine style
586            let style = self.get_tab_style(idx, tab.enabled);
587
588            // Render indicator if selected and enabled
589            let text_with_padding = if self.state.selected_index == idx && self.style.show_indicator
590            {
591                format!("{} {} ", self.style.indicator, text)
592            } else {
593                format!(" {} ", text)
594            };
595
596            // Render the text using unicode width for proper calculation
597            let text_width = text_with_padding.width() as u16;
598            buf.set_string(x, y, &text_with_padding, style);
599            x += text_width;
600
601            // Render badge if present (included in click region)
602            if let Some(badge) = tab.badge {
603                let badge_text = format!(" {} ", badge);
604                let badge_width = badge_text.width() as u16;
605                buf.set_string(x, y, &badge_text, self.style.badge_style);
606                x += badge_width;
607            }
608
609            // Calculate actual tab width and register click region
610            let tab_width = x - tab_start_x;
611            if tab_width > 0 {
612                let tab_area = Rect::new(tab_start_x, y, tab_width, 1);
613                click_regions.push((tab_area, TabViewAction::TabClick(idx)));
614            }
615
616            // Render divider (if not last visible) - not part of click region
617            if idx + 1 < visible_start + visible_count && idx + 1 < self.tabs.len() {
618                let divider_width = self.style.divider.width() as u16;
619                buf.set_string(
620                    x,
621                    y,
622                    self.style.divider,
623                    Style::default().fg(Color::DarkGray),
624                );
625                x += divider_width;
626            }
627        }
628
629        // Render scroll-right indicator
630        if show_next {
631            let indicator = self.style.scroll_right;
632            let indicator_x = area.x + area.width - 2;
633            let indicator_area = Rect::new(indicator_x, y, 2, 1);
634            buf.set_string(
635                indicator_x,
636                y,
637                indicator,
638                Style::default().fg(Color::Yellow),
639            );
640            click_regions.push((indicator_area, TabViewAction::ScrollNext));
641        }
642    }
643
644    /// Render vertical tabs (left/right)
645    fn render_vertical_tabs(
646        &self,
647        area: Rect,
648        buf: &mut Buffer,
649        click_regions: &mut Vec<(Rect, TabViewAction)>,
650    ) {
651        let x = area.x;
652        let mut y = area.y;
653        let width = area.width;
654
655        // Check if we need scroll indicators
656        let visible_count = (area.height as usize).min(self.tabs.len());
657        let show_prev = self.state.scroll_offset > 0;
658        let show_next = self.state.scroll_offset + visible_count < self.tabs.len();
659
660        // Render scroll-up indicator
661        if show_prev {
662            let indicator = format!("{:^width$}", self.style.scroll_up, width = width as usize);
663            buf.set_string(x, y, &indicator, Style::default().fg(Color::Yellow));
664            click_regions.push((Rect::new(x, y, width, 1), TabViewAction::ScrollPrev));
665            y += 1;
666        }
667
668        // Render visible tabs
669        let available_height = area
670            .height
671            .saturating_sub(if show_prev { 1 } else { 0 })
672            .saturating_sub(if show_next { 1 } else { 0 });
673        let visible_start = self.state.scroll_offset;
674        let visible_count = (available_height as usize).min(self.tabs.len() - visible_start);
675
676        for (idx, tab) in self
677            .tabs
678            .iter()
679            .enumerate()
680            .skip(visible_start)
681            .take(visible_count)
682        {
683            if y >= area.y + area.height - if show_next { 1 } else { 0 } {
684                break;
685            }
686
687            // Build tab text
688            let mut text = String::new();
689            if self.state.selected_index == idx && self.style.show_indicator {
690                text.push_str(self.style.indicator);
691                text.push(' ');
692            } else {
693                text.push_str("  ");
694            }
695            if let Some(icon) = tab.icon {
696                text.push_str(icon);
697                text.push(' ');
698            }
699            text.push_str(tab.label);
700
701            // Add badge
702            if let Some(badge) = tab.badge {
703                text.push_str(&format!(" ({})", badge));
704            }
705
706            // Truncate if too long
707            let max_len = width as usize;
708            let display_text = if text.chars().count() > max_len {
709                let truncated: String = text.chars().take(max_len - 1).collect();
710                format!("{}…", truncated)
711            } else {
712                format!("{:width$}", text, width = max_len)
713            };
714
715            // Determine style
716            let style = self.get_tab_style(idx, tab.enabled);
717
718            let tab_area = Rect::new(x, y, width, 1);
719            buf.set_string(x, y, &display_text, style);
720            click_regions.push((tab_area, TabViewAction::TabClick(idx)));
721
722            y += 1;
723        }
724
725        // Render scroll-down indicator
726        if show_next {
727            let indicator_y = area.y + area.height - 1;
728            let indicator = format!("{:^width$}", self.style.scroll_down, width = width as usize);
729            buf.set_string(
730                x,
731                indicator_y,
732                &indicator,
733                Style::default().fg(Color::Yellow),
734            );
735            click_regions.push((
736                Rect::new(x, indicator_y, width, 1),
737                TabViewAction::ScrollNext,
738            ));
739        }
740    }
741
742    /// Get the appropriate style for a tab
743    fn get_tab_style(&self, idx: usize, enabled: bool) -> Style {
744        if !enabled {
745            self.style.disabled_style
746        } else if idx == self.state.selected_index
747            && self.state.focused
748            && self.state.tab_bar_focused
749        {
750            self.style.focused_style
751        } else if idx == self.state.selected_index {
752            self.style.selected_style
753        } else {
754            self.style.normal_style
755        }
756    }
757
758    /// Calculate if horizontal tabs overflow
759    fn calculate_overflow_horizontal(&self, available_width: u16) -> bool {
760        let total_width: u16 = self
761            .tabs
762            .iter()
763            .map(|t| t.display_width() as u16 + self.style.divider.width() as u16)
764            .sum();
765        total_width > available_width
766    }
767
768    /// Calculate how many tabs fit horizontally
769    fn visible_tabs_horizontal(&self, available_width: u16) -> usize {
770        let mut width = 0u16;
771        let mut count = 0;
772        for tab in self.tabs.iter().skip(self.state.scroll_offset) {
773            let tab_width = tab.display_width() as u16 + self.style.divider.width() as u16;
774            if width + tab_width > available_width {
775                break;
776            }
777            width += tab_width;
778            count += 1;
779        }
780        count.max(1)
781    }
782
783    /// Render content area
784    fn render_content(&self, area: Rect, buf: &mut Buffer) {
785        let inner = if self.style.bordered_content {
786            let block = Block::default()
787                .borders(Borders::ALL)
788                .border_style(self.style.content_border_style);
789            let inner = block.inner(area);
790            block.render(area, buf);
791            inner
792        } else {
793            area
794        };
795
796        if let Some(ref renderer) = self.content_renderer {
797            renderer(self.state.selected_index, inner, buf);
798        }
799    }
800
801    /// Render the tab view and return click regions
802    pub fn render_stateful(self, area: Rect, buf: &mut Buffer) -> Vec<(Rect, TabViewAction)> {
803        let (tab_area, content_area) = self.calculate_layout(area);
804
805        // Render tab bar
806        let click_regions = self.render_tab_bar(tab_area, buf);
807
808        // Render content
809        self.render_content(content_area, buf);
810
811        click_regions
812    }
813
814    /// Render and register click regions
815    pub fn render_with_registry(
816        self,
817        area: Rect,
818        buf: &mut Buffer,
819        registry: &mut ClickRegionRegistry<TabViewAction>,
820    ) {
821        let regions = self.render_stateful(area, buf);
822        for (rect, action) in regions {
823            registry.register(rect, action);
824        }
825    }
826}
827
828impl<'a, F> Widget for TabView<'a, F>
829where
830    F: Fn(usize, Rect, &mut Buffer),
831{
832    fn render(self, area: Rect, buf: &mut Buffer) {
833        let _ = self.render_stateful(area, buf);
834    }
835}
836
837/// Handle keyboard events for the tab view
838///
839/// Returns true if the event was handled.
840pub fn handle_tab_view_key(
841    state: &mut TabViewState,
842    key: &KeyEvent,
843    position: TabPosition,
844) -> bool {
845    // Handle tab bar navigation based on position
846    if state.tab_bar_focused {
847        match key.code {
848            // Horizontal navigation for horizontal tabs
849            KeyCode::Left if position.is_horizontal() => {
850                state.select_prev();
851                true
852            }
853            KeyCode::Right if position.is_horizontal() => {
854                state.select_next();
855                true
856            }
857            // Vertical navigation for vertical tabs
858            KeyCode::Up if position.is_vertical() => {
859                state.select_prev();
860                true
861            }
862            KeyCode::Down if position.is_vertical() => {
863                state.select_next();
864                true
865            }
866            // Home/End
867            KeyCode::Home => {
868                state.select_first();
869                true
870            }
871            KeyCode::End => {
872                state.select_last();
873                true
874            }
875            // Number keys for direct selection (1-9)
876            KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => {
877                let idx = (c as usize) - ('1' as usize);
878                if idx < state.total_tabs {
879                    state.select(idx);
880                }
881                true
882            }
883            // Enter to focus content
884            KeyCode::Enter => {
885                state.toggle_focus();
886                true
887            }
888            _ => false,
889        }
890    } else {
891        // Content focused - Escape to go back to tab bar
892        match key.code {
893            KeyCode::Esc => {
894                state.toggle_focus();
895                true
896            }
897            _ => false,
898        }
899    }
900}
901
902/// Handle mouse events for the tab view
903///
904/// Returns the action if a click was handled.
905pub fn handle_tab_view_mouse(
906    state: &mut TabViewState,
907    registry: &ClickRegionRegistry<TabViewAction>,
908    mouse: &MouseEvent,
909) -> Option<TabViewAction> {
910    use crossterm::event::{MouseButton, MouseEventKind};
911
912    if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
913        if let Some(action) = registry.handle_click(mouse.column, mouse.row) {
914            match action {
915                TabViewAction::TabClick(idx) => {
916                    state.select(*idx);
917                    state.tab_bar_focused = true;
918                    return Some(*action);
919                }
920                TabViewAction::ScrollPrev => {
921                    if state.scroll_offset > 0 {
922                        state.scroll_offset -= 1;
923                    }
924                    return Some(*action);
925                }
926                TabViewAction::ScrollNext => {
927                    state.scroll_offset += 1;
928                    return Some(*action);
929                }
930            }
931        }
932    }
933
934    None
935}
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940
941    #[test]
942    fn test_tab_creation() {
943        let tab = Tab::new("Test").icon("🔧").badge("5").enabled(true);
944
945        assert_eq!(tab.label, "Test");
946        assert_eq!(tab.icon, Some("🔧"));
947        assert_eq!(tab.badge, Some("5"));
948        assert!(tab.enabled);
949    }
950
951    #[test]
952    fn test_tab_display_width() {
953        let simple = Tab::new("Test");
954        // " Test " = 6 chars
955        assert_eq!(simple.display_width(), 6);
956
957        let with_icon = Tab::new("Test").icon("⚙");
958        // " ⚙ Test " = 8 chars
959        assert_eq!(with_icon.display_width(), 8);
960
961        let with_badge = Tab::new("Test").badge("3");
962        // " Test " + " 3 " = 6 + 3 = 9 chars
963        assert_eq!(with_badge.display_width(), 9);
964    }
965
966    #[test]
967    fn test_state_navigation() {
968        let mut state = TabViewState::new(5);
969        assert_eq!(state.selected_index, 0);
970
971        state.select_next();
972        assert_eq!(state.selected_index, 1);
973
974        state.select_prev();
975        assert_eq!(state.selected_index, 0);
976
977        state.select_prev(); // Should not go below 0
978        assert_eq!(state.selected_index, 0);
979
980        state.select_last();
981        assert_eq!(state.selected_index, 4);
982
983        state.select_next(); // Should not go above total
984        assert_eq!(state.selected_index, 4);
985
986        state.select_first();
987        assert_eq!(state.selected_index, 0);
988    }
989
990    #[test]
991    fn test_state_direct_select() {
992        let mut state = TabViewState::new(5);
993
994        state.select(3);
995        assert_eq!(state.selected_index, 3);
996
997        state.select(10); // Out of range - should not change
998        assert_eq!(state.selected_index, 3);
999    }
1000
1001    #[test]
1002    fn test_state_focus_toggle() {
1003        let mut state = TabViewState::new(3);
1004        assert!(state.tab_bar_focused);
1005
1006        state.toggle_focus();
1007        assert!(!state.tab_bar_focused);
1008
1009        state.toggle_focus();
1010        assert!(state.tab_bar_focused);
1011    }
1012
1013    #[test]
1014    fn test_ensure_visible() {
1015        let mut state = TabViewState::new(20);
1016        state.selected_index = 15;
1017        state.ensure_visible(10);
1018        assert!(state.scroll_offset >= 6); // 15 - 10 + 1 = 6
1019    }
1020
1021    #[test]
1022    fn test_tab_position() {
1023        assert!(TabPosition::Top.is_horizontal());
1024        assert!(TabPosition::Bottom.is_horizontal());
1025        assert!(TabPosition::Left.is_vertical());
1026        assert!(TabPosition::Right.is_vertical());
1027
1028        assert!(!TabPosition::Top.is_vertical());
1029        assert!(!TabPosition::Left.is_horizontal());
1030    }
1031
1032    #[test]
1033    fn test_style_presets() {
1034        let top = TabViewStyle::top();
1035        assert_eq!(top.position, TabPosition::Top);
1036
1037        let bottom = TabViewStyle::bottom();
1038        assert_eq!(bottom.position, TabPosition::Bottom);
1039
1040        let left = TabViewStyle::left();
1041        assert_eq!(left.position, TabPosition::Left);
1042        assert!(left.tab_width.is_some());
1043
1044        let right = TabViewStyle::right();
1045        assert_eq!(right.position, TabPosition::Right);
1046    }
1047
1048    #[test]
1049    fn test_focusable_impl() {
1050        let mut state = TabViewState::with_focus_id(3, FocusId::new(42));
1051
1052        assert_eq!(state.focus_id().id(), 42);
1053        assert!(!state.is_focused());
1054
1055        state.set_focused(true);
1056        assert!(state.is_focused());
1057    }
1058
1059    #[test]
1060    fn test_tab_view_render() {
1061        let tabs = vec![Tab::new("Tab 1"), Tab::new("Tab 2"), Tab::new("Tab 3")];
1062        let state = TabViewState::new(tabs.len());
1063        let tab_view = TabView::new(&tabs, &state);
1064
1065        let mut buf = Buffer::empty(Rect::new(0, 0, 50, 10));
1066        tab_view.render(Rect::new(0, 0, 50, 10), &mut buf);
1067        // Just verify it doesn't panic
1068    }
1069
1070    #[test]
1071    fn test_key_handling_horizontal() {
1072        let mut state = TabViewState::new(5);
1073
1074        // Right arrow moves next
1075        let key = KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::NONE);
1076        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1077        assert_eq!(state.selected_index, 1);
1078
1079        // Left arrow moves prev
1080        let key = KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::NONE);
1081        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1082        assert_eq!(state.selected_index, 0);
1083
1084        // Home goes to first
1085        let key = KeyEvent::new(KeyCode::Home, crossterm::event::KeyModifiers::NONE);
1086        state.select(3);
1087        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1088        assert_eq!(state.selected_index, 0);
1089
1090        // End goes to last
1091        let key = KeyEvent::new(KeyCode::End, crossterm::event::KeyModifiers::NONE);
1092        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1093        assert_eq!(state.selected_index, 4);
1094    }
1095
1096    #[test]
1097    fn test_key_handling_vertical() {
1098        let mut state = TabViewState::new(5);
1099
1100        // Down arrow moves next in vertical mode
1101        let key = KeyEvent::new(KeyCode::Down, crossterm::event::KeyModifiers::NONE);
1102        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Left));
1103        assert_eq!(state.selected_index, 1);
1104
1105        // Up arrow moves prev
1106        let key = KeyEvent::new(KeyCode::Up, crossterm::event::KeyModifiers::NONE);
1107        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Left));
1108        assert_eq!(state.selected_index, 0);
1109    }
1110
1111    #[test]
1112    fn test_number_key_selection() {
1113        let mut state = TabViewState::new(5);
1114
1115        // Press '3' to select tab 3 (index 2)
1116        let key = KeyEvent::new(KeyCode::Char('3'), crossterm::event::KeyModifiers::NONE);
1117        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1118        assert_eq!(state.selected_index, 2);
1119
1120        // Press '1' to select tab 1 (index 0)
1121        let key = KeyEvent::new(KeyCode::Char('1'), crossterm::event::KeyModifiers::NONE);
1122        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1123        assert_eq!(state.selected_index, 0);
1124    }
1125
1126    #[test]
1127    fn test_focus_toggle_with_enter() {
1128        let mut state = TabViewState::new(3);
1129        assert!(state.tab_bar_focused);
1130
1131        // Enter toggles to content
1132        let key = KeyEvent::new(KeyCode::Enter, crossterm::event::KeyModifiers::NONE);
1133        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1134        assert!(!state.tab_bar_focused);
1135
1136        // Escape goes back to tab bar
1137        let key = KeyEvent::new(KeyCode::Esc, crossterm::event::KeyModifiers::NONE);
1138        assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1139        assert!(state.tab_bar_focused);
1140    }
1141}