Skip to main content

egui_components/
sidebar.rs

1//! `Sidebar` — a vertical navigation panel with icon + label items.
2//!
3//! Build the items inside the [`show`](Sidebar::show) closure; each
4//! [`item`](SidebarUi::item) gets an auto-incrementing index. The currently
5//! selected index is passed in for highlighting, and the index clicked this
6//! frame is returned. Set [`collapsed`](Sidebar::collapsed) for an icon-only
7//! rail (labels move into hover tooltips).
8//!
9//! ```ignore
10//! let clicked = sc::Sidebar::new("nav")
11//!     .selected(self.page)
12//!     .collapsed(self.collapsed)
13//!     .show(ui, |s| {
14//!         s.header("Workspace");
15//!         s.item(sc::IconKind::Home, "Home");      // 0
16//!         s.item(sc::IconKind::File, "Documents");  // 1
17//!         s.item(sc::IconKind::Settings, "Settings"); // 2
18//!     });
19//! if let Some(i) = clicked { self.page = i; }
20//! ```
21
22use egui::{pos2, vec2, Align, FontId, Layout, Rect, Sense, Ui, UiBuilder};
23use egui_components_theme::Theme;
24
25use crate::icon::{paint_icon, IconKind};
26use crate::tooltip::Tooltip;
27
28pub struct Sidebar {
29    width: f32,
30    collapsed_width: f32,
31    collapsed: bool,
32    selected: Option<usize>,
33}
34
35impl Default for Sidebar {
36    fn default() -> Self {
37        Self::new("sidebar")
38    }
39}
40
41impl Sidebar {
42    pub fn new(_id_salt: impl std::hash::Hash) -> Self {
43        Self {
44            width: 220.0,
45            collapsed_width: 56.0,
46            collapsed: false,
47            selected: None,
48        }
49    }
50    pub fn width(mut self, w: f32) -> Self {
51        self.width = w;
52        self
53    }
54    pub fn collapsed(mut self, c: bool) -> Self {
55        self.collapsed = c;
56        self
57    }
58    pub fn selected(mut self, idx: Option<usize>) -> Self {
59        self.selected = idx;
60        self
61    }
62
63    pub fn show(self, ui: &mut Ui, build: impl FnOnce(&mut SidebarUi)) -> Option<usize> {
64        let theme = Theme::get(ui.ctx());
65        let c = theme.colors;
66        let width = if self.collapsed {
67            self.collapsed_width
68        } else {
69            self.width
70        };
71        let height = ui.available_height();
72
73        let (rect, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover());
74        // Panel surface + right border.
75        ui.painter().rect_filled(rect, 0.0, c.muted_background);
76        ui.painter().line_segment(
77            [rect.right_top(), rect.right_bottom()],
78            theme.border_stroke(),
79        );
80
81        let mut content = ui.new_child(
82            UiBuilder::new()
83                .max_rect(rect.shrink(8.0))
84                .layout(Layout::top_down(Align::Min)),
85        );
86
87        let mut sb = SidebarUi {
88            ui: &mut content,
89            theme,
90            collapsed: self.collapsed,
91            selected: self.selected,
92            next_index: 0,
93            clicked: None,
94        };
95        build(&mut sb);
96        sb.clicked
97    }
98}
99
100/// Builder handed to the [`Sidebar::show`] closure.
101pub struct SidebarUi<'a> {
102    ui: &'a mut Ui,
103    theme: Theme,
104    collapsed: bool,
105    selected: Option<usize>,
106    next_index: usize,
107    clicked: Option<usize>,
108}
109
110impl SidebarUi<'_> {
111    /// A muted group heading (hidden when the sidebar is collapsed).
112    pub fn header(&mut self, text: impl Into<String>) {
113        if self.collapsed {
114            self.ui.add_space(8.0);
115            return;
116        }
117        self.ui.add_space(8.0);
118        self.ui.add(
119            crate::label::Label::new(text.into())
120                .muted()
121                .size(crate::common::Size::Small),
122        );
123        self.ui.add_space(2.0);
124    }
125
126    pub fn separator(&mut self) {
127        self.ui.add_space(6.0);
128        self.ui.add(crate::separator::Separator::horizontal());
129        self.ui.add_space(6.0);
130    }
131
132    /// A navigation item. Returns `true` if it was clicked this frame.
133    pub fn item(&mut self, icon: IconKind, label: impl Into<String>) -> bool {
134        let index = self.next_index;
135        self.next_index += 1;
136        let selected = self.selected == Some(index);
137        let label = label.into();
138
139        let c = self.theme.colors;
140        let m = self.theme.metrics;
141        let row_h = m.button_height_md;
142        let resp = {
143            let ui = &mut self.ui;
144            let (rect, resp) =
145                ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
146
147            if ui.is_rect_visible(rect) {
148                let painter = ui.painter();
149                let bg = if selected {
150                    c.secondary_background
151                } else if resp.hovered() {
152                    c.accent_background
153                } else {
154                    egui::Color32::TRANSPARENT
155                };
156                if bg != egui::Color32::TRANSPARENT {
157                    painter.rect_filled(rect, self.theme.corner_sm(), bg);
158                }
159                if selected {
160                    painter.rect_filled(
161                        Rect::from_min_size(
162                            pos2(rect.left(), rect.top() + 5.0),
163                            vec2(2.5, rect.height() - 10.0),
164                        ),
165                        egui::CornerRadius::same(1),
166                        c.primary_background,
167                    );
168                }
169
170                let fg = if selected { c.foreground } else { c.muted_foreground };
171                let icon_size = 18.0;
172                if self.collapsed {
173                    let ir = Rect::from_center_size(rect.center(), vec2(icon_size, icon_size));
174                    paint_icon(painter, icon, ir, fg, 1.7);
175                } else {
176                    let ir = Rect::from_center_size(
177                        pos2(rect.left() + 10.0 + icon_size * 0.5, rect.center().y),
178                        vec2(icon_size, icon_size),
179                    );
180                    paint_icon(painter, icon, ir, fg, 1.7);
181                    painter.text(
182                        pos2(ir.right() + 10.0, rect.center().y),
183                        egui::Align2::LEFT_CENTER,
184                        &label,
185                        FontId::proportional(m.font_size_md),
186                        fg,
187                    );
188                }
189                if resp.hovered() {
190                    ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
191                }
192            }
193            resp
194        };
195
196        // In collapsed mode, reveal the label as a tooltip.
197        if self.collapsed {
198            Tooltip::new(label).attach(resp.clone());
199        }
200
201        if resp.clicked() {
202            self.clicked = Some(index);
203            true
204        } else {
205            false
206        }
207    }
208}
209
210/// `Rail` — a compact, always-icon-only vertical navigation strip.
211///
212/// Where a collapsed [`Sidebar`] is a *temporary* icon view of a labelled
213/// panel, a `Rail` is a *permanent* slim bar (think a VS Code activity bar or
214/// a Material navigation rail). Labels always live in hover tooltips. Items
215/// added after [`footer`](RailUi::footer) are pinned to the bottom of the rail.
216///
217/// ```ignore
218/// let clicked = sc::Rail::new("rail")
219///     .selected(self.page)
220///     .show(ui, |r| {
221///         r.item(sc::IconKind::Home, "Home");        // 0
222///         r.item(sc::IconKind::Search, "Explore");   // 1
223///         r.footer();                                // pin the rest to the bottom
224///         r.item(sc::IconKind::Settings, "Settings"); // 2
225///     });
226/// if let Some(i) = clicked { self.page = i; }
227/// ```
228pub struct Rail {
229    width: f32,
230    selected: Option<usize>,
231}
232
233impl Default for Rail {
234    fn default() -> Self {
235        Self::new("rail")
236    }
237}
238
239impl Rail {
240    pub fn new(_id_salt: impl std::hash::Hash) -> Self {
241        Self {
242            width: 56.0,
243            selected: None,
244        }
245    }
246    pub fn width(mut self, w: f32) -> Self {
247        self.width = w;
248        self
249    }
250    pub fn selected(mut self, idx: Option<usize>) -> Self {
251        self.selected = idx;
252        self
253    }
254
255    pub fn show(self, ui: &mut Ui, build: impl FnOnce(&mut RailUi)) -> Option<usize> {
256        let theme = Theme::get(ui.ctx());
257        let c = theme.colors;
258        let height = ui.available_height();
259
260        let (rect, _) = ui.allocate_exact_size(vec2(self.width, height), Sense::hover());
261        ui.painter().rect_filled(rect, 0.0, c.muted_background);
262        ui.painter().line_segment(
263            [rect.right_top(), rect.right_bottom()],
264            theme.border_stroke(),
265        );
266
267        // First pass: collect the item specs so footer items can be laid out
268        // against the bottom edge after the top group is placed.
269        let mut spec = RailUi {
270            theme,
271            next_index: 0,
272            in_footer: false,
273            top: Vec::new(),
274            bottom: Vec::new(),
275        };
276        build(&mut spec);
277
278        let inner = rect.shrink(8.0);
279        let row_h = 44.0;
280        let mut clicked = None;
281
282        // Top group, laid out from the top down.
283        let mut top_ui = ui.new_child(
284            UiBuilder::new()
285                .max_rect(inner)
286                .layout(Layout::top_down(Align::Center)),
287        );
288        for it in &spec.top {
289            if paint_rail_item(&mut top_ui, theme, it, self.selected, row_h) {
290                clicked = Some(it.index);
291            }
292        }
293
294        // Footer group, anchored to the bottom edge.
295        if !spec.bottom.is_empty() {
296            let footer_h = spec.bottom.len() as f32 * (row_h + top_ui.spacing().item_spacing.y);
297            let footer_rect = Rect::from_min_size(
298                pos2(inner.left(), inner.bottom() - footer_h),
299                vec2(inner.width(), footer_h),
300            );
301            let mut bottom_ui = ui.new_child(
302                UiBuilder::new()
303                    .max_rect(footer_rect)
304                    .layout(Layout::top_down(Align::Center)),
305            );
306            for it in &spec.bottom {
307                if paint_rail_item(&mut bottom_ui, theme, it, self.selected, row_h) {
308                    clicked = Some(it.index);
309                }
310            }
311        }
312
313        clicked
314    }
315}
316
317struct RailItem {
318    icon: IconKind,
319    label: String,
320    index: usize,
321}
322
323/// Builder handed to the [`Rail::show`] closure.
324pub struct RailUi {
325    theme: Theme,
326    next_index: usize,
327    in_footer: bool,
328    top: Vec<RailItem>,
329    bottom: Vec<RailItem>,
330}
331
332impl RailUi {
333    /// Register a navigation item. Each call gets the next auto-incrementing
334    /// index; the index is what [`Rail::show`] returns when the item is clicked.
335    pub fn item(&mut self, icon: IconKind, label: impl Into<String>) {
336        let item = RailItem {
337            icon,
338            label: label.into(),
339            index: self.next_index,
340        };
341        self.next_index += 1;
342        if self.in_footer {
343            self.bottom.push(item);
344        } else {
345            self.top.push(item);
346        }
347    }
348
349    /// Pin every subsequent [`item`](Self::item) to the bottom of the rail.
350    pub fn footer(&mut self) {
351        self.in_footer = true;
352    }
353
354    /// Read access to the resolved theme, in case callers want to match it.
355    pub fn theme(&self) -> Theme {
356        self.theme
357    }
358}
359
360fn paint_rail_item(
361    ui: &mut Ui,
362    theme: Theme,
363    item: &RailItem,
364    selected: Option<usize>,
365    row_h: f32,
366) -> bool {
367    let c = theme.colors;
368    let selected = selected == Some(item.index);
369    let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
370
371    if ui.is_rect_visible(rect) {
372        let painter = ui.painter();
373        let bg = if selected {
374            c.secondary_background
375        } else if resp.hovered() {
376            c.accent_background
377        } else {
378            egui::Color32::TRANSPARENT
379        };
380        if bg != egui::Color32::TRANSPARENT {
381            painter.rect_filled(rect, theme.corner_sm(), bg);
382        }
383        if selected {
384            painter.rect_filled(
385                Rect::from_min_size(
386                    pos2(rect.left(), rect.top() + 6.0),
387                    vec2(2.5, rect.height() - 12.0),
388                ),
389                egui::CornerRadius::same(1),
390                c.primary_background,
391            );
392        }
393        let fg = if selected { c.foreground } else { c.muted_foreground };
394        let icon_size = 20.0;
395        let ir = Rect::from_center_size(rect.center(), vec2(icon_size, icon_size));
396        paint_icon(painter, item.icon, ir, fg, 1.7);
397        if resp.hovered() {
398            ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
399        }
400    }
401
402    Tooltip::new(item.label.clone()).attach(resp.clone());
403    resp.clicked()
404}