Skip to main content

revue/widget/layout/
tabs.rs

1//! Tabs widget for tabbed navigation
2
3use crate::render::Cell;
4use crate::style::Color;
5use crate::utils::Selection;
6use crate::widget::traits::{RenderContext, View, WidgetProps};
7use crate::{impl_props_builders, impl_styled_view};
8
9/// Tab item
10#[derive(Clone)]
11pub struct Tab {
12    /// Tab label
13    pub label: String,
14}
15
16impl Tab {
17    /// Create a new tab
18    pub fn new(label: impl Into<String>) -> Self {
19        Self {
20            label: label.into(),
21        }
22    }
23}
24
25/// Tabs widget for tabbed navigation
26pub struct Tabs {
27    tabs: Vec<Tab>,
28    selection: Selection,
29    fg: Option<Color>,
30    bg: Option<Color>,
31    active_fg: Option<Color>,
32    active_bg: Option<Color>,
33    divider: char,
34    props: WidgetProps,
35}
36
37impl Tabs {
38    /// Create a new tabs widget
39    pub fn new() -> Self {
40        Self {
41            tabs: Vec::new(),
42            selection: Selection::new(0),
43            fg: None,
44            bg: None,
45            active_fg: Some(Color::WHITE),
46            active_bg: Some(Color::BLUE),
47            divider: '│',
48            props: WidgetProps::new(),
49        }
50    }
51
52    /// Set tabs
53    pub fn tabs(mut self, tabs: Vec<impl Into<String>>) -> Self {
54        self.tabs = tabs.into_iter().map(|t| Tab::new(t)).collect();
55        self.selection.set_len(self.tabs.len());
56        self
57    }
58
59    /// Add a tab
60    pub fn tab(mut self, label: impl Into<String>) -> Self {
61        self.tabs.push(Tab::new(label));
62        self.selection.set_len(self.tabs.len());
63        self
64    }
65
66    /// Set selected tab index
67    pub fn selected(mut self, index: usize) -> Self {
68        self.selection.set(index);
69        self
70    }
71
72    /// Set foreground color
73    pub fn fg(mut self, color: Color) -> Self {
74        self.fg = Some(color);
75        self
76    }
77
78    /// Set background color
79    pub fn bg(mut self, color: Color) -> Self {
80        self.bg = Some(color);
81        self
82    }
83
84    /// Set active tab colors
85    pub fn active_style(mut self, fg: Color, bg: Color) -> Self {
86        self.active_fg = Some(fg);
87        self.active_bg = Some(bg);
88        self
89    }
90
91    /// Set divider character
92    pub fn divider(mut self, ch: char) -> Self {
93        self.divider = ch;
94        self
95    }
96
97    /// Get selected tab index
98    pub fn selected_index(&self) -> usize {
99        self.selection.index
100    }
101
102    /// Get selected tab label
103    pub fn selected_label(&self) -> Option<&str> {
104        self.tabs
105            .get(self.selection.index)
106            .map(|t| t.label.as_str())
107    }
108
109    /// Select next tab (wraps around)
110    pub fn select_next(&mut self) {
111        self.selection.next();
112    }
113
114    /// Select previous tab (wraps around)
115    pub fn select_prev(&mut self) {
116        self.selection.prev();
117    }
118
119    /// Select first tab
120    pub fn select_first(&mut self) {
121        self.selection.first();
122    }
123
124    /// Select last tab
125    pub fn select_last(&mut self) {
126        self.selection.last();
127    }
128
129    /// Select tab by index
130    pub fn select(&mut self, index: usize) {
131        self.selection.set(index);
132    }
133
134    /// Handle key input, returns true if selection changed
135    pub fn handle_key(&mut self, key: &crate::event::Key) -> bool {
136        use crate::event::Key;
137
138        match key {
139            Key::Left | Key::Char('h') => {
140                let old = self.selection.index;
141                self.select_prev();
142                old != self.selection.index
143            }
144            Key::Right | Key::Char('l') => {
145                let old = self.selection.index;
146                self.select_next();
147                old != self.selection.index
148            }
149            Key::Home => {
150                let old = self.selection.index;
151                self.select_first();
152                old != self.selection.index
153            }
154            Key::End => {
155                let old = self.selection.index;
156                self.select_last();
157                old != self.selection.index
158            }
159            Key::Char(c) if c.is_ascii_digit() => {
160                let index = (*c as usize) - ('1' as usize);
161                if index < self.tabs.len() {
162                    let old = self.selection.index;
163                    self.selection.index = index;
164                    old != self.selection.index
165                } else {
166                    false
167                }
168            }
169            _ => false,
170        }
171    }
172
173    /// Get number of tabs
174    pub fn len(&self) -> usize {
175        self.tabs.len()
176    }
177
178    /// Check if tabs is empty
179    pub fn is_empty(&self) -> bool {
180        self.tabs.is_empty()
181    }
182}
183
184impl Default for Tabs {
185    fn default() -> Self {
186        Self::new()
187    }
188}
189
190impl View for Tabs {
191    fn render(&self, ctx: &mut RenderContext) {
192        let area = ctx.area;
193        if area.width < 3 || area.height < 1 || self.tabs.is_empty() {
194            return;
195        }
196
197        let mut x = area.x;
198
199        for (i, tab) in self.tabs.iter().enumerate() {
200            let is_active = i == self.selection.index;
201            let (fg, bg) = if is_active {
202                (self.active_fg, self.active_bg)
203            } else {
204                (self.fg, self.bg)
205            };
206
207            // Draw padding
208            let mut cell = Cell::new(' ');
209            cell.fg = fg;
210            cell.bg = bg;
211            if x < area.x + area.width {
212                ctx.buffer.set(x, area.y, cell);
213                x += 1;
214            }
215
216            // Draw label
217            for ch in tab.label.chars() {
218                if x >= area.x + area.width {
219                    break;
220                }
221                let mut cell = Cell::new(ch);
222                cell.fg = fg;
223                cell.bg = bg;
224                if is_active {
225                    cell.modifier |= crate::render::Modifier::BOLD;
226                }
227                ctx.buffer.set(x, area.y, cell);
228                x += 1;
229            }
230
231            // Draw padding
232            if x < area.x + area.width {
233                let mut cell = Cell::new(' ');
234                cell.fg = fg;
235                cell.bg = bg;
236                ctx.buffer.set(x, area.y, cell);
237                x += 1;
238            }
239
240            // Draw divider (unless last tab)
241            if i < self.tabs.len() - 1 && x < area.x + area.width {
242                let mut cell = Cell::new(self.divider);
243                cell.fg = self.fg;
244                ctx.buffer.set(x, area.y, cell);
245                x += 1;
246            }
247        }
248    }
249
250    crate::impl_view_meta!("Tabs");
251}
252
253/// Helper function to create tabs
254pub fn tabs() -> Tabs {
255    Tabs::new()
256}
257
258impl_styled_view!(Tabs);
259impl_props_builders!(Tabs);
260
261// Most tests moved to tests/widget_tests.rs
262// Tests below access private fields and must stay inline