Skip to main content

egui_charts/ui_kit/tab_bar/
mod.rs

1//! Tab bar component with underline indicator.
2//!
3//! Provides a horizontal tab bar that renders clickable text labels with
4//! an accent-colored underline on the active tab. Used across all tabbed dialogs.
5//!
6//! # Example
7//! ```
8//! use egui_charts::ui_kit::tab_bar::{TabBar, TabLabel};
9//!
10//! #[derive(Clone, Copy, PartialEq)]
11//! enum MyTab {
12//!     Chart,
13//!     Orders,
14//! }
15//!
16//! impl TabLabel for MyTab {
17//!     fn tab_label(&self) -> &str {
18//!         match self {
19//!             MyTab::Chart => "Chart",
20//!             MyTab::Orders => "Orders",
21//!         }
22//!     }
23//! }
24//!
25//! # egui::__run_test_ui(|ui| {
26//! # let mut active_tab = MyTab::Chart;
27//! TabBar::new(&[MyTab::Chart, MyTab::Orders], &mut active_tab).show(ui);
28//! # });
29//! ```
30
31use crate::localization as l10n;
32use crate::theme::ThemeExt;
33use crate::tokens::DESIGN_TOKENS;
34use crate::ui_kit::buttons::ButtonSize;
35use egui::{Role, Sense, Ui, WidgetInfo, vec2};
36
37/// Trait for tab enum types to provide their display label.
38///
39/// Implement this on your tab enum so `TabBar` can render it.
40pub trait TabLabel: Copy + PartialEq {
41    /// Label displayed for this tab.
42    fn tab_label(&self) -> &str;
43}
44
45/// Horizontal tab bar with underline indicator on the active tab.
46///
47/// Renders clickable text labels 16 px apart over a full-width rule, starting at the content
48/// edge. The active tab's accent underline sits on the rule, and content starts 8 px below it.
49pub struct TabBar<'a, T: TabLabel> {
50    tabs: &'a [T],
51    active: &'a mut T,
52    pills: bool,
53    single_row: bool,
54}
55
56impl<'a, T: TabLabel> TabBar<'a, T> {
57    /// Bind the offered tabs to the current selection.
58    pub fn new(tabs: &'a [T], active: &'a mut T) -> Self {
59        Self {
60            tabs,
61            active,
62            pills: false,
63            single_row: false,
64        }
65    }
66
67    /// Render rounded chips instead of an underline, wrapping onto further rows when they do not
68    /// fit the width.
69    pub fn pills(mut self) -> Self {
70        self.pills = true;
71        self
72    }
73
74    /// Keep pills on one horizontally scrollable row.
75    pub(crate) fn single_row(mut self) -> Self {
76        self.single_row = true;
77        self
78    }
79
80    /// Show the tab bar. Returns `true` if the active tab changed.
81    pub fn show(self, ui: &mut Ui) -> bool {
82        let theme = ui.active_theme();
83        let mut changed = false;
84        let rule_slot = ui.painter().add(egui::Shape::Noop);
85
86        let add_tabs = |ui: &mut Ui| {
87            ui.spacing_mut().item_spacing.x = if self.pills { 4.0 } else { theme.spacing.xxl };
88            for tab in self.tabs {
89                let is_active = *tab == *self.active;
90                let galley = ui.painter().layout_no_wrap(
91                    l10n::text(ui.ctx(), tab.tab_label()),
92                    egui::FontId::proportional(if self.pills { 14.0 } else { theme.text.label }),
93                    egui::Color32::PLACEHOLDER,
94                );
95                let (rect, response) = ui.allocate_exact_size(
96                    vec2(
97                        galley.size().x + if self.pills { 24.0 } else { 0.0 },
98                        ButtonSize::MD.pixels(),
99                    ),
100                    Sense::click(),
101                );
102                response
103                    .widget_info(|| WidgetInfo::labeled(Role::Tab, ui.is_enabled(), galley.text()));
104                ui.ctx()
105                    .accesskit_node_builder(response.id, |node| node.set_selected(is_active));
106
107                let visuals = ui.visuals();
108                if self.pills {
109                    let fill = if is_active {
110                        theme.ui.text
111                    } else {
112                        theme
113                            .ui
114                            .panel_bg
115                            .lerp_to_gamma(theme.ui.panel_bg_secondary, 0.6)
116                    };
117                    ui.painter().rect_filled(rect, rect.height() / 2.0, fill);
118                }
119                let text_color = if self.pills && is_active {
120                    theme.ui.panel_bg
121                } else if is_active {
122                    visuals.strong_text_color()
123                } else if response.hovered() {
124                    visuals.widgets.hovered.fg_stroke.color
125                } else {
126                    visuals.text_color()
127                };
128                let text_pos = rect.center() - galley.size() / 2.0;
129                ui.painter().galley(text_pos, galley, text_color);
130
131                if is_active && !self.pills {
132                    let thick = DESIGN_TOKENS.stroke.thick;
133                    ui.painter().hline(
134                        rect.x_range(),
135                        rect.bottom() - thick / 2.0,
136                        egui::Stroke::new(thick, theme.ui.accent),
137                    );
138                }
139                crate::styles::focus::draw_focus_ring(
140                    ui,
141                    &response,
142                    if self.pills { rect.height() / 2.0 } else { 0.0 },
143                );
144
145                if response.clicked() {
146                    *self.active = *tab;
147                    changed = true;
148                }
149            }
150        };
151        let row = if self.pills && self.single_row {
152            egui::ScrollArea::horizontal()
153                .id_salt("single_row_tabs")
154                .auto_shrink([false, true])
155                .show(ui, |ui| ui.horizontal(add_tabs))
156                .inner
157        } else if self.pills {
158            ui.horizontal_wrapped(add_tabs)
159        } else {
160            ui.horizontal(add_tabs)
161        };
162
163        // The rule spans the parent's width, under the active underline.
164        let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
165        let rule = egui::Shape::hline(
166            ui.max_rect().x_range(),
167            row.response.rect.bottom() - stroke.width / 2.0,
168            stroke,
169        );
170        if !self.pills {
171            ui.painter().set(rule_slot, rule);
172        }
173        ui.add_space(theme.spacing.lg - ui.spacing().item_spacing.y);
174
175        changed
176    }
177}