egui_charts/ui_kit/tab_bar/
mod.rs1use 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
37pub trait TabLabel: Copy + PartialEq {
41 fn tab_label(&self) -> &str;
43}
44
45pub 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 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 pub fn pills(mut self) -> Self {
70 self.pills = true;
71 self
72 }
73
74 pub(crate) fn single_row(mut self) -> Self {
76 self.single_row = true;
77 self
78 }
79
80 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 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}