Skip to main content

elegance/
browser_tabs.rs

1//! Browser-style closable tabs with a dirty indicator.
2//!
3//! [`BrowserTabs`] is an owned-state widget for an editor- or browser-style
4//! strip of tabs. The caller stores the widget on their app struct (so the
5//! tab list and selection survive across frames), pushes tabs in via
6//! [`BrowserTabs::add_tab`], and reacts to user actions by draining
7//! [`BrowserTabs::take_events`] each frame.
8//!
9//! Each [`BrowserTab`] carries a stable string id, a label, an optional icon
10//! glyph, and a `dirty` flag. The dirty flag paints a small sky dot to
11//! signal unsaved changes. The active tab fills with the theme's card
12//! colour so it visually merges with the panel below.
13//!
14//! # Example
15//!
16//! ```no_run
17//! use elegance::{BrowserTab, BrowserTabs, BrowserTabsEvent};
18//!
19//! struct App { tabs: BrowserTabs, untitled: u32 }
20//!
21//! impl Default for App {
22//!     fn default() -> Self {
23//!         let tabs = BrowserTabs::new("editor")
24//!             .with_tab(BrowserTab::new("readme", "README.md"))
25//!             .with_tab(BrowserTab::new("theme", "theme.rs").dirty(true))
26//!             .with_tab(BrowserTab::new("button", "widgets/button.rs"));
27//!         Self { tabs, untitled: 0 }
28//!     }
29//! }
30//!
31//! # impl App {
32//! fn ui(&mut self, ui: &mut egui::Ui) {
33//!     self.tabs.show(ui);
34//!     for ev in self.tabs.take_events() {
35//!         if let BrowserTabsEvent::NewRequested = ev {
36//!             self.untitled += 1;
37//!             let id = format!("untitled-{}", self.untitled);
38//!             let label = format!("Untitled-{}", self.untitled);
39//!             self.tabs.add_tab(BrowserTab::new(id, label));
40//!         }
41//!     }
42//! }
43//! # }
44//! ```
45
46use std::hash::Hash;
47
48use egui::{
49    pos2, vec2, Color32, CornerRadius, FontId, FontSelection, Id, Rect, Response, RichText, Sense,
50    Stroke, TextWrapMode, Ui, Vec2, WidgetInfo, WidgetText, WidgetType,
51};
52
53use crate::theme::{with_alpha, Theme};
54
55/// A single tab cell rendered by [`BrowserTabs`].
56#[derive(Clone, Debug)]
57pub struct BrowserTab {
58    /// Stable identifier used to track selection and emit events.
59    pub id: String,
60    /// Display label. Truncated with an ellipsis if it doesn't fit.
61    pub label: String,
62    /// Optional leading icon glyph. Pass any string; typically a single
63    /// glyph from a font like Lucide via [`crate::glyphs`].
64    pub icon: Option<String>,
65    /// Show a small sky dot to signal unsaved changes.
66    pub dirty: bool,
67}
68
69impl BrowserTab {
70    /// Create a new tab with a stable id and a display label.
71    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
72        Self {
73            id: id.into(),
74            label: label.into(),
75            icon: None,
76            dirty: false,
77        }
78    }
79
80    /// Set a leading icon glyph (e.g. one from [`crate::glyphs`]).
81    #[inline]
82    pub fn icon(mut self, icon: impl Into<String>) -> Self {
83        self.icon = Some(icon.into());
84        self
85    }
86
87    /// Mark or clear the unsaved-changes indicator.
88    #[inline]
89    pub fn dirty(mut self, dirty: bool) -> Self {
90        self.dirty = dirty;
91        self
92    }
93}
94
95/// Events emitted by [`BrowserTabs`] for a frame.
96///
97/// Drain via [`BrowserTabs::take_events`]; the queue is cleared each call.
98#[derive(Clone, Debug, PartialEq, Eq)]
99pub enum BrowserTabsEvent {
100    /// The user changed the active tab. Carries the id of the newly-active tab.
101    Activated(String),
102    /// The user clicked the close (×) button. The widget has already
103    /// removed the tab from its list when this fires; the caller can free
104    /// any associated state.
105    Closed(String),
106    /// The user clicked the trailing "+" button. The widget does NOT add a
107    /// tab automatically; the caller decides label / icon / id and calls
108    /// [`BrowserTabs::add_tab`].
109    NewRequested,
110}
111
112const STRIP_PAD_X: f32 = 8.0;
113const STRIP_PAD_Y: f32 = 8.0;
114const TAB_PAD_X: f32 = 10.0;
115const TAB_PAD_Y: f32 = 7.0;
116const TAB_GAP: f32 = 2.0;
117const TAB_RADIUS: f32 = 7.0;
118const ICON_SIZE: f32 = 12.0;
119const INNER_GAP: f32 = 8.0;
120const DIRTY_SIZE: f32 = 7.0;
121const DIRTY_TO_CLOSE_GAP: f32 = 5.0;
122const CLOSE_SIZE: f32 = 16.0;
123const CLOSE_INNER: f32 = 9.0;
124const CLOSE_RADIUS: u8 = 4;
125const NEW_BTN_SIZE: f32 = 28.0;
126const NEW_BTN_INNER: f32 = 14.0;
127const NEW_BTN_RADIUS: u8 = 5;
128const NEW_BTN_GAP: f32 = 4.0;
129
130const DEFAULT_MIN_TAB_WIDTH: f32 = 120.0;
131const DEFAULT_MAX_TAB_WIDTH: f32 = 220.0;
132
133/// A horizontal strip of browser-style closable tabs.
134///
135/// See the module-level docs for an example.
136#[must_use = "Call `.show(ui)` to render the widget."]
137pub struct BrowserTabs {
138    id_salt: Id,
139    tabs: Vec<BrowserTab>,
140    selected: Option<String>,
141    show_new_button: bool,
142    min_tab_width: f32,
143    max_tab_width: f32,
144    events: Vec<BrowserTabsEvent>,
145}
146
147impl Default for BrowserTabs {
148    fn default() -> Self {
149        Self::new("elegance::browser_tabs")
150    }
151}
152
153impl std::fmt::Debug for BrowserTabs {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        f.debug_struct("BrowserTabs")
156            .field("id_salt", &self.id_salt)
157            .field("tabs", &self.tabs.len())
158            .field("selected", &self.selected)
159            .field("show_new_button", &self.show_new_button)
160            .field("min_tab_width", &self.min_tab_width)
161            .field("max_tab_width", &self.max_tab_width)
162            .field("events", &self.events.len())
163            .finish()
164    }
165}
166
167impl BrowserTabs {
168    /// Create an empty strip. `id_salt` scopes the widget's interaction
169    /// state in egui memory; two `BrowserTabs` strips on the same page
170    /// need distinct salts.
171    pub fn new(id_salt: impl Hash) -> Self {
172        Self {
173            id_salt: Id::new(("elegance::browser_tabs", id_salt)),
174            tabs: Vec::new(),
175            selected: None,
176            show_new_button: true,
177            min_tab_width: DEFAULT_MIN_TAB_WIDTH,
178            max_tab_width: DEFAULT_MAX_TAB_WIDTH,
179            events: Vec::new(),
180        }
181    }
182
183    /// Push a tab at construction time (builder form).
184    #[inline]
185    pub fn with_tab(mut self, tab: BrowserTab) -> Self {
186        self.add_tab(tab);
187        self
188    }
189
190    /// Whether to show the trailing "+" button. Default: `true`.
191    #[inline]
192    pub fn show_new_button(mut self, show: bool) -> Self {
193        self.show_new_button = show;
194        self
195    }
196
197    /// Minimum width for a single tab in points. Default: `120.0`.
198    #[inline]
199    pub fn min_tab_width(mut self, w: f32) -> Self {
200        self.min_tab_width = w.max(60.0);
201        if self.max_tab_width < self.min_tab_width {
202            self.max_tab_width = self.min_tab_width;
203        }
204        self
205    }
206
207    /// Maximum width for a single tab in points. Default: `220.0`.
208    #[inline]
209    pub fn max_tab_width(mut self, w: f32) -> Self {
210        self.max_tab_width = w.max(self.min_tab_width);
211        self
212    }
213
214    /// Append a tab. If no tab is currently selected, the new tab becomes
215    /// the active one. Subsequent calls don't change the active tab unless
216    /// the caller invokes [`Self::set_selected`].
217    pub fn add_tab(&mut self, tab: BrowserTab) {
218        if self.selected.is_none() {
219            self.selected = Some(tab.id.clone());
220        }
221        self.tabs.push(tab);
222    }
223
224    /// Remove a tab by id. If the removed tab was active, selection moves
225    /// to the next tab (or the previous one if it was the last). Returns
226    /// `true` if a tab was actually removed.
227    pub fn remove_tab(&mut self, id: &str) -> bool {
228        let Some(pos) = self.tabs.iter().position(|t| t.id == id) else {
229            return false;
230        };
231        let was_selected = self.selected.as_deref() == Some(id);
232        self.tabs.remove(pos);
233        if was_selected {
234            self.selected = self
235                .tabs
236                .get(pos)
237                .or_else(|| self.tabs.get(pos.saturating_sub(1)))
238                .map(|t| t.id.clone());
239        }
240        true
241    }
242
243    /// Currently active tab id, if any.
244    #[inline]
245    pub fn selected(&self) -> Option<&str> {
246        self.selected.as_deref()
247    }
248
249    /// Set the active tab. No-op if no tab with that id exists.
250    pub fn set_selected(&mut self, id: impl Into<String>) {
251        let id = id.into();
252        if self.tabs.iter().any(|t| t.id == id) {
253            self.selected = Some(id);
254        }
255    }
256
257    /// Borrow the underlying tab list.
258    #[inline]
259    pub fn tabs(&self) -> &[BrowserTab] {
260        &self.tabs
261    }
262
263    /// Find a tab by id.
264    pub fn tab(&self, id: &str) -> Option<&BrowserTab> {
265        self.tabs.iter().find(|t| t.id == id)
266    }
267
268    /// Find a tab by id (mutably) so the caller can flip `dirty` or rename it.
269    pub fn tab_mut(&mut self, id: &str) -> Option<&mut BrowserTab> {
270        self.tabs.iter_mut().find(|t| t.id == id)
271    }
272
273    /// Drain events queued since the last call. Returns events in the order
274    /// they were emitted.
275    pub fn take_events(&mut self) -> Vec<BrowserTabsEvent> {
276        std::mem::take(&mut self.events)
277    }
278
279    /// Render the strip. Call once per frame.
280    pub fn show(&mut self, ui: &mut Ui) -> Response {
281        let theme = Theme::current(ui.ctx());
282        let p = &theme.palette;
283        let t = &theme.typography;
284
285        let label_size = t.small + 0.5;
286        let tab_height = TAB_PAD_Y * 2.0 + label_size.max(ICON_SIZE);
287        let strip_height = STRIP_PAD_Y + tab_height;
288
289        let avail_w = ui.available_width();
290        let (strip_rect, response) =
291            ui.allocate_exact_size(Vec2::new(avail_w, strip_height), Sense::hover());
292
293        if !ui.is_rect_visible(strip_rect) {
294            response.widget_info(|| WidgetInfo::labeled(WidgetType::Other, true, "browser tabs"));
295            return response;
296        }
297
298        let strip_bg = p.input_bg;
299        ui.painter()
300            .rect_filled(strip_rect, CornerRadius::ZERO, strip_bg);
301
302        let active_id = self.selected.clone();
303        let mut active_rect: Option<Rect> = None;
304        let mut activate_target: Option<String> = None;
305        let mut close_target: Option<String> = None;
306        let mut new_clicked = false;
307
308        let tabs_top = strip_rect.min.y + STRIP_PAD_Y;
309        let mut x = strip_rect.min.x + STRIP_PAD_X;
310
311        for tab in self.tabs.iter() {
312            let icon_w = if tab.icon.is_some() {
313                ICON_SIZE + INNER_GAP
314            } else {
315                0.0
316            };
317            let dirty_block_w = if tab.dirty {
318                INNER_GAP + DIRTY_SIZE + DIRTY_TO_CLOSE_GAP
319            } else {
320                INNER_GAP
321            };
322            let close_w = CLOSE_SIZE;
323            let max_label_w =
324                (self.max_tab_width - 2.0 * TAB_PAD_X - icon_w - dirty_block_w - close_w).max(0.0);
325
326            let label_galley = WidgetText::from(
327                RichText::new(&tab.label)
328                    .size(label_size)
329                    .color(Color32::PLACEHOLDER),
330            )
331            .into_galley(
332                ui,
333                Some(TextWrapMode::Truncate),
334                max_label_w,
335                FontSelection::FontId(FontId::proportional(label_size)),
336            );
337            let label_w = label_galley.size().x.min(max_label_w);
338
339            let mut tab_w = 2.0 * TAB_PAD_X + icon_w + label_w + dirty_block_w + close_w;
340            tab_w = tab_w.clamp(self.min_tab_width, self.max_tab_width);
341            let tab_rect = Rect::from_min_size(pos2(x, tabs_top), vec2(tab_w, tab_height));
342
343            let tab_id = self.id_salt.with(("tab", tab.id.as_str()));
344            let resp = ui.interact(tab_rect, tab_id, Sense::click());
345
346            let close_center = pos2(
347                tab_rect.max.x - TAB_PAD_X - CLOSE_SIZE * 0.5,
348                tab_rect.center().y,
349            );
350            let close_rect = Rect::from_center_size(close_center, Vec2::splat(CLOSE_SIZE));
351            let close_id = self.id_salt.with(("close", tab.id.as_str()));
352            let close_resp = ui.interact(close_rect, close_id, Sense::click());
353
354            let is_active = active_id.as_deref() == Some(tab.id.as_str());
355            let any_hover = resp.hovered() || close_resp.hovered();
356
357            let radius = CornerRadius {
358                nw: TAB_RADIUS as u8,
359                ne: TAB_RADIUS as u8,
360                sw: 0,
361                se: 0,
362            };
363            let painter = ui.painter();
364
365            let (fill, label_color, icon_color) = if is_active {
366                (p.card, p.text, p.sky)
367            } else if any_hover {
368                (p.depth_tint(strip_bg, 0.06), p.text, p.text_muted)
369            } else {
370                (p.depth_tint(strip_bg, 0.02), p.text_muted, p.text_faint)
371            };
372            painter.rect_filled(tab_rect, radius, fill);
373
374            // Active tab gets a 1px top edge in the border colour plus side
375            // highlights so it reads as the lifted "merged with the panel
376            // below" cell. Inactive tabs get a suppressed outline (alpha
377            // ~110/255) so each cell stays distinct without competing.
378            let (top_color, side_color) = if is_active {
379                (p.border, p.depth_tint(p.card, 0.04))
380            } else {
381                let outline = with_alpha(p.border, 110);
382                (outline, outline)
383            };
384            painter.line_segment(
385                [
386                    pos2(tab_rect.min.x + TAB_RADIUS, tab_rect.min.y + 0.5),
387                    pos2(tab_rect.max.x - TAB_RADIUS, tab_rect.min.y + 0.5),
388                ],
389                Stroke::new(1.0, top_color),
390            );
391            painter.line_segment(
392                [
393                    pos2(tab_rect.min.x + 0.5, tab_rect.min.y + TAB_RADIUS),
394                    pos2(tab_rect.min.x + 0.5, tab_rect.max.y),
395                ],
396                Stroke::new(1.0, side_color),
397            );
398            painter.line_segment(
399                [
400                    pos2(tab_rect.max.x - 0.5, tab_rect.min.y + TAB_RADIUS),
401                    pos2(tab_rect.max.x - 0.5, tab_rect.max.y),
402                ],
403                Stroke::new(1.0, side_color),
404            );
405
406            let mut cursor_x = tab_rect.min.x + TAB_PAD_X;
407            let cy = tab_rect.center().y;
408
409            if let Some(icon) = &tab.icon {
410                let icon_galley = WidgetText::from(
411                    RichText::new(icon)
412                        .size(ICON_SIZE)
413                        .color(Color32::PLACEHOLDER),
414                )
415                .into_galley(
416                    ui,
417                    Some(TextWrapMode::Extend),
418                    f32::INFINITY,
419                    FontSelection::FontId(FontId::proportional(ICON_SIZE)),
420                );
421                let painter = ui.painter();
422                painter.galley(
423                    pos2(cursor_x, cy - icon_galley.size().y * 0.5),
424                    icon_galley,
425                    icon_color,
426                );
427                cursor_x += ICON_SIZE + INNER_GAP;
428            }
429
430            let painter = ui.painter();
431            let label_pos = pos2(cursor_x, cy - label_galley.size().y * 0.5);
432            painter.galley(label_pos, label_galley, label_color);
433
434            if tab.dirty {
435                let dot_x = close_rect.min.x - DIRTY_TO_CLOSE_GAP - DIRTY_SIZE * 0.5;
436                painter.circle_filled(pos2(dot_x, cy), DIRTY_SIZE * 0.5, p.sky);
437            }
438
439            let close_visible = is_active || any_hover;
440            if close_visible {
441                if close_resp.hovered() {
442                    let close_bg = p.depth_tint(if is_active { p.card } else { strip_bg }, 0.10);
443                    painter.rect_filled(close_rect, CornerRadius::same(CLOSE_RADIUS), close_bg);
444                }
445                let cross_color = if close_resp.hovered() {
446                    p.text
447                } else if is_active {
448                    p.text_muted
449                } else {
450                    p.text_faint
451                };
452                let half = CLOSE_INNER * 0.5;
453                let stroke = Stroke::new(1.5, cross_color);
454                painter.line_segment(
455                    [
456                        pos2(close_center.x - half, close_center.y - half),
457                        pos2(close_center.x + half, close_center.y + half),
458                    ],
459                    stroke,
460                );
461                painter.line_segment(
462                    [
463                        pos2(close_center.x + half, close_center.y - half),
464                        pos2(close_center.x - half, close_center.y + half),
465                    ],
466                    stroke,
467                );
468            }
469
470            if is_active {
471                active_rect = Some(tab_rect);
472            }
473
474            let info_active = is_active;
475            let info_label = tab.label.clone();
476            resp.widget_info(move || {
477                WidgetInfo::selected(WidgetType::Button, true, info_active, &info_label)
478            });
479            let close_label = format!("Close {}", tab.label);
480            close_resp
481                .widget_info(move || WidgetInfo::labeled(WidgetType::Button, true, &close_label));
482
483            if close_resp.clicked() {
484                close_target = Some(tab.id.clone());
485            } else if resp.clicked() {
486                activate_target = Some(tab.id.clone());
487            }
488
489            x += tab_w + TAB_GAP;
490        }
491
492        if self.show_new_button {
493            x += NEW_BTN_GAP;
494            let btn_y = tabs_top + tab_height - NEW_BTN_SIZE - 2.0;
495            let new_rect = Rect::from_min_size(pos2(x, btn_y), Vec2::splat(NEW_BTN_SIZE));
496            let new_id = self.id_salt.with("new_tab");
497            let new_resp = ui.interact(new_rect, new_id, Sense::click());
498
499            let painter = ui.painter();
500            let hovered = new_resp.hovered();
501            if hovered {
502                painter.rect_filled(
503                    new_rect,
504                    CornerRadius::same(NEW_BTN_RADIUS),
505                    p.depth_tint(strip_bg, 0.06),
506                );
507            }
508            let cross_color = if hovered { p.text } else { p.text_faint };
509            let center = new_rect.center();
510            let half = NEW_BTN_INNER * 0.5;
511            let stroke = Stroke::new(2.0, cross_color);
512            painter.line_segment(
513                [
514                    pos2(center.x, center.y - half),
515                    pos2(center.x, center.y + half),
516                ],
517                stroke,
518            );
519            painter.line_segment(
520                [
521                    pos2(center.x - half, center.y),
522                    pos2(center.x + half, center.y),
523                ],
524                stroke,
525            );
526            new_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, "New tab"));
527
528            if new_resp.clicked() {
529                new_clicked = true;
530            }
531        }
532
533        let border_y = strip_rect.bottom() - 0.5;
534        let stroke = Stroke::new(1.0, p.border);
535        let painter = ui.painter();
536        if let Some(active) = active_rect {
537            painter.line_segment(
538                [
539                    pos2(strip_rect.min.x, border_y),
540                    pos2(active.min.x, border_y),
541                ],
542                stroke,
543            );
544            painter.line_segment(
545                [
546                    pos2(active.max.x, border_y),
547                    pos2(strip_rect.max.x, border_y),
548                ],
549                stroke,
550            );
551        } else {
552            painter.line_segment(
553                [
554                    pos2(strip_rect.min.x, border_y),
555                    pos2(strip_rect.max.x, border_y),
556                ],
557                stroke,
558            );
559        }
560
561        if let Some(id) = activate_target {
562            if self.selected.as_deref() != Some(id.as_str()) {
563                self.selected = Some(id.clone());
564                self.events.push(BrowserTabsEvent::Activated(id));
565            }
566        }
567        if let Some(id) = close_target {
568            if self.remove_tab(&id) {
569                self.events.push(BrowserTabsEvent::Closed(id));
570                if let Some(new_active) = self.selected.clone() {
571                    self.events.push(BrowserTabsEvent::Activated(new_active));
572                }
573            }
574        }
575        if new_clicked {
576            self.events.push(BrowserTabsEvent::NewRequested);
577        }
578
579        response.widget_info(|| WidgetInfo::labeled(WidgetType::Other, true, "browser tabs"));
580        response
581    }
582}