Skip to main content

zest_widget/widget/
tab_bar.rs

1//! Compact horizontal tab bar. Single widget that wraps a `Row` of
2//! tap-targets, highlighting the active tab via [`ButtonClass::Suggested`].
3//! Defaults to `Length::Fixed(20)` height — sized for `FONT_8X13` plus
4//! 2px border + 2px vertical padding.
5
6use super::{Widget, button::Button, element::Element, row::Row};
7use alloc::{string::String, vec::Vec};
8use embedded_graphics::{pixelcolor::PixelColor, prelude::*, primitives::Rectangle};
9use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase, UiAction, WidgetId};
10use zest_theme::{ButtonClass, Theme};
11
12/// Default tab-bar height.
13pub const DEFAULT_HEIGHT: u32 = 20;
14
15#[derive(Clone)]
16/// One tab's data: label + the message to emit when tapped.
17pub struct Tab<M> {
18    /// Visible label.
19    pub label: String,
20    /// Message emitted on tap.
21    pub message: M,
22    /// Whether this tab is currently the active one. Active tabs use
23    /// [`ButtonClass::Suggested`]; inactive use [`ButtonClass::Standard`].
24    pub active: bool,
25}
26
27impl<M> Tab<M> {
28    /// Construct a tab.
29    pub fn new(label: impl Into<String>, message: M, active: bool) -> Self {
30        Self {
31            label: label.into(),
32            message,
33            active,
34        }
35    }
36}
37
38/// Horizontal tab strip. Each tab is a button that emits a user
39/// message on tap; the active tab uses the suggested style.
40///
41/// Unlike the previous design, no theme reference is needed at
42/// construction — styling flows through the catalog at draw time.
43pub struct TabBar<'a, C: PixelColor, M: Clone> {
44    id: Option<WidgetId>,
45    tabs: Vec<Tab<M>>,
46    spacing: u32,
47    inner: Option<Row<'a, C, M>>,
48    width: Length,
49    height: Length,
50}
51
52impl<'a, C: PixelColor + 'a, M: Clone + 'a> TabBar<'a, C, M> {
53    /// Build a tab bar from a list of tabs.
54    pub fn new<I>(tabs: I) -> Self
55    where
56        I: IntoIterator<Item = Tab<M>>,
57    {
58        Self {
59            id: None,
60            tabs: tabs.into_iter().collect(),
61            spacing: 2,
62            inner: None,
63            width: Length::Fill,
64            height: Length::Fixed(DEFAULT_HEIGHT),
65        }
66    }
67
68    /// Width sizing intent.
69    #[must_use]
70    pub fn width(mut self, width: impl Into<Length>) -> Self {
71        self.width = width.into();
72        self
73    }
74
75    /// Height sizing intent. Defaults to [`DEFAULT_HEIGHT`].
76    #[must_use]
77    pub fn height(mut self, height: impl Into<Length>) -> Self {
78        self.height = height.into();
79        self
80    }
81
82    /// Set a stable base id so tabs can participate in focus traversal.
83    #[must_use]
84    pub fn id(mut self, id: WidgetId) -> Self {
85        self.id = Some(id);
86        self
87    }
88
89    /// Gap between tabs.
90    #[must_use]
91    pub fn spacing(mut self, spacing: u32) -> Self {
92        self.spacing = spacing;
93        self
94    }
95
96    fn build_inner(&self) -> Row<'a, C, M> {
97        let mut row = Row::new().spacing(self.spacing);
98        for (index, tab) in self.tabs.iter().enumerate() {
99            let class = if tab.active {
100                ButtonClass::Suggested
101            } else {
102                ButtonClass::Standard
103            };
104            let mut button = Button::new(tab.label.clone())
105                .on_press(tab.message.clone())
106                .class(class);
107            if let Some(id) = self.tab_id(index) {
108                button = button.id(id);
109            }
110            row = row.push(button);
111        }
112        row
113    }
114
115    fn ensure_built(&mut self) {
116        if self.inner.is_none() {
117            self.inner = Some(self.build_inner());
118        }
119    }
120
121    fn tab_id(&self, index: usize) -> Option<WidgetId> {
122        self.id
123            .map(|id| WidgetId::new(id.raw().wrapping_add(index as u64 + 1)))
124    }
125}
126
127impl<'a, C: PixelColor + 'a, M: Clone + 'a> Widget<C, M> for TabBar<'a, C, M> {
128    fn measure(&mut self, constraints: Constraints) -> Size {
129        let w = self
130            .width
131            .resolve(constraints.max.width, constraints.max.width);
132        let h = self.height.resolve(DEFAULT_HEIGHT, constraints.max.height);
133        constraints.clamp(Size::new(w, h))
134    }
135
136    fn preferred_size(&self) -> (Length, Length) {
137        (self.width, self.height)
138    }
139
140    fn arrange(&mut self, rect: Rectangle) {
141        self.ensure_built();
142        if let Some(inner) = self.inner.as_mut() {
143            inner.arrange(rect);
144        }
145    }
146
147    fn rect(&self) -> Rectangle {
148        self.inner.as_ref().map_or(Rectangle::zero(), Widget::rect)
149    }
150
151    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
152        self.ensure_built();
153        self.inner
154            .as_mut()
155            .and_then(|inner| Widget::<C, M>::handle_touch(inner, point, phase))
156    }
157
158    fn mark_pressed(&mut self, point: Point) {
159        self.ensure_built();
160        if let Some(inner) = self.inner.as_mut() {
161            Widget::<C, M>::mark_pressed(inner, point);
162        }
163    }
164
165    fn collect_focusable(&self, out: &mut Vec<WidgetId>) {
166        for index in 0..self.tabs.len() {
167            if let Some(id) = self.tab_id(index) {
168                out.push(id);
169            }
170        }
171    }
172
173    fn sync_focus(&mut self, focused: Option<WidgetId>) {
174        self.ensure_built();
175        if let Some(inner) = self.inner.as_mut() {
176            inner.sync_focus(focused);
177        }
178    }
179
180    fn route_action(&mut self, target: WidgetId, action: UiAction) -> Option<M> {
181        self.ensure_built();
182        self.inner
183            .as_mut()
184            .and_then(|inner| inner.route_action(target, action))
185    }
186
187    fn navigate_focus(&self, target: WidgetId, action: UiAction) -> Option<WidgetId> {
188        self.inner
189            .as_ref()
190            .and_then(|inner| inner.navigate_focus(target, action))
191    }
192
193    fn focus_rect(&self, target: WidgetId) -> Option<Rectangle> {
194        self.inner
195            .as_ref()
196            .and_then(|inner| inner.focus_rect(target))
197    }
198
199    fn focus_at(&self, point: Point) -> Option<WidgetId> {
200        self.inner.as_ref().and_then(|inner| inner.focus_at(point))
201    }
202
203    fn draw<'t>(
204        &self,
205        renderer: &mut dyn Renderer<C>,
206        theme: &Theme<'t, C>,
207    ) -> Result<(), RenderError> {
208        if let Some(inner) = self.inner.as_ref() {
209            Widget::<C, M>::draw(inner, renderer, theme)
210        } else {
211            Ok(())
212        }
213    }
214}
215
216impl<'a, C: PixelColor + 'a, M: Clone + 'a> From<TabBar<'a, C, M>> for Element<'a, C, M> {
217    fn from(bar: TabBar<'a, C, M>) -> Self {
218        Element::new(bar)
219    }
220}