Skip to main content

egui_components/
breadcrumb.rs

1//! `Breadcrumb` — a horizontal trail of navigable links.
2//!
3//! All but the last item render as clickable links separated by a chevron; the
4//! final item is the muted "current page" and is not clickable. [`show`] returns
5//! the index of the item clicked this frame, if any.
6//!
7//! ```ignore
8//! if let Some(i) = sc::Breadcrumb::new()
9//!     .item("Home")
10//!     .item("Library")
11//!     .current("Data structures")
12//!     .show(ui)
13//! {
14//!     // navigate to crumb `i`
15//! }
16//! ```
17
18use egui::{vec2, FontId, Rect, Sense, Ui};
19use egui_components_theme::Theme;
20
21use crate::icon::{paint_icon, IconKind};
22
23pub struct Breadcrumb {
24    items: Vec<String>,
25    /// Index of the current (non-clickable) item, if the last `current` was set.
26    has_current: bool,
27}
28
29impl Default for Breadcrumb {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl Breadcrumb {
36    pub fn new() -> Self {
37        Self {
38            items: Vec::new(),
39            has_current: false,
40        }
41    }
42
43    /// A clickable crumb.
44    pub fn item(mut self, label: impl Into<String>) -> Self {
45        self.items.push(label.into());
46        self.has_current = false;
47        self
48    }
49
50    /// The final, current crumb (muted, not clickable). Should be added last.
51    pub fn current(mut self, label: impl Into<String>) -> Self {
52        self.items.push(label.into());
53        self.has_current = true;
54        self
55    }
56
57    pub fn show(self, ui: &mut Ui) -> Option<usize> {
58        let theme = Theme::get(ui.ctx());
59        let c = theme.colors;
60        let m = theme.metrics;
61        let font = FontId::proportional(m.font_size_sm);
62        let last = self.items.len().saturating_sub(1);
63
64        let mut clicked = None;
65        ui.horizontal(|ui| {
66            for (i, label) in self.items.iter().enumerate() {
67                let is_current = self.has_current && i == last;
68                let galley = ui.ctx().fonts_mut(|f| {
69                    f.layout_no_wrap(label.clone(), font.clone(), c.muted_foreground)
70                });
71                let sense = if is_current {
72                    Sense::hover()
73                } else {
74                    Sense::click()
75                };
76                let (rect, resp) = ui.allocate_exact_size(galley.size(), sense);
77
78                let color = if is_current {
79                    c.foreground
80                } else if resp.hovered() {
81                    c.link_hover_foreground
82                } else {
83                    c.muted_foreground
84                };
85                ui.painter()
86                    .galley_with_override_text_color(rect.min, galley, color);
87                if resp.hovered() && !is_current {
88                    let y = rect.bottom() - 1.0;
89                    ui.painter().line_segment(
90                        [egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)],
91                        egui::Stroke::new(1.0, color),
92                    );
93                    ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
94                }
95                if resp.clicked() {
96                    clicked = Some(i);
97                }
98
99                // Separator chevron (not after the last item).
100                if i != last {
101                    let (sep_rect, _) =
102                        ui.allocate_exact_size(vec2(14.0, galley_h(&font)), Sense::hover());
103                    let ir = Rect::from_center_size(sep_rect.center(), vec2(12.0, 12.0));
104                    paint_icon(ui.painter(), IconKind::ChevronRight, ir, c.muted_foreground, 1.4);
105                }
106            }
107        });
108
109        clicked
110    }
111}
112
113fn galley_h(font: &FontId) -> f32 {
114    font.size + 4.0
115}