Skip to main content

egui_components/
tabs.rs

1//! `Tabs` widget — a horizontal tab bar driving a `&mut usize` selection.
2//!
3//! Three visual variants from gpui-component (`underline`, `pill`, `segmented`).
4//! Callers render their own per-tab content based on the resulting selection.
5
6use crate::common::Size;
7use egui::{
8    pos2, vec2, Color32, FontId, Rect, Response, Sense, Stroke, Ui, Vec2, Widget, WidgetText,
9};
10use egui_components_theme::{mix, Theme};
11
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
13pub enum TabVariant {
14    #[default]
15    Underline,
16    Pill,
17    Segmented,
18}
19
20pub struct Tabs<'a> {
21    selected: &'a mut usize,
22    tabs: Vec<WidgetText>,
23    disabled: Vec<bool>,
24    variant: TabVariant,
25    size: Size,
26}
27
28impl<'a> Tabs<'a> {
29    pub fn new(selected: &'a mut usize) -> Self {
30        Self {
31            selected,
32            tabs: Vec::new(),
33            disabled: Vec::new(),
34            variant: TabVariant::default(),
35            size: Size::Medium,
36        }
37    }
38
39    /// Append a single tab.
40    pub fn tab(mut self, label: impl Into<WidgetText>) -> Self {
41        self.tabs.push(label.into());
42        self.disabled.push(false);
43        self
44    }
45
46    /// Append a disabled tab — still rendered, never selectable via click.
47    pub fn disabled_tab(mut self, label: impl Into<WidgetText>) -> Self {
48        self.tabs.push(label.into());
49        self.disabled.push(true);
50        self
51    }
52
53    /// Append several tabs at once.
54    pub fn tabs<I, T>(mut self, items: I) -> Self
55    where
56        I: IntoIterator<Item = T>,
57        T: Into<WidgetText>,
58    {
59        for it in items {
60            self.tabs.push(it.into());
61            self.disabled.push(false);
62        }
63        self
64    }
65
66    pub fn variant(mut self, v: TabVariant) -> Self {
67        self.variant = v;
68        self
69    }
70    pub fn underline(self) -> Self {
71        self.variant(TabVariant::Underline)
72    }
73    pub fn pill(self) -> Self {
74        self.variant(TabVariant::Pill)
75    }
76    pub fn segmented(self) -> Self {
77        self.variant(TabVariant::Segmented)
78    }
79
80    pub fn size(mut self, s: Size) -> Self {
81        self.size = s;
82        self
83    }
84    pub fn small(self) -> Self {
85        self.size(Size::Small)
86    }
87    pub fn large(self) -> Self {
88        self.size(Size::Large)
89    }
90
91    pub fn show(self, ui: &mut Ui) -> Response {
92        let theme = Theme::get(ui.ctx());
93        let m = theme.metrics;
94        let c = &theme.colors;
95        let font = FontId::proportional(self.size.font_size(&m));
96        let height = self.size.button_height(&m);
97        let pad_x = match self.size {
98            Size::Small => 10.0,
99            Size::Medium => 14.0,
100            Size::Large => 18.0,
101        };
102        let gap = match self.variant {
103            TabVariant::Underline => 4.0,
104            TabVariant::Pill => 4.0,
105            TabVariant::Segmented => 2.0,
106        };
107
108        if self.tabs.is_empty() {
109            return ui.allocate_response(Vec2::ZERO, Sense::hover());
110        }
111        if *self.selected >= self.tabs.len() {
112            *self.selected = 0;
113        }
114
115        let galleys: Vec<_> = self
116            .tabs
117            .iter()
118            .map(|t| {
119                t.clone().into_galley(
120                    ui,
121                    Some(egui::TextWrapMode::Extend),
122                    f32::INFINITY,
123                    font.clone(),
124                )
125            })
126            .collect();
127
128        let widths: Vec<f32> = galleys
129            .iter()
130            .map(|g| g.size().x + pad_x * 2.0)
131            .collect();
132
133        let outer_pad = match self.variant {
134            TabVariant::Segmented => 3.0,
135            _ => 0.0,
136        };
137        let row_h = height + outer_pad * 2.0;
138        let row_gap_y = 4.0;
139
140        // Greedy line-fit: pack tabs into rows constrained by available width.
141        // The first tab on a row always fits, even if its own width already
142        // exceeds the available area (it just overflows that single row).
143        //
144        // `ui.available_width()` is unreliable inside an `egui::ScrollArea`
145        // that has previously expanded to fit wider content — after the user
146        // shrinks the window, the layout's reported available width can
147        // remain stuck at the prior, wider content size. The visible viewport
148        // (`clip_rect`) is always trimmed to the actual window, so we cap
149        // against it: this is what's actually paintable without scroll-
150        // clipping the tabs.
151        let visible_w =
152            (ui.clip_rect().right() - ui.cursor().min.x).max(0.0);
153        let available_w = ui.available_width().min(visible_w);
154        let mut rows: Vec<std::ops::Range<usize>> = Vec::new();
155        {
156            let mut start = 0usize;
157            let mut row_w = outer_pad * 2.0 + widths[0];
158            for i in 1..widths.len() {
159                let next_w = row_w + gap + widths[i];
160                if next_w > available_w {
161                    rows.push(start..i);
162                    start = i;
163                    row_w = outer_pad * 2.0 + widths[i];
164                } else {
165                    row_w = next_w;
166                }
167            }
168            rows.push(start..widths.len());
169        }
170
171        let row_count = rows.len();
172        let total_h = row_h * row_count as f32
173            + row_gap_y * row_count.saturating_sub(1) as f32;
174        let (rect, response) =
175            ui.allocate_exact_size(vec2(available_w, total_h), Sense::hover());
176
177        if !ui.is_rect_visible(rect) {
178            return response;
179        }
180
181        let mut clicked_idx: Option<usize> = None;
182
183        for (row_idx, row) in rows.iter().enumerate() {
184            let row_top = rect.top() + (row_h + row_gap_y) * row_idx as f32;
185
186            // Width actually consumed by this row's tabs (used to size the
187            // segmented bar / underline that sits behind them).
188            let mut row_tab_total = 0.0;
189            for (j, i) in row.clone().enumerate() {
190                if j > 0 {
191                    row_tab_total += gap;
192                }
193                row_tab_total += widths[i];
194            }
195            let row_total_w = row_tab_total + outer_pad * 2.0;
196
197            let row_rect = Rect::from_min_size(
198                pos2(rect.left(), row_top),
199                vec2(row_total_w, row_h),
200            );
201
202            if matches!(self.variant, TabVariant::Segmented) {
203                ui.painter()
204                    .rect_filled(row_rect, theme.corner(), c.muted_background);
205            }
206            if matches!(self.variant, TabVariant::Underline) {
207                ui.painter().line_segment(
208                    [
209                        pos2(row_rect.left(), row_rect.bottom() - 1.0),
210                        pos2(row_rect.right(), row_rect.bottom() - 1.0),
211                    ],
212                    Stroke::new(1.0, c.border),
213                );
214            }
215
216            let mut x = rect.left() + outer_pad;
217            let tab_y = row_top + outer_pad;
218            for i in row.clone() {
219                let w = widths[i];
220                let tab_rect = Rect::from_min_size(pos2(x, tab_y), vec2(w, height));
221                let disabled = self.disabled[i];
222                let id = response.id.with(("tab", i));
223                let sense = if disabled { Sense::hover() } else { Sense::click() };
224                let tab_resp = ui.interact(tab_rect, id, sense);
225                let is_selected = *self.selected == i;
226
227                paint_tab(
228                    ui,
229                    tab_rect,
230                    &tab_resp,
231                    &theme,
232                    self.variant,
233                    is_selected,
234                    disabled,
235                    &galleys[i],
236                );
237
238                if tab_resp.clicked() && !disabled {
239                    clicked_idx = Some(i);
240                }
241                if !disabled && tab_resp.hovered() {
242                    ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
243                }
244
245                x += w + gap;
246            }
247        }
248
249        if let Some(i) = clicked_idx {
250            *self.selected = i;
251        }
252
253        response
254    }
255}
256
257impl<'a> Widget for Tabs<'a> {
258    fn ui(self, ui: &mut Ui) -> Response {
259        self.show(ui)
260    }
261}
262
263fn paint_tab(
264    ui: &mut Ui,
265    rect: Rect,
266    response: &Response,
267    theme: &Theme,
268    variant: TabVariant,
269    selected: bool,
270    disabled: bool,
271    galley: &std::sync::Arc<egui::Galley>,
272) {
273    let c = &theme.colors;
274    let painter = ui.painter();
275
276    let text_color = if disabled {
277        mix(c.muted_foreground, Color32::TRANSPARENT, 0.3)
278    } else if selected {
279        match variant {
280            TabVariant::Pill => c.primary_foreground,
281            TabVariant::Underline => c.foreground,
282            TabVariant::Segmented => c.foreground,
283        }
284    } else {
285        c.muted_foreground
286    };
287
288    match variant {
289        TabVariant::Underline => {
290            // Background: subtle hover when not selected
291            if !disabled && !selected && response.hovered() {
292                painter.rect_filled(
293                    rect.shrink(2.0),
294                    theme.corner_sm(),
295                    c.accent_background,
296                );
297            }
298            // Selected: 2px underline at the bottom
299            if selected {
300                let y = rect.bottom() - 1.0;
301                let pad = 4.0;
302                painter.line_segment(
303                    [
304                        pos2(rect.left() + pad, y),
305                        pos2(rect.right() - pad, y),
306                    ],
307                    Stroke::new(2.0, c.primary_background),
308                );
309            }
310        }
311        TabVariant::Pill => {
312            let radius = egui::CornerRadius::same((rect.height() * 0.5) as u8);
313            let bg = if selected {
314                if disabled {
315                    mix(c.primary_background, Color32::TRANSPARENT, 0.5)
316                } else if response.is_pointer_button_down_on() {
317                    c.primary_active_background
318                } else if response.hovered() {
319                    c.primary_hover_background
320                } else {
321                    c.primary_background
322                }
323            } else if !disabled && response.hovered() {
324                c.secondary_hover_background
325            } else {
326                Color32::TRANSPARENT
327            };
328            if bg != Color32::TRANSPARENT {
329                painter.rect_filled(rect, radius, bg);
330            }
331        }
332        TabVariant::Segmented => {
333            if selected {
334                painter.rect_filled(rect, theme.corner_sm(), c.popover_background);
335                painter.rect_stroke(
336                    rect,
337                    theme.corner_sm(),
338                    Stroke::new(1.0, c.border),
339                    egui::StrokeKind::Inside,
340                );
341            } else if !disabled && response.hovered() {
342                painter.rect_filled(rect, theme.corner_sm(), c.accent_background);
343            }
344        }
345    }
346
347    if response.has_focus() {
348        painter.rect_stroke(
349            rect.expand(1.0),
350            theme.corner(),
351            theme.focus_ring(),
352            egui::StrokeKind::Outside,
353        );
354    }
355
356    let text_pos = rect.center() - galley.size() * 0.5;
357    painter.galley_with_override_text_color(text_pos, galley.clone(), text_color);
358}