Skip to main content

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}