Skip to main content

armas_basic/components/
tabs.rs

1//! Tabs Component
2//!
3//! Tab navigation styled like shadcn/ui Tabs.
4//! Features a muted background container with animated active indicator.
5
6use super::content::ContentContext;
7use crate::ext::ArmasContextExt;
8use egui::{Pos2, Ui, Vec2};
9
10// Default constants
11const DEFAULT_HEIGHT: f32 = 28.0;
12const DEFAULT_PADDING: f32 = 2.0;
13const DEFAULT_LIST_RADIUS: f32 = 6.0;
14const DEFAULT_TRIGGER_RADIUS: f32 = 4.0;
15const DEFAULT_TRIGGER_PADDING_X: f32 = 8.0;
16const DEFAULT_GAP: f32 = 4.0;
17const DEFAULT_FONT_SIZE: f32 = 13.5;
18
19/// Response from the tabs component
20#[derive(Debug, Clone)]
21pub struct TabsResponse {
22    /// The underlying egui response
23    pub response: egui::Response,
24    /// The newly selected tab index, if changed this frame
25    pub selected: Option<usize>,
26    /// Whether the selection changed this frame
27    pub changed: bool,
28}
29
30/// Tabs component for switching between content sections
31///
32/// # Example
33///
34/// ```rust,no_run
35/// # use egui::Ui;
36/// # fn example(ui: &mut Ui) {
37/// use armas_basic::Tabs;
38///
39/// let mut tabs = Tabs::new(vec!["Account", "Password"])
40///     .height(24.0)
41///     .font_size(12.0);
42/// let response = tabs.show(ui);
43/// if response.changed {
44///     // Tab changed to response.selected
45/// }
46/// # }
47/// ```
48pub struct Tabs {
49    labels: Vec<String>,
50    active_index: usize,
51    animate: bool,
52    indicator_pos: f32,
53    persist_state: bool,
54    // Visual config
55    width: f32,
56    height: f32,
57    padding: f32,
58    list_radius: f32,
59    trigger_radius: f32,
60    trigger_padding_x: f32,
61    gap: f32,
62    font_size: f32,
63}
64
65impl Tabs {
66    /// Create new tabs with labels
67    #[must_use]
68    pub fn new(labels: Vec<impl Into<String>>) -> Self {
69        Self {
70            labels: labels.into_iter().map(std::convert::Into::into).collect(),
71            active_index: 0,
72            animate: true,
73            indicator_pos: 0.0,
74            persist_state: true,
75            width: 0.0,
76            height: DEFAULT_HEIGHT,
77            padding: DEFAULT_PADDING,
78            list_radius: DEFAULT_LIST_RADIUS,
79            trigger_radius: DEFAULT_TRIGGER_RADIUS,
80            trigger_padding_x: DEFAULT_TRIGGER_PADDING_X,
81            gap: DEFAULT_GAP,
82            font_size: DEFAULT_FONT_SIZE,
83        }
84    }
85
86    /// Set active tab index
87    #[must_use]
88    pub fn active(mut self, index: usize) -> Self {
89        let max = if self.labels.is_empty() {
90            usize::MAX
91        } else {
92            self.labels.len().saturating_sub(1)
93        };
94        self.active_index = index.min(max);
95        self.indicator_pos = self.active_index as f32;
96        self.persist_state = false;
97        self
98    }
99
100    /// Enable or disable animation
101    #[must_use]
102    pub const fn animate(mut self, animate: bool) -> Self {
103        self.animate = animate;
104        self
105    }
106
107    /// Set width (0 = fit content, which is the default)
108    #[must_use]
109    pub const fn width(mut self, width: f32) -> Self {
110        self.width = width;
111        self
112    }
113
114    /// Set overall height
115    #[must_use]
116    pub const fn height(mut self, height: f32) -> Self {
117        self.height = height;
118        self
119    }
120
121    /// Set inner padding
122    #[must_use]
123    pub const fn padding(mut self, padding: f32) -> Self {
124        self.padding = padding;
125        self
126    }
127
128    /// Set outer container corner radius
129    #[must_use]
130    pub const fn list_radius(mut self, radius: f32) -> Self {
131        self.list_radius = radius;
132        self
133    }
134
135    /// Set active indicator corner radius
136    #[must_use]
137    pub const fn trigger_radius(mut self, radius: f32) -> Self {
138        self.trigger_radius = radius;
139        self
140    }
141
142    /// Set horizontal padding inside each tab trigger
143    #[must_use]
144    pub const fn trigger_padding_x(mut self, padding: f32) -> Self {
145        self.trigger_padding_x = padding;
146        self
147    }
148
149    /// Set gap between tabs
150    #[must_use]
151    pub const fn gap(mut self, gap: f32) -> Self {
152        self.gap = gap;
153        self
154    }
155
156    /// Set font size
157    #[must_use]
158    pub const fn font_size(mut self, size: f32) -> Self {
159        self.font_size = size;
160        self
161    }
162
163    /// Load persisted state and update animation.
164    fn load_and_animate(&mut self, ui: &Ui, count: usize) {
165        if self.persist_state {
166            let tabs_id = ui.id().with("tabs_state");
167            let (stored_active, stored_indicator): (usize, f32) = ui.ctx().data_mut(|d| {
168                d.get_persisted(tabs_id)
169                    .unwrap_or((self.active_index, self.active_index as f32))
170            });
171
172            if self.active_index == 0 && stored_active > 0 && stored_active < count {
173                self.active_index = stored_active;
174            }
175            self.indicator_pos = stored_indicator;
176        }
177
178        let dt = ui.input(|i| i.stable_dt);
179        if self.animate {
180            let target = self.active_index as f32;
181            let speed = 12.0;
182            self.indicator_pos += (target - self.indicator_pos) * speed * dt;
183            if (self.indicator_pos - target).abs() > 0.01 {
184                ui.ctx().request_repaint();
185            }
186        } else {
187            self.indicator_pos = self.active_index as f32;
188        }
189    }
190
191    /// Compute tab widths and total width.
192    fn compute_widths(&self, ui: &Ui, count: usize) -> (Vec<f32>, f32) {
193        let total_gap = self.gap * count.saturating_sub(1) as f32;
194        let explicit_width = if self.width > 0.0 { self.width } else { 0.0 };
195
196        let tab_widths: Vec<f32> = if explicit_width > 0.0 {
197            let inner = explicit_width - self.padding * 2.0 - total_gap;
198            let per_tab = inner / count as f32;
199            vec![per_tab; count]
200        } else if !self.labels.is_empty() {
201            // Fit to content using label text
202            let char_width = self.font_size * 0.6;
203            self.labels
204                .iter()
205                .map(|label| {
206                    let text_width = char_width * label.len() as f32;
207                    text_width + self.trigger_padding_x * 2.0
208                })
209                .collect()
210        } else {
211            // Content mode with no labels: use available width, distribute evenly
212            let avail = ui.available_width();
213            let inner = avail - self.padding * 2.0 - total_gap;
214            let per_tab = inner / count as f32;
215            vec![per_tab; count]
216        };
217
218        let total_width = if explicit_width > 0.0 {
219            explicit_width
220        } else if !self.labels.is_empty() {
221            tab_widths.iter().sum::<f32>() + total_gap + self.padding * 2.0
222        } else {
223            ui.available_width()
224        };
225
226        (tab_widths, total_width)
227    }
228
229    /// Draw the list background and animated indicator. Returns `(list_rect, x_positions, inner_height)`.
230    fn draw_background(
231        &self,
232        ui: &mut Ui,
233        tab_widths: &[f32],
234        total_width: f32,
235        count: usize,
236    ) -> (egui::Rect, Vec<f32>, f32, egui::Response) {
237        let theme = ui.ctx().armas_theme();
238
239        let (list_rect, list_response) =
240            ui.allocate_exact_size(Vec2::new(total_width, self.height), egui::Sense::hover());
241
242        ui.painter()
243            .rect_filled(list_rect, self.list_radius, theme.muted());
244
245        let inner_height = self.height - self.padding * 2.0;
246
247        // Calculate cumulative x positions
248        let mut x_positions: Vec<f32> = Vec::with_capacity(count);
249        let mut current_x = list_rect.min.x + self.padding;
250        for (i, width) in tab_widths.iter().enumerate() {
251            x_positions.push(current_x);
252            current_x += width;
253            if i < count - 1 {
254                current_x += self.gap;
255            }
256        }
257
258        // Draw animated active indicator background
259        if !tab_widths.is_empty() {
260            let floor_idx = (self.indicator_pos.floor() as usize).min(tab_widths.len() - 1);
261            let ceil_idx = (self.indicator_pos.ceil() as usize).min(tab_widths.len() - 1);
262            let t = self.indicator_pos.fract();
263
264            let start_x =
265                x_positions[floor_idx] + (x_positions[ceil_idx] - x_positions[floor_idx]) * t;
266            let width = tab_widths[floor_idx] + (tab_widths[ceil_idx] - tab_widths[floor_idx]) * t;
267
268            let active_rect = egui::Rect::from_min_size(
269                Pos2::new(start_x, list_rect.min.y + self.padding),
270                Vec2::new(width, inner_height),
271            );
272
273            ui.painter()
274                .rect_filled(active_rect, self.trigger_radius, theme.background());
275        }
276
277        (list_rect, x_positions, inner_height, list_response)
278    }
279
280    /// Persist state if enabled.
281    fn persist(&self, ui: &Ui) {
282        if self.persist_state {
283            let tabs_id = ui.id().with("tabs_state");
284            ui.ctx().data_mut(|d| {
285                d.insert_persisted(tabs_id, (self.active_index, self.indicator_pos));
286            });
287        }
288    }
289
290    /// Show the tabs and return the response
291    pub fn show(&mut self, ui: &mut Ui) -> TabsResponse {
292        let n = self.labels.len();
293        if n == 0 {
294            let (_, empty_response) =
295                ui.allocate_exact_size(egui::Vec2::new(0.0, self.height), egui::Sense::hover());
296            return TabsResponse {
297                response: empty_response,
298                selected: None,
299                changed: false,
300            };
301        }
302
303        let theme = ui.ctx().armas_theme();
304        self.load_and_animate(ui, n);
305
306        let (tab_widths, total_width) = self.compute_widths(ui, n);
307        let (list_rect, x_positions, inner_height, list_response) =
308            self.draw_background(ui, &tab_widths, total_width, n);
309
310        let font_id = egui::FontId::proportional(self.font_size);
311        let mut selected = None;
312
313        for (index, label) in self.labels.iter().enumerate() {
314            let tab_rect = egui::Rect::from_min_size(
315                Pos2::new(x_positions[index], list_rect.min.y + self.padding),
316                Vec2::new(tab_widths[index], inner_height),
317            );
318
319            let is_active = index == self.active_index;
320            let is_hovered = ui.rect_contains_pointer(tab_rect);
321
322            let text_color = if is_active {
323                theme.foreground()
324            } else {
325                theme.muted_foreground()
326            };
327
328            ui.painter().text(
329                tab_rect.center(),
330                egui::Align2::CENTER_CENTER,
331                label,
332                font_id.clone(),
333                text_color,
334            );
335
336            if is_hovered && ui.input(|i| i.pointer.primary_clicked()) {
337                selected = Some(index);
338                self.active_index = index;
339            }
340        }
341
342        let changed = selected.is_some();
343        if let Some(new_index) = selected {
344            self.active_index = new_index;
345        }
346
347        self.persist(ui);
348
349        TabsResponse {
350            response: list_response,
351            selected,
352            changed,
353        }
354    }
355
356    /// Show the tabs with custom content for each tab trigger.
357    ///
358    /// The closure receives the tab index, a `&mut Ui`, and a [`ContentContext`].
359    /// If [`width`](Self::width) is not set, the tabs use `ui.available_width()`
360    /// and distribute tab widths evenly.
361    ///
362    /// # Example
363    ///
364    /// ```ignore
365    /// let mut tabs = Tabs::new(Vec::<String>::new()).height(28.0);
366    /// let response = tabs.show_ui(ui, 3, |index, ui, ctx| {
367    ///     // Render icon + label for tab `index` using ctx.color
368    ///     let labels = ["Account", "Password", "Settings"];
369    ///     ui.label(labels[index]);
370    /// });
371    /// ```
372    pub fn show_ui(
373        &mut self,
374        ui: &mut Ui,
375        count: usize,
376        render_tab: impl Fn(usize, &mut Ui, &ContentContext),
377    ) -> TabsResponse {
378        if count == 0 {
379            let (_, empty_response) =
380                ui.allocate_exact_size(egui::Vec2::new(0.0, self.height), egui::Sense::hover());
381            return TabsResponse {
382                response: empty_response,
383                selected: None,
384                changed: false,
385            };
386        }
387
388        let theme = ui.ctx().armas_theme();
389        self.load_and_animate(ui, count);
390
391        let (tab_widths, total_width) = self.compute_widths(ui, count);
392        let (list_rect, x_positions, inner_height, list_response) =
393            self.draw_background(ui, &tab_widths, total_width, count);
394
395        let mut selected = None;
396
397        for index in 0..count {
398            let tab_rect = egui::Rect::from_min_size(
399                Pos2::new(x_positions[index], list_rect.min.y + self.padding),
400                Vec2::new(tab_widths[index], inner_height),
401            );
402
403            let is_active = index == self.active_index;
404            let is_hovered = ui.rect_contains_pointer(tab_rect);
405
406            let text_color = if is_active {
407                theme.foreground()
408            } else {
409                theme.muted_foreground()
410            };
411
412            // Create child UI for custom content
413            let mut child_ui = ui.new_child(
414                egui::UiBuilder::new()
415                    .max_rect(tab_rect)
416                    .layout(egui::Layout::left_to_right(egui::Align::Center)),
417            );
418            child_ui.style_mut().visuals.override_text_color = Some(text_color);
419
420            let ctx = ContentContext {
421                color: text_color,
422                font_size: self.font_size,
423                is_active,
424            };
425            render_tab(index, &mut child_ui, &ctx);
426
427            if is_hovered && ui.input(|i| i.pointer.primary_clicked()) {
428                selected = Some(index);
429                self.active_index = index;
430            }
431        }
432
433        let changed = selected.is_some();
434        if let Some(new_index) = selected {
435            self.active_index = new_index;
436        }
437
438        self.persist(ui);
439
440        TabsResponse {
441            response: list_response,
442            selected,
443            changed,
444        }
445    }
446}