1use 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}