Skip to main content

egui_shadcn/
sidebar.rs

1//! Sidebar component - collapsible navigation rail.
2
3use crate::theme::Theme;
4use egui::RichText;
5use egui::{
6    Align, Align2, Color32, CornerRadius, FontId, Frame, Id, Layout, Margin, Response, Sense,
7    Stroke, StrokeKind, Ui, Vec2, WidgetText, pos2, vec2,
8};
9
10const DEFAULT_EXPANDED_WIDTH: f32 = 240.0;
11const DEFAULT_COLLAPSED_WIDTH: f32 = 64.0;
12
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14pub enum SidebarSide {
15    #[default]
16    Left,
17    Right,
18}
19
20pub struct SidebarProviderProps<'a> {
21    pub id_source: Id,
22    pub open: &'a mut bool,
23    pub default_open: bool,
24    pub expanded_width: f32,
25    pub collapsed_width: f32,
26    pub animate: bool,
27    pub on_open_change: Option<&'a mut dyn FnMut(bool)>,
28}
29
30impl<'a> SidebarProviderProps<'a> {
31    pub fn new(id_source: Id, open: &'a mut bool) -> Self {
32        Self {
33            id_source,
34            open,
35            default_open: true,
36            expanded_width: DEFAULT_EXPANDED_WIDTH,
37            collapsed_width: DEFAULT_COLLAPSED_WIDTH,
38            animate: true,
39            on_open_change: None,
40        }
41    }
42
43    pub fn default_open(mut self, default_open: bool) -> Self {
44        self.default_open = default_open;
45        self
46    }
47
48    pub fn expanded_width(mut self, width: f32) -> Self {
49        self.expanded_width = width;
50        self
51    }
52
53    pub fn collapsed_width(mut self, width: f32) -> Self {
54        self.collapsed_width = width;
55        self
56    }
57
58    pub fn animate(mut self, animate: bool) -> Self {
59        self.animate = animate;
60        self
61    }
62
63    pub fn on_open_change(mut self, cb: &'a mut dyn FnMut(bool)) -> Self {
64        self.on_open_change = Some(cb);
65        self
66    }
67}
68
69pub struct SidebarContext<'a> {
70    pub id_source: Id,
71    pub open: &'a mut bool,
72    pub expanded_width: f32,
73    pub collapsed_width: f32,
74    pub animate: bool,
75    on_open_change: Option<&'a mut dyn FnMut(bool)>,
76}
77
78impl<'a> SidebarContext<'a> {
79    pub fn is_collapsed(&self) -> bool {
80        !*self.open
81    }
82
83    pub fn set_open(&mut self, open: bool) {
84        if *self.open == open {
85            return;
86        }
87        *self.open = open;
88        if let Some(cb) = self.on_open_change.as_mut() {
89            cb(open);
90        }
91    }
92
93    pub fn toggle(&mut self) {
94        let next = !*self.open;
95        self.set_open(next);
96    }
97}
98
99#[derive(Clone, Copy, Debug)]
100pub struct SidebarProps {
101    pub side: SidebarSide,
102    pub padding: Margin,
103    pub border: bool,
104}
105
106impl SidebarProps {
107    pub fn new() -> Self {
108        Self {
109            side: SidebarSide::Left,
110            padding: Margin::same(0),
111            border: true,
112        }
113    }
114
115    pub fn side(mut self, side: SidebarSide) -> Self {
116        self.side = side;
117        self
118    }
119
120    pub fn padding(mut self, padding: Margin) -> Self {
121        self.padding = padding;
122        self
123    }
124
125    pub fn border(mut self, border: bool) -> Self {
126        self.border = border;
127        self
128    }
129}
130
131impl Default for SidebarProps {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137pub struct SidebarResponse<R> {
138    pub response: Response,
139    pub inner: R,
140    pub width: f32,
141}
142
143fn apply_default_open(ui: &Ui, props: &mut SidebarProviderProps<'_>) {
144    let init_id = props.id_source.with("default-open-initialized");
145    let initialized = ui
146        .ctx()
147        .data(|d| d.get_temp::<bool>(init_id))
148        .unwrap_or(false);
149    if !initialized {
150        *props.open = props.default_open;
151        ui.ctx().data_mut(|d| d.insert_temp(init_id, true));
152    }
153}
154
155pub fn sidebar_provider<R>(
156    ui: &mut Ui,
157    mut props: SidebarProviderProps<'_>,
158    add_contents: impl FnOnce(&mut Ui, &mut SidebarContext) -> R,
159) -> R {
160    apply_default_open(ui, &mut props);
161
162    let mut ctx = SidebarContext {
163        id_source: props.id_source,
164        open: props.open,
165        expanded_width: props.expanded_width,
166        collapsed_width: props.collapsed_width,
167        animate: props.animate,
168        on_open_change: props.on_open_change,
169    };
170
171    add_contents(ui, &mut ctx)
172}
173
174pub fn sidebar<R>(
175    ui: &mut Ui,
176    theme: &Theme,
177    ctx: &mut SidebarContext,
178    props: SidebarProps,
179    add_contents: impl FnOnce(&mut Ui, &mut SidebarContext) -> R,
180) -> SidebarResponse<R> {
181    let open = *ctx.open;
182    let anim_t = if ctx.animate {
183        ui.ctx()
184            .animate_bool(ctx.id_source.with("sidebar-open"), open)
185    } else if open {
186        1.0
187    } else {
188        0.0
189    };
190
191    let width = ctx.collapsed_width + (ctx.expanded_width - ctx.collapsed_width) * anim_t;
192    let height = ui.available_height().max(1.0);
193
194    let palette = &theme.palette;
195    let rounding = CornerRadius::same(theme.radius.r2.round() as u8);
196    let border = Stroke::new(1.0, palette.sidebar_border);
197
198    let inner = ui.allocate_ui_with_layout(
199        Vec2::new(width, height),
200        Layout::top_down(Align::Min),
201        |sidebar_ui| {
202            sidebar_ui.set_min_height(height);
203            sidebar_ui.set_min_width(width);
204            sidebar_ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
205
206            let frame = Frame::default()
207                .fill(palette.sidebar)
208                .stroke(if props.border { border } else { Stroke::NONE })
209                .corner_radius(rounding)
210                .inner_margin(props.padding);
211
212            frame
213                .show(sidebar_ui, |content_ui| {
214                    content_ui.visuals_mut().override_text_color = Some(palette.sidebar_foreground);
215                    add_contents(content_ui, ctx)
216                })
217                .inner
218        },
219    );
220
221    SidebarResponse {
222        response: inner.response,
223        inner: inner.inner,
224        width,
225    }
226}
227
228pub fn sidebar_trigger(
229    ui: &mut Ui,
230    theme: &Theme,
231    ctx: &mut SidebarContext,
232    label: impl Into<WidgetText>,
233) -> Response {
234    let response = crate::Button::new(label)
235        .variant(crate::ButtonVariant::Ghost)
236        .size(crate::ButtonSize::Sm)
237        .show(ui, theme);
238    if response.clicked() {
239        ctx.toggle();
240    }
241    response
242}
243
244pub fn sidebar_header<R>(
245    ui: &mut Ui,
246    ctx: &SidebarContext,
247    add_contents: impl FnOnce(&mut Ui, &SidebarContext) -> R,
248) -> R {
249    sidebar_section(ui, ctx, Margin::symmetric(12, 12), add_contents)
250}
251
252pub fn sidebar_content<R>(
253    ui: &mut Ui,
254    ctx: &SidebarContext,
255    add_contents: impl FnOnce(&mut Ui, &SidebarContext) -> R,
256) -> R {
257    sidebar_section(ui, ctx, Margin::symmetric(12, 8), add_contents)
258}
259
260pub fn sidebar_footer<R>(
261    ui: &mut Ui,
262    ctx: &SidebarContext,
263    add_contents: impl FnOnce(&mut Ui, &SidebarContext) -> R,
264) -> R {
265    sidebar_section(ui, ctx, Margin::symmetric(12, 12), add_contents)
266}
267
268fn sidebar_section<R>(
269    ui: &mut Ui,
270    ctx: &SidebarContext,
271    padding: Margin,
272    add_contents: impl FnOnce(&mut Ui, &SidebarContext) -> R,
273) -> R {
274    Frame::default()
275        .inner_margin(padding)
276        .show(ui, |section_ui| add_contents(section_ui, ctx))
277        .inner
278}
279
280#[derive(Clone, Copy, Debug)]
281pub struct SidebarGroupProps {
282    pub spacing: f32,
283}
284
285impl SidebarGroupProps {
286    pub fn new() -> Self {
287        Self { spacing: 8.0 }
288    }
289
290    pub fn spacing(mut self, spacing: f32) -> Self {
291        self.spacing = spacing;
292        self
293    }
294}
295
296impl Default for SidebarGroupProps {
297    fn default() -> Self {
298        Self::new()
299    }
300}
301
302pub fn sidebar_group<R>(
303    ui: &mut Ui,
304    _ctx: &SidebarContext,
305    props: SidebarGroupProps,
306    add_contents: impl FnOnce(&mut Ui) -> R,
307) -> R {
308    ui.vertical(|group_ui| {
309        group_ui.spacing_mut().item_spacing = vec2(0.0, props.spacing);
310        add_contents(group_ui)
311    })
312    .inner
313}
314
315#[derive(Clone, Debug)]
316pub struct SidebarGroupLabelProps {
317    pub text: WidgetText,
318    pub show_when_collapsed: bool,
319}
320
321impl SidebarGroupLabelProps {
322    pub fn new(text: impl Into<WidgetText>) -> Self {
323        Self {
324            text: text.into(),
325            show_when_collapsed: false,
326        }
327    }
328
329    pub fn show_when_collapsed(mut self, show: bool) -> Self {
330        self.show_when_collapsed = show;
331        self
332    }
333}
334
335pub fn sidebar_group_label(
336    ui: &mut Ui,
337    theme: &Theme,
338    ctx: &SidebarContext,
339    props: SidebarGroupLabelProps,
340) -> Response {
341    if ctx.is_collapsed() && !props.show_when_collapsed {
342        return ui.allocate_response(Vec2::ZERO, Sense::hover());
343    }
344
345    let text = RichText::new(props.text.text())
346        .color(theme.palette.sidebar_foreground.gamma_multiply(0.6))
347        .size(11.0);
348    ui.add(egui::Label::new(text).sense(Sense::hover()))
349}
350
351pub fn sidebar_group_content<R>(
352    ui: &mut Ui,
353    _ctx: &SidebarContext,
354    add_contents: impl FnOnce(&mut Ui) -> R,
355) -> R {
356    ui.vertical(|content_ui| {
357        content_ui.spacing_mut().item_spacing = vec2(0.0, 4.0);
358        add_contents(content_ui)
359    })
360    .inner
361}
362
363pub fn sidebar_menu<R>(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R {
364    ui.vertical(|menu_ui| {
365        menu_ui.spacing_mut().item_spacing = vec2(0.0, 4.0);
366        add_contents(menu_ui)
367    })
368    .inner
369}
370
371pub fn sidebar_menu_item<R>(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R {
372    ui.horizontal(|item_ui| {
373        item_ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
374        add_contents(item_ui)
375    })
376    .inner
377}
378
379#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
380pub enum SidebarMenuButtonSize {
381    Sm,
382    #[default]
383    Md,
384    Lg,
385}
386
387impl SidebarMenuButtonSize {
388    fn height(self) -> f32 {
389        match self {
390            SidebarMenuButtonSize::Sm => 28.0,
391            SidebarMenuButtonSize::Md => 32.0,
392            SidebarMenuButtonSize::Lg => 40.0,
393        }
394    }
395
396    fn padding(self) -> Margin {
397        match self {
398            SidebarMenuButtonSize::Sm => Margin::symmetric(10, 6),
399            SidebarMenuButtonSize::Md => Margin::symmetric(12, 8),
400            SidebarMenuButtonSize::Lg => Margin::symmetric(12, 10),
401        }
402    }
403
404    fn text_size(self) -> f32 {
405        match self {
406            SidebarMenuButtonSize::Sm => 12.0,
407            SidebarMenuButtonSize::Md => 13.0,
408            SidebarMenuButtonSize::Lg => 14.0,
409        }
410    }
411}
412
413#[derive(Clone, Debug)]
414pub struct SidebarMenuButtonProps {
415    pub label: WidgetText,
416    pub size: SidebarMenuButtonSize,
417    pub active: bool,
418    pub disabled: bool,
419    pub show_label_when_collapsed: bool,
420}
421
422impl SidebarMenuButtonProps {
423    pub fn new(label: impl Into<WidgetText>) -> Self {
424        Self {
425            label: label.into(),
426            size: SidebarMenuButtonSize::Md,
427            active: false,
428            disabled: false,
429            show_label_when_collapsed: true,
430        }
431    }
432
433    pub fn size(mut self, size: SidebarMenuButtonSize) -> Self {
434        self.size = size;
435        self
436    }
437
438    pub fn active(mut self, active: bool) -> Self {
439        self.active = active;
440        self
441    }
442
443    pub fn disabled(mut self, disabled: bool) -> Self {
444        self.disabled = disabled;
445        self
446    }
447
448    pub fn show_label_when_collapsed(mut self, show: bool) -> Self {
449        self.show_label_when_collapsed = show;
450        self
451    }
452}
453
454pub fn sidebar_menu_button(
455    ui: &mut Ui,
456    theme: &Theme,
457    ctx: &SidebarContext,
458    props: SidebarMenuButtonProps,
459) -> Response {
460    let collapsed = ctx.is_collapsed();
461    let height = props.size.height();
462    let padding = props.size.padding();
463    let desired = vec2(ui.available_width(), height);
464    let sense = if props.disabled {
465        Sense::hover()
466    } else {
467        Sense::click()
468    };
469
470    let (rect, response) = ui.allocate_exact_size(desired, sense);
471    let hovered = response.hovered() || response.has_focus();
472
473    let palette = &theme.palette;
474    let bg = if props.active || hovered {
475        palette.sidebar_accent
476    } else {
477        Color32::TRANSPARENT
478    };
479    if bg != Color32::TRANSPARENT {
480        ui.painter()
481            .rect_filled(rect, CornerRadius::same(theme.radius.r2.round() as u8), bg);
482    }
483
484    let text_color = if props.active || hovered {
485        palette.sidebar_accent_foreground
486    } else {
487        palette.sidebar_foreground
488    };
489
490    let label_text = if collapsed && !props.show_label_when_collapsed {
491        let mut short = props.label.text().to_string();
492        short.truncate(1);
493        WidgetText::from(short)
494    } else {
495        props.label
496    };
497
498    let align = if collapsed && !props.show_label_when_collapsed {
499        Align2::CENTER_CENTER
500    } else {
501        Align2::LEFT_CENTER
502    };
503    let pos = if collapsed && !props.show_label_when_collapsed {
504        rect.center()
505    } else {
506        pos2(rect.left() + padding.left as f32, rect.center().y)
507    };
508
509    ui.painter().text(
510        pos,
511        align,
512        label_text.text(),
513        FontId::proportional(props.size.text_size()),
514        text_color,
515    );
516
517    if response.has_focus() && !props.disabled {
518        let focus_color = palette.sidebar_ring;
519        ui.painter().rect_stroke(
520            rect,
521            CornerRadius::same(theme.radius.r2.round() as u8),
522            theme.focus.stroke(focus_color),
523            StrokeKind::Outside,
524        );
525    }
526
527    if props.disabled {
528        response
529    } else {
530        response.on_hover_cursor(egui::CursorIcon::PointingHand)
531    }
532}