Skip to main content

rlvgl_widgets/
tabview.rs

1//! LPAR-13 tab bar + content-pane navigation widget.
2//!
3//! A [`Tabview`] splits its bounding rectangle into a fixed **tab bar** and a
4//! **content area**. Each tab has a name drawn as a button in the bar; activating
5//! a tab makes its content pane the only visible one. The tab bar position
6//! (top, bottom, left, right) is configurable via [`TabBarPos`].
7//!
8//! # Layout container role (LPAR-13 §5.G)
9//!
10//! `Tabview` overrides [`Widget::set_bounds`] and recomputes the tab-bar rect
11//! and content-area rect on every call so layout-driven sizing is adopted
12//! (LPAR-10 §5.A contract, LPAR-12 precedent).
13//!
14//! # Key navigation
15//!
16//! Call [`Tabview::navigate_next_tab`] / [`Tabview::navigate_prev_tab`] from an
17//! `ObjectEvent::Key` handler wired by the application (LPAR-12 pattern).
18//! No raw `Event::KeyDown` interception occurs inside `Widget::handle_event`.
19
20extern crate alloc;
21
22use alloc::string::String;
23use alloc::vec::Vec;
24
25use rlvgl_core::draw::draw_widget_bg;
26use rlvgl_core::event::Event;
27use rlvgl_core::font::{FontMetrics, WidgetFont, shape_text_ltr};
28use rlvgl_core::renderer::Renderer;
29use rlvgl_core::style::Style;
30use rlvgl_core::widget::{Color, Rect, Widget};
31
32// ---------------------------------------------------------------------------
33// Constants
34// ---------------------------------------------------------------------------
35
36/// Default tab bar thickness in pixels.
37const DEFAULT_BAR_THICKNESS: i32 = 24;
38/// Text padding inside a tab button.
39const TAB_PAD_X: i32 = 4;
40/// Sentinel value for [`TabId`] meaning "no tab".
41const TAB_NONE_VALUE: u16 = u16::MAX;
42
43// ---------------------------------------------------------------------------
44// TabId
45// ---------------------------------------------------------------------------
46
47/// Opaque identifier for a tab within a [`Tabview`].
48///
49/// Assigned sequentially by [`Tabview::add_tab`]. The sentinel value
50/// [`TAB_NONE`](Tabview::TAB_NONE) indicates an absent or invalid tab.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub struct TabId(pub u16);
53
54// ---------------------------------------------------------------------------
55// TabBarPos
56// ---------------------------------------------------------------------------
57
58/// Position of the tab bar within the [`Tabview`] bounding box.
59///
60/// Mirrors `lv_dir_t`-based positioning in LVGL's `lv_tabview`.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62pub enum TabBarPos {
63    /// Tab bar at the top of the widget; content area below.
64    #[default]
65    Top,
66    /// Tab bar at the bottom of the widget; content area above.
67    Bottom,
68    /// Tab bar at the left of the widget; content area to the right.
69    Left,
70    /// Tab bar at the right of the widget; content area to the left.
71    Right,
72}
73
74// ---------------------------------------------------------------------------
75// Internal tab descriptor
76// ---------------------------------------------------------------------------
77
78struct TabDesc {
79    name: String,
80}
81
82// ---------------------------------------------------------------------------
83// Tabview
84// ---------------------------------------------------------------------------
85
86/// Tab bar + content-pane navigation widget.
87///
88/// Create with [`Tabview::new`], add tabs with [`Tabview::add_tab`], and
89/// switch the active tab with [`Tabview::set_active`] or the navigation helpers.
90pub struct Tabview {
91    /// Widget bounding box.
92    bounds: Rect,
93    /// Ordered list of tabs.
94    tabs: Vec<TabDesc>,
95    /// Index into `tabs` of the currently active tab, or `u16::MAX` for none.
96    active_idx: u16,
97    /// Position of the tab bar.
98    bar_pos: TabBarPos,
99    /// Thickness of the tab bar in pixels.
100    bar_thickness: i32,
101    /// Counter for assigning sequential [`TabId`] values.
102    next_id: u16,
103    /// Overall widget background style (Part::MAIN).
104    pub style: Style,
105    /// Background color for inactive tab buttons (Part::ITEMS).
106    pub tab_color: Color,
107    /// Background color for the active tab button (Part::SELECTED).
108    pub active_tab_color: Color,
109    /// Text color for inactive tabs.
110    pub tab_text_color: Color,
111    /// Text color for the active tab.
112    pub active_tab_text_color: Color,
113    /// Font assignment for this widget (FONT-00 §5); resolves to `FONT_6X10`
114    /// when unset.
115    font: WidgetFont,
116}
117
118impl Tabview {
119    /// Sentinel [`TabId`] meaning "no tab selected / invalid".
120    pub const TAB_NONE: TabId = TabId(TAB_NONE_VALUE);
121
122    /// Create a new tabview with the given bounds and tab bar position.
123    ///
124    /// The tab bar uses the default thickness of `24` pixels. Adjust with
125    /// [`set_bar_pos`](Self::set_bar_pos) and [`set_bounds`](Self::set_bounds).
126    pub fn new(bounds: Rect, bar_pos: TabBarPos) -> Self {
127        Self {
128            bounds,
129            tabs: Vec::new(),
130            active_idx: TAB_NONE_VALUE,
131            bar_pos,
132            bar_thickness: DEFAULT_BAR_THICKNESS,
133            next_id: 0,
134            style: Style::default(),
135            tab_color: Color(140, 140, 140, 255),
136            active_tab_color: Color(60, 120, 200, 255),
137            tab_text_color: Color(200, 200, 200, 255),
138            active_tab_text_color: Color(255, 255, 255, 255),
139            font: WidgetFont::new(),
140        }
141    }
142
143    /// Assign the font used to render this widget (FONT-00 §5); resolves to
144    /// `FONT_6X10` when unset.
145    pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
146        self.font.set(font);
147    }
148
149    /// Append a tab with the given name and return its [`TabId`].
150    ///
151    /// If this is the first tab, it becomes the active tab automatically.
152    pub fn add_tab(&mut self, name: &str) -> TabId {
153        let id = TabId(self.next_id);
154        self.next_id = self.next_id.saturating_add(1);
155        let idx = self.tabs.len() as u16;
156        self.tabs.push(TabDesc {
157            name: String::from(name),
158        });
159        if self.active_idx == TAB_NONE_VALUE {
160            self.active_idx = idx;
161        }
162        id
163    }
164
165    /// Rename the tab with the given id.
166    ///
167    /// No-op if the id is out of range or `TAB_NONE`.
168    pub fn rename_tab(&mut self, id: TabId, name: &str) {
169        if let Some(tab) = self.tabs.get_mut(id.0 as usize) {
170            tab.name = String::from(name);
171        }
172    }
173
174    /// Make the tab with the given id the active one.
175    ///
176    /// No-op if the id is out of range or `TAB_NONE`.
177    pub fn set_active(&mut self, id: TabId) {
178        if id.0 < self.tabs.len() as u16 {
179            self.active_idx = id.0;
180        }
181    }
182
183    /// Return the [`TabId`] of the currently active tab, or [`TAB_NONE`](Self::TAB_NONE).
184    pub fn active_tab(&self) -> TabId {
185        if self.active_idx == TAB_NONE_VALUE {
186            Self::TAB_NONE
187        } else {
188            TabId(self.active_idx)
189        }
190    }
191
192    /// Return the number of registered tabs.
193    pub fn tab_count(&self) -> usize {
194        self.tabs.len()
195    }
196
197    /// Return the content-area rect for the given tab id.
198    ///
199    /// All tabs share the same content-area geometry (the bounding rect minus
200    /// the tab bar). Returns a zero-area rect if `id` is out of range.
201    pub fn tab_content_bounds(&self, id: TabId) -> Rect {
202        if id.0 as usize >= self.tabs.len() {
203            return Rect {
204                x: 0,
205                y: 0,
206                width: 0,
207                height: 0,
208            };
209        }
210        self.content_rect()
211    }
212
213    /// Return the tab bar position.
214    pub fn bar_pos(&self) -> TabBarPos {
215        self.bar_pos
216    }
217
218    /// Set the tab bar position and recompute internal geometry.
219    pub fn set_bar_pos(&mut self, pos: TabBarPos) {
220        self.bar_pos = pos;
221    }
222
223    /// Advance to the next tab, wrapping from the last back to the first.
224    ///
225    /// No-op when there are fewer than two tabs.
226    ///
227    /// Wire to `ObjectEvent::Key(Key::ArrowRight)` or `Key::Tab` from the
228    /// application handler.
229    pub fn navigate_next_tab(&mut self) {
230        let n = self.tabs.len() as u16;
231        if n < 2 {
232            return;
233        }
234        self.active_idx = (self.active_idx.saturating_add(1)) % n;
235    }
236
237    /// Move to the previous tab, wrapping from the first back to the last.
238    ///
239    /// No-op when there are fewer than two tabs.
240    ///
241    /// Wire to `ObjectEvent::Key(Key::ArrowLeft)` from the application handler.
242    pub fn navigate_prev_tab(&mut self) {
243        let n = self.tabs.len() as u16;
244        if n < 2 {
245            return;
246        }
247        self.active_idx = if self.active_idx == 0 {
248            n - 1
249        } else {
250            self.active_idx - 1
251        };
252    }
253
254    // -----------------------------------------------------------------------
255    // Private geometry helpers
256    // -----------------------------------------------------------------------
257
258    /// Return the screen-space rect of the tab bar.
259    fn bar_rect(&self) -> Rect {
260        let t = self.bar_thickness;
261        match self.bar_pos {
262            TabBarPos::Top => Rect {
263                x: self.bounds.x,
264                y: self.bounds.y,
265                width: self.bounds.width,
266                height: t,
267            },
268            TabBarPos::Bottom => Rect {
269                x: self.bounds.x,
270                y: self.bounds.y + self.bounds.height - t,
271                width: self.bounds.width,
272                height: t,
273            },
274            TabBarPos::Left => Rect {
275                x: self.bounds.x,
276                y: self.bounds.y,
277                width: t,
278                height: self.bounds.height,
279            },
280            TabBarPos::Right => Rect {
281                x: self.bounds.x + self.bounds.width - t,
282                y: self.bounds.y,
283                width: t,
284                height: self.bounds.height,
285            },
286        }
287    }
288
289    /// Return the screen-space rect of the content area.
290    fn content_rect(&self) -> Rect {
291        let t = self.bar_thickness;
292        match self.bar_pos {
293            TabBarPos::Top => Rect {
294                x: self.bounds.x,
295                y: self.bounds.y + t,
296                width: self.bounds.width,
297                height: (self.bounds.height - t).max(0),
298            },
299            TabBarPos::Bottom => Rect {
300                x: self.bounds.x,
301                y: self.bounds.y,
302                width: self.bounds.width,
303                height: (self.bounds.height - t).max(0),
304            },
305            TabBarPos::Left => Rect {
306                x: self.bounds.x + t,
307                y: self.bounds.y,
308                width: (self.bounds.width - t).max(0),
309                height: self.bounds.height,
310            },
311            TabBarPos::Right => Rect {
312                x: self.bounds.x,
313                y: self.bounds.y,
314                width: (self.bounds.width - t).max(0),
315                height: self.bounds.height,
316            },
317        }
318    }
319
320    /// Draw the tab bar with all tab buttons.
321    fn draw_bar(&self, renderer: &mut dyn Renderer) {
322        let bar = self.bar_rect();
323        let n = self.tabs.len();
324        if n == 0 {
325            return;
326        }
327
328        let (is_horiz, tab_w, tab_h) = match self.bar_pos {
329            TabBarPos::Top | TabBarPos::Bottom => {
330                let w = bar.width / n as i32;
331                (true, w, bar.height)
332            }
333            TabBarPos::Left | TabBarPos::Right => {
334                let h = bar.height / n as i32;
335                (false, bar.width, h)
336            }
337        };
338
339        for (i, tab) in self.tabs.iter().enumerate() {
340            let (tx, ty) = if is_horiz {
341                (bar.x + i as i32 * tab_w, bar.y)
342            } else {
343                (bar.x, bar.y + i as i32 * tab_h)
344            };
345            let tab_rect = Rect {
346                x: tx,
347                y: ty,
348                width: tab_w,
349                height: tab_h,
350            };
351            let is_active = i as u16 == self.active_idx;
352            let bg = if is_active {
353                self.active_tab_color
354            } else {
355                self.tab_color
356            };
357            if bg.3 > 0 {
358                renderer.fill_rect(tab_rect, bg);
359            }
360            // Shaped text label.
361            let text_color = if is_active {
362                self.active_tab_text_color
363            } else {
364                self.tab_text_color
365            };
366            if text_color.3 > 0 {
367                let font = self.font.resolve();
368                let metrics = rlvgl_core::font::FontMetrics::line_metrics(font);
369                let baseline =
370                    ty + metrics.ascent as i32 + (tab_h - metrics.line_height as i32) / 2;
371                let shaped = shape_text_ltr(font, &tab.name, (tx + TAB_PAD_X, baseline), 0);
372                renderer.draw_text_shaped(&shaped, (0, 0), text_color);
373            }
374        }
375    }
376}
377
378impl Widget for Tabview {
379    fn bounds(&self) -> Rect {
380        self.bounds
381    }
382
383    fn widget_font_mut(&mut self) -> Option<&mut WidgetFont> {
384        Some(&mut self.font)
385    }
386
387    fn set_bounds(&mut self, bounds: Rect) {
388        self.bounds = bounds;
389        // Geometry is derived lazily from bounds + bar_pos + bar_thickness.
390    }
391
392    fn draw(&self, renderer: &mut dyn Renderer) {
393        if self.bounds.width <= 0 || self.bounds.height <= 0 {
394            return;
395        }
396        // Part::MAIN background.
397        draw_widget_bg(renderer, self.bounds, &self.style);
398        // Tab bar (Part::ITEMS / Part::SELECTED per button).
399        self.draw_bar(renderer);
400        // Content area: only the active tab's area is filled here; the caller
401        // places child widgets in the content bounds.
402        let content = self.content_rect();
403        if content.width > 0 && content.height > 0 && self.style.bg_color.3 > 0 {
404            renderer.fill_rect(content, self.style.bg_color);
405        }
406    }
407
408    fn handle_event(&mut self, _event: &Event) -> bool {
409        false
410    }
411}
412
413// ---------------------------------------------------------------------------
414// Tests
415// ---------------------------------------------------------------------------
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    fn rect(x: i32, y: i32, w: i32, h: i32) -> Rect {
422        Rect {
423            x,
424            y,
425            width: w,
426            height: h,
427        }
428    }
429
430    struct NullRenderer;
431    impl rlvgl_core::renderer::Renderer for NullRenderer {
432        fn fill_rect(&mut self, _r: Rect, _c: Color) {}
433        fn draw_text(&mut self, _pos: (i32, i32), _t: &str, _c: Color) {}
434    }
435
436    #[test]
437    fn new_has_no_tabs_and_none_active() {
438        let tv = Tabview::new(rect(0, 0, 200, 120), TabBarPos::Top);
439        assert_eq!(tv.tab_count(), 0);
440        assert_eq!(tv.active_tab(), Tabview::TAB_NONE);
441    }
442
443    #[test]
444    fn first_add_makes_active_and_returns_id() {
445        let mut tv = Tabview::new(rect(0, 0, 200, 120), TabBarPos::Top);
446        let id = tv.add_tab("Home");
447        assert_eq!(tv.tab_count(), 1);
448        assert_eq!(tv.active_tab(), id);
449    }
450
451    #[test]
452    fn set_active_changes_active_tab() {
453        let mut tv = Tabview::new(rect(0, 0, 200, 120), TabBarPos::Top);
454        let a = tv.add_tab("A");
455        let b = tv.add_tab("B");
456        tv.set_active(b);
457        assert_eq!(tv.active_tab(), b);
458        tv.set_active(a);
459        assert_eq!(tv.active_tab(), a);
460    }
461
462    #[test]
463    fn navigate_next_wraps_to_first() {
464        let mut tv = Tabview::new(rect(0, 0, 200, 120), TabBarPos::Top);
465        let a = tv.add_tab("A");
466        let b = tv.add_tab("B");
467        tv.set_active(b);
468        tv.navigate_next_tab();
469        assert_eq!(tv.active_tab(), a); // wrapped
470    }
471
472    #[test]
473    fn navigate_prev_wraps_to_last() {
474        let mut tv = Tabview::new(rect(0, 0, 200, 120), TabBarPos::Top);
475        let a = tv.add_tab("A");
476        let b = tv.add_tab("B");
477        tv.set_active(a);
478        tv.navigate_prev_tab();
479        assert_eq!(tv.active_tab(), b); // wrapped to last
480    }
481
482    #[test]
483    fn content_bounds_excludes_bar_for_top_position() {
484        let mut tv = Tabview::new(rect(0, 0, 200, 120), TabBarPos::Top);
485        let id = tv.add_tab("Tab");
486        let content = tv.tab_content_bounds(id);
487        assert_eq!(content.y, DEFAULT_BAR_THICKNESS);
488        assert_eq!(content.height, 120 - DEFAULT_BAR_THICKNESS);
489    }
490
491    #[test]
492    fn content_bounds_excludes_bar_for_bottom_position() {
493        let mut tv = Tabview::new(rect(0, 0, 200, 120), TabBarPos::Bottom);
494        let id = tv.add_tab("Tab");
495        let content = tv.tab_content_bounds(id);
496        assert_eq!(content.y, 0);
497        assert_eq!(content.height, 120 - DEFAULT_BAR_THICKNESS);
498    }
499
500    #[test]
501    fn rename_tab_changes_name() {
502        let mut tv = Tabview::new(rect(0, 0, 200, 120), TabBarPos::Top);
503        let id = tv.add_tab("OldName");
504        tv.rename_tab(id, "NewName");
505        assert_eq!(tv.tabs[0].name, "NewName");
506    }
507
508    #[test]
509    fn set_bounds_updates_geometry() {
510        let mut tv = Tabview::new(rect(0, 0, 200, 120), TabBarPos::Top);
511        tv.set_bounds(rect(10, 10, 300, 200));
512        assert_eq!(tv.bounds(), rect(10, 10, 300, 200));
513    }
514
515    #[test]
516    fn draw_does_not_panic_with_no_tabs() {
517        let tv = Tabview::new(rect(0, 0, 200, 120), TabBarPos::Top);
518        let mut r = NullRenderer;
519        tv.draw(&mut r); // must not panic
520    }
521}