egui_material3/tabs.rs
1use crate::get_global_color;
2use eframe::egui::{self, Color32, FontId, Pos2, Rect, Response, Sense, Ui, Vec2, Widget};
3use eframe::egui::epaint::CornerRadius;
4
5/// Material Design tabs component.
6///
7/// Tabs organize content across different screens, data sets, and other interactions.
8/// They allow users to navigate between related groups of content.
9///
10/// # Example
11/// ```rust
12/// # egui::__run_test_ui(|ui| {
13/// let mut selected_tab = 0;
14///
15/// ui.add(MaterialTabs::primary(&mut selected_tab)
16/// .tab("Home")
17/// .tab("Profile")
18/// .tab("Settings"));
19/// # });
20/// ```
21#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
22pub struct MaterialTabs<'a> {
23 /// Reference to the currently selected tab index
24 selected: &'a mut usize,
25 /// List of tab items
26 tabs: Vec<TabItem>,
27 /// Whether the tabs are enabled for interaction
28 enabled: bool,
29 /// Visual variant of the tabs (primary or secondary)
30 variant: TabVariant,
31 /// Optional salt for generating unique IDs
32 id_salt: Option<String>,
33 /// Optional custom height for the tab bar
34 height: Option<f32>,
35}
36
37/// Individual tab item data.
38pub struct TabItem {
39 /// Display label for the tab
40 label: String,
41 /// Optional icon for the tab
42 icon: Option<String>,
43}
44
45/// Visual variants for tabs component.
46#[derive(Clone, Copy, PartialEq)]
47pub enum TabVariant {
48 /// Primary tabs (filled background, more prominent)
49 Primary,
50 /// Secondary tabs (outlined style, less prominent)
51 Secondary,
52}
53
54impl<'a> MaterialTabs<'a> {
55 /// Create a new tabs component.
56 ///
57 /// # Arguments
58 /// * `selected` - Mutable reference to the currently selected tab index
59 /// * `variant` - Visual variant (Primary or Secondary)
60 ///
61 /// # Example
62 /// ```rust
63 /// # egui::__run_test_ui(|ui| {
64 /// let mut tab_index = 0;
65 /// let tabs = MaterialTabs::new(&mut tab_index, TabVariant::Primary);
66 /// # });
67 /// ```
68 pub fn new(selected: &'a mut usize, variant: TabVariant) -> Self {
69 Self {
70 selected,
71 tabs: Vec::new(),
72 enabled: true,
73 variant,
74 id_salt: None,
75 height: None,
76 }
77 }
78
79 /// Create a primary tabs component.
80 ///
81 /// Primary tabs have a filled background and are more prominent.
82 ///
83 /// # Arguments
84 /// * `selected` - Mutable reference to the currently selected tab index
85 ///
86 /// # Example
87 /// ```rust
88 /// # egui::__run_test_ui(|ui| {
89 /// let mut tab_index = 0;
90 /// let tabs = MaterialTabs::primary(&mut tab_index);
91 /// # });
92 /// ```
93 pub fn primary(selected: &'a mut usize) -> Self {
94 Self::new(selected, TabVariant::Primary)
95 }
96
97 /// Create a secondary tabs component.
98 ///
99 /// Secondary tabs have an outlined style and are less prominent than primary tabs.
100 ///
101 /// # Arguments
102 /// * `selected` - Mutable reference to the currently selected tab index
103 ///
104 /// # Example
105 /// ```rust
106 /// # egui::__run_test_ui(|ui| {
107 /// let mut tab_index = 0;
108 /// let tabs = MaterialTabs::secondary(&mut tab_index);
109 /// # });
110 /// ```
111 pub fn secondary(selected: &'a mut usize) -> Self {
112 Self::new(selected, TabVariant::Secondary)
113 }
114
115 /// Add a tab with just a text label.
116 ///
117 /// # Arguments
118 /// * `label` - Text label for the tab
119 ///
120 /// # Example
121 /// ```rust
122 /// # egui::__run_test_ui(|ui| {
123 /// let mut tab_index = 0;
124 /// ui.add(MaterialTabs::primary(&mut tab_index)
125 /// .tab("Home")
126 /// .tab("About"));
127 /// # });
128 /// ```
129 pub fn tab(mut self, label: impl Into<String>) -> Self {
130 self.tabs.push(TabItem {
131 label: label.into(),
132 icon: None,
133 });
134 self
135 }
136
137 /// Add a tab with both an icon and text label.
138 ///
139 /// # Arguments
140 /// * `label` - Text label for the tab
141 /// * `icon` - Icon identifier (e.g., "home", "person", "settings")
142 ///
143 /// # Example
144 /// ```rust
145 /// # egui::__run_test_ui(|ui| {
146 /// let mut tab_index = 0;
147 /// ui.add(MaterialTabs::primary(&mut tab_index)
148 /// .tab_with_icon("Home", "home")
149 /// .tab_with_icon("Profile", "person"));
150 /// # });
151 /// ```
152 pub fn tab_with_icon(mut self, label: impl Into<String>, icon: impl Into<String>) -> Self {
153 self.tabs.push(TabItem {
154 label: label.into(),
155 icon: Some(icon.into()),
156 });
157 self
158 }
159
160 /// Set whether the tabs are enabled for interaction.
161 ///
162 /// # Arguments
163 /// * `enabled` - `true` to enable tabs, `false` to disable
164 ///
165 /// # Example
166 /// ```rust
167 /// # egui::__run_test_ui(|ui| {
168 /// let mut tab_index = 0;
169 /// ui.add(MaterialTabs::primary(&mut tab_index)
170 /// .tab("Home")
171 /// .tab("Profile")
172 /// .tab("Settings")
173 /// .enabled(false));
174 /// # });
175 /// ```
176 pub fn enabled(mut self, enabled: bool) -> Self {
177 self.enabled = enabled;
178 self
179 }
180
181 /// Set an optional salt for generating unique IDs for the tabs.
182 ///
183 /// This is useful if you have multiple instances of tabs and want to avoid ID collisions.
184 ///
185 /// # Arguments
186 /// * `salt` - Salt string to use in ID generation
187 ///
188 /// # Example
189 /// ```rust
190 /// # egui::__run_test_ui(|ui| {
191 /// let mut tab_index = 0;
192 /// ui.add(MaterialTabs::primary(&mut tab_index)
193 /// .tab("Home")
194 /// .tab("Profile")
195 /// .tab("Settings")
196 /// .id_salt("unique_salt"));
197 /// # });
198 /// ```
199 pub fn id_salt(mut self, salt: impl Into<String>) -> Self {
200 self.id_salt = Some(salt.into());
201 self
202 }
203
204 /// Set a custom height for the tab bar.
205 ///
206 /// Default height is 46.0 for text-only tabs, 72.0 for tabs with icons.
207 pub fn height(mut self, height: f32) -> Self {
208 self.height = Some(height);
209 self
210 }
211}
212
213/// M3 tab height constants
214const TAB_HEIGHT_TEXT_ONLY: f32 = 46.0;
215const TAB_HEIGHT_WITH_ICON: f32 = 72.0;
216/// M3 indicator constants
217const PRIMARY_INDICATOR_HEIGHT: f32 = 3.0;
218const SECONDARY_INDICATOR_HEIGHT: f32 = 2.0;
219const INDICATOR_TOP_ROUNDING: f32 = 3.0;
220/// M3 divider
221const DIVIDER_HEIGHT: f32 = 1.0;
222/// M3 label font size
223const LABEL_FONT_SIZE: f32 = 14.0;
224const ICON_FONT_SIZE: f32 = 18.0;
225
226impl<'a> Widget for MaterialTabs<'a> {
227 fn ui(self, ui: &mut Ui) -> Response {
228 let has_icons = self.tabs.iter().any(|t| t.icon.is_some());
229 let tab_height = self
230 .height
231 .unwrap_or(if has_icons { TAB_HEIGHT_WITH_ICON } else { TAB_HEIGHT_TEXT_ONLY });
232 let tab_width = ui.available_width() / self.tabs.len().max(1) as f32;
233
234 let desired_size = Vec2::new(ui.available_width(), tab_height);
235 let (rect, mut response) = ui.allocate_exact_size(desired_size, Sense::hover());
236
237 // Material Design 3 colors
238 let primary_color = get_global_color("primary");
239 let surface_container = get_global_color("surfaceContainer");
240 let surface = get_global_color("surface");
241 let on_surface = get_global_color("onSurface");
242 let on_surface_variant = get_global_color("onSurfaceVariant");
243 let outline_variant = get_global_color("outlineVariant");
244
245 // Draw tab bar background
246 let bg_color = match self.variant {
247 TabVariant::Primary => surface_container,
248 TabVariant::Secondary => surface,
249 };
250 ui.painter().rect_filled(rect, 0.0, bg_color);
251
252 // Draw tabs
253 let mut any_clicked = false;
254 let label_font = FontId::proportional(LABEL_FONT_SIZE);
255 let icon_font = FontId::proportional(ICON_FONT_SIZE);
256
257 for (index, tab) in self.tabs.iter().enumerate() {
258 let tab_rect = Rect::from_min_size(
259 Pos2::new(rect.min.x + index as f32 * tab_width, rect.min.y),
260 Vec2::new(tab_width, tab_height),
261 );
262
263 // Create unique ID for each tab using optional salt
264 let tab_id = if let Some(ref salt) = self.id_salt {
265 egui::Id::new((salt, "tab", index))
266 } else {
267 egui::Id::new(("tab", index))
268 };
269
270 let tab_response = ui.interact(tab_rect, tab_id, Sense::click());
271
272 let is_selected = *self.selected == index;
273 let is_hovered = tab_response.hovered();
274
275 // M3 label colors per variant
276 let text_color = match self.variant {
277 TabVariant::Primary => {
278 if is_selected {
279 primary_color
280 } else {
281 on_surface_variant
282 }
283 }
284 TabVariant::Secondary => {
285 if is_selected {
286 on_surface
287 } else {
288 on_surface_variant
289 }
290 }
291 };
292
293 // Apply disabled opacity
294 let text_color = if self.enabled {
295 text_color
296 } else {
297 text_color.linear_multiply(0.38)
298 };
299
300 // M3 state layer (hover overlay)
301 if is_hovered && self.enabled {
302 let state_layer_color = match self.variant {
303 TabVariant::Primary => primary_color,
304 TabVariant::Secondary => on_surface,
305 };
306 let hover_color = Color32::from_rgba_unmultiplied(
307 state_layer_color.r(),
308 state_layer_color.g(),
309 state_layer_color.b(),
310 20, // ~8% opacity
311 );
312 ui.painter().rect_filled(tab_rect, 0.0, hover_color);
313 }
314
315 // Handle click
316 if tab_response.clicked() && self.enabled {
317 *self.selected = index;
318 any_clicked = true;
319 }
320
321 // Layout and draw tab content
322 if let Some(icon) = &tab.icon {
323 // Icon + text layout: icon above label
324 let icon_y = tab_rect.center().y - 10.0;
325 let label_y = tab_rect.center().y + 12.0;
326
327 // Draw icon as text (emoji/unicode)
328 ui.painter().text(
329 Pos2::new(tab_rect.center().x, icon_y),
330 egui::Align2::CENTER_CENTER,
331 icon,
332 icon_font.clone(),
333 text_color,
334 );
335
336 // Draw label text
337 ui.painter().text(
338 Pos2::new(tab_rect.center().x, label_y),
339 egui::Align2::CENTER_CENTER,
340 &tab.label,
341 label_font.clone(),
342 text_color,
343 );
344 } else {
345 // Text-only layout: centered
346 ui.painter().text(
347 tab_rect.center(),
348 egui::Align2::CENTER_CENTER,
349 &tab.label,
350 label_font.clone(),
351 text_color,
352 );
353 }
354
355 // Draw indicator for selected tab
356 if is_selected && self.enabled {
357 match self.variant {
358 TabVariant::Primary => {
359 // M3: indicator width matches label, top-rounded corners
360 let galley = ui.painter().layout_no_wrap(
361 tab.label.clone(),
362 label_font.clone(),
363 text_color,
364 );
365 let label_width = galley.size().x + 16.0; // add padding
366 let indicator_x =
367 tab_rect.center().x - label_width / 2.0;
368 let indicator_rect = Rect::from_min_size(
369 Pos2::new(indicator_x, tab_rect.max.y - PRIMARY_INDICATOR_HEIGHT),
370 Vec2::new(label_width, PRIMARY_INDICATOR_HEIGHT),
371 );
372 let rounding = CornerRadius {
373 nw: INDICATOR_TOP_ROUNDING as u8,
374 ne: INDICATOR_TOP_ROUNDING as u8,
375 sw: 0,
376 se: 0,
377 };
378 ui.painter()
379 .rect_filled(indicator_rect, rounding, primary_color);
380 }
381 TabVariant::Secondary => {
382 // M3: full tab width underline, primary color
383 let indicator_rect = Rect::from_min_size(
384 Pos2::new(tab_rect.min.x, tab_rect.max.y - SECONDARY_INDICATOR_HEIGHT),
385 Vec2::new(tab_width, SECONDARY_INDICATOR_HEIGHT),
386 );
387 ui.painter()
388 .rect_filled(indicator_rect, 0.0, primary_color);
389 }
390 }
391 }
392 }
393
394 // M3: Draw bottom divider for both variants
395 let divider_rect = Rect::from_min_size(
396 Pos2::new(rect.min.x, rect.max.y - DIVIDER_HEIGHT),
397 Vec2::new(rect.width(), DIVIDER_HEIGHT),
398 );
399 ui.painter().rect_filled(divider_rect, 0.0, outline_variant);
400
401 if any_clicked {
402 response.mark_changed();
403 }
404 response
405 }
406}
407
408/// Convenience function to create primary tabs.
409///
410/// Shorthand for `MaterialTabs::primary()`.
411///
412/// # Arguments
413/// * `selected` - Mutable reference to the currently selected tab index
414///
415/// # Example
416/// ```rust
417/// # egui::__run_test_ui(|ui| {
418/// let mut tab_index = 0;
419/// ui.add(tabs_primary(&mut tab_index)
420/// .tab("Tab 1")
421/// .tab("Tab 2"));
422/// # });
423/// ```
424pub fn tabs_primary<'a>(selected: &'a mut usize) -> MaterialTabs<'a> {
425 MaterialTabs::primary(selected)
426}
427
428/// Convenience function to create secondary tabs.
429///
430/// Shorthand for `MaterialTabs::secondary()`.
431///
432/// # Arguments
433/// * `selected` - Mutable reference to the currently selected tab index
434///
435/// # Example
436/// ```rust
437/// # egui::__run_test_ui(|ui| {
438/// let mut tab_index = 0;
439/// ui.add(tabs_secondary(&mut tab_index)
440/// .tab("Tab 1")
441/// .tab("Tab 2"));
442/// # });
443/// ```
444pub fn tabs_secondary<'a>(selected: &'a mut usize) -> MaterialTabs<'a> {
445 MaterialTabs::secondary(selected)
446}