Skip to main content

lv_tui/widgets/
tabs.rs

1use crate::component::{Component, EventCx, MeasureCx};
2use crate::event::Event;
3use crate::geom::{Rect, Size};
4use crate::layout::Constraint;
5use crate::node::Node;
6use crate::render::RenderCx;
7use crate::style::Style;
8
9/// A tabbed container widget.
10///
11/// Each tab has a title and a child component. Use `←`/`→` to switch tabs.
12/// Only the selected tab's child is rendered and receives events.
13pub struct Tabs {
14    /// (title, child node) pairs
15    tabs: Vec<(String, Node)>,
16    /// Currently selected tab index
17    selected: usize,
18    /// Current layout rect — updated on each layout pass
19    rect: Rect,
20    /// Default container style
21    style: Style,
22    /// Tab header row style
23    header_style: Style,
24    /// Selected tab style
25    selected_style: Style,
26}
27
28impl Tabs {
29    /// Creates an empty tab container.
30    pub fn new() -> Self {
31        Self {
32            tabs: Vec::new(),
33            selected: 0,
34            rect: Rect::default(),
35            style: Style::default(),
36            header_style: Style::default().bold(),
37            selected_style: Style::default(),
38        }
39    }
40
41    /// Adds a tab with the given title and child component.
42    pub fn tab(mut self, title: impl Into<String>, component: impl Component + 'static) -> Self {
43        self.tabs.push((title.into(), Node::new(component)));
44        self
45    }
46
47    /// Sets the container style.
48    pub fn style(mut self, style: Style) -> Self {
49        self.style = style;
50        self
51    }
52
53    /// Sets the header row style.
54    pub fn header_style(mut self, style: Style) -> Self {
55        self.header_style = style;
56        self
57    }
58
59    /// Sets the selected tab style.
60    pub fn selected_style(mut self, style: Style) -> Self {
61        self.selected_style = style;
62        self
63    }
64
65    /// Returns the currently selected tab index.
66    pub fn selected_index(&self) -> usize {
67        self.selected
68    }
69
70    /// Sets the selected tab.
71    pub fn set_selected(&mut self, index: usize, cx: &mut EventCx) {
72        if index < self.tabs.len() {
73            self.selected = index;
74            cx.invalidate_paint();
75        }
76    }
77
78    /// Returns the inner content area (below header + separator).
79    fn content_rect(&self) -> Rect {
80        let y = self.rect.y.saturating_add(2); // header + separator
81        Rect {
82            x: self.rect.x,
83            y,
84            width: self.rect.width,
85            height: self.rect.height.saturating_sub(2),
86        }
87    }
88}
89
90impl Component for Tabs {
91    fn render(&self, cx: &mut RenderCx) {
92        if self.tabs.is_empty() {
93            return;
94        }
95
96        // --- header row ---
97        for (i, (title, _)) in self.tabs.iter().enumerate() {
98            if i == self.selected {
99                cx.set_style(self.selected_style.clone());
100            } else {
101                cx.set_style(self.header_style.clone());
102            }
103            cx.text(format!(" {} ", title));
104            if i < self.tabs.len() - 1 {
105                cx.set_style(self.header_style.clone());
106                cx.text("│");
107            }
108        }
109        cx.line("");
110
111        // --- separator ---
112        cx.set_style(self.style.clone());
113        let total_width: u16 = self.tabs.iter()
114            .map(|(t, _)| t.chars().map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16).sum::<u16>() + 2)
115            .sum::<u16>()
116            + (self.tabs.len().saturating_sub(1)) as u16; // separators
117        cx.text("─".repeat(total_width as usize));
118        cx.line("");
119
120        // --- selected tab content ---
121        if let Some((_, child)) = self.tabs.get(self.selected) {
122            child.render_with_parent(cx.buffer, cx.focused_id, cx.clip_rect, cx.wrap, cx.truncate, cx.align, Some(&cx.style));
123        }
124    }
125
126    fn measure(&self, constraint: Constraint, _cx: &mut MeasureCx) -> Size {
127        if self.tabs.is_empty() {
128            return Size { width: 0, height: 0 };
129        }
130
131        let child_size = self.tabs.get(self.selected)
132            .map(|(_, child)| child.measure(constraint))
133            .unwrap_or_default();
134
135        Size {
136            width: constraint.max.width,
137            height: 2u16.saturating_add(child_size.height), // header + separator + content
138        }
139    }
140
141    fn event(&mut self, event: &Event, cx: &mut EventCx) {
142        if self.tabs.is_empty() {
143            return;
144        }
145
146        if matches!(event, Event::Focus | Event::Blur | Event::Tick) {
147            return;
148        }
149
150        // Handle tab-switching and global quit in Target or Bubble phase.
151        // Skip Capture — the focused child needs the event first.
152        if cx.phase() == crate::event::EventPhase::Capture {
153            return;
154        }
155        if let Event::Key(key_event) = event {
156            match &key_event.key {
157                crate::event::Key::Left => {
158                    if self.selected > 0 {
159                        self.selected -= 1;
160                    } else {
161                        self.selected = self.tabs.len() - 1;
162                    }
163                    cx.invalidate_paint();
164                    cx.dispatch(crate::event::Command::FocusPrev);
165                    return;
166                }
167                crate::event::Key::Right => {
168                    if self.selected + 1 < self.tabs.len() {
169                        self.selected += 1;
170                    } else {
171                        self.selected = 0;
172                    }
173                    cx.invalidate_paint();
174                    cx.dispatch(crate::event::Command::FocusNext);
175                    return;
176                }
177                crate::event::Key::Char('q') => {
178                    cx.quit();
179                    return;
180                }
181                _ => {}
182            }
183        }
184        // Handle Ctrl+C
185        if let Event::Key(k) = event {
186            if k.key == crate::event::Key::Char('c') && k.modifiers.ctrl {
187                cx.quit();
188            }
189        }
190    }
191
192    fn layout(&mut self, rect: Rect, _cx: &mut crate::component::LayoutCx) {
193        self.rect = rect;
194        let content = self.content_rect();
195        if let Some((_, child)) = self.tabs.get_mut(self.selected) {
196            child.layout(content);
197        }
198    }
199
200    fn for_each_child(&self, f: &mut dyn FnMut(&Node)) {
201        if let Some((_, child)) = self.tabs.get(self.selected) {
202            f(child);
203        }
204    }
205
206    fn for_each_child_mut(&mut self, f: &mut dyn FnMut(&mut Node)) {
207        if let Some((_, child)) = self.tabs.get_mut(self.selected) {
208            f(child);
209        }
210    }
211
212    fn focusable(&self) -> bool {
213        false
214    }
215
216    fn style(&self) -> Style {
217        self.style.clone()
218    }
219}