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