1use crate::animation::SpringAnimation;
33use crate::ext::ArmasContextExt;
34use egui::{Color32, Pos2, Rect, Response, Sense, Stroke, Ui, Vec2};
35
36const SIDEBAR_WIDTH: f32 = 256.0; const SIDEBAR_WIDTH_ICON: f32 = 48.0; const ITEM_HEIGHT: f32 = 32.0; const ITEM_HEIGHT_SM: f32 = 28.0; const ITEM_GAP: f32 = 4.0; const ITEM_PADDING: f32 = 8.0; const ICON_SIZE: f32 = 16.0; const CORNER_RADIUS: f32 = 6.0; const GROUP_PADDING: f32 = 8.0; const SPRING_STIFFNESS: f32 = 300.0;
51const SPRING_DAMPING: f32 = 25.0;
52
53#[derive(Clone, Debug)]
59pub struct SidebarState {
60 pub open: bool,
62 width_spring: SpringAnimation,
64 expanded_groups: std::collections::HashMap<String, bool>,
66 active_index: Option<usize>,
68}
69
70impl Default for SidebarState {
71 fn default() -> Self {
72 Self::new(true)
73 }
74}
75
76impl SidebarState {
77 #[must_use]
79 pub fn new(open: bool) -> Self {
80 let target = if open {
81 SIDEBAR_WIDTH
82 } else {
83 SIDEBAR_WIDTH_ICON
84 };
85 Self {
86 open,
87 width_spring: SpringAnimation::new(target, target)
88 .params(SPRING_STIFFNESS, SPRING_DAMPING),
89 expanded_groups: std::collections::HashMap::new(),
90 active_index: None,
91 }
92 }
93
94 pub const fn toggle(&mut self) {
96 self.open = !self.open;
97 let target = if self.open {
98 SIDEBAR_WIDTH
99 } else {
100 SIDEBAR_WIDTH_ICON
101 };
102 self.width_spring.set_target(target);
103 }
104
105 pub const fn set_open(&mut self, open: bool) {
107 if self.open != open {
108 self.open = open;
109 let target = if open {
110 SIDEBAR_WIDTH
111 } else {
112 SIDEBAR_WIDTH_ICON
113 };
114 self.width_spring.set_target(target);
115 }
116 }
117
118 #[must_use]
120 pub const fn is_open(&self) -> bool {
121 self.open
122 }
123
124 #[must_use]
126 pub const fn width(&self) -> f32 {
127 self.width_spring.value
128 }
129
130 #[must_use]
132 pub fn is_animating(&self) -> bool {
133 !self.width_spring.is_settled(0.5, 0.5)
134 }
135}
136
137#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
143pub enum SidebarVariant {
144 #[default]
146 Sidebar,
147 Floating,
149 Inset,
151}
152
153#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
155pub enum CollapsibleMode {
156 #[default]
158 Icon,
159 Offcanvas,
161 None,
163}
164
165#[derive(Clone)]
171struct InternalSidebarItem {
172 id: String,
173 icon: String,
174 label: String,
175 active: bool,
176 badge: Option<String>,
177 depth: usize,
178 is_group_header: bool,
179 is_group_label: bool,
180}
181
182pub struct SidebarItemBuilder<'a> {
187 item: &'a mut InternalSidebarItem,
188}
189
190impl SidebarItemBuilder<'_> {
191 #[must_use]
193 pub const fn active(self, active: bool) -> Self {
194 self.item.active = active;
195 self
196 }
197
198 #[must_use]
200 pub fn badge(self, badge: impl Into<String>) -> Self {
201 self.item.badge = Some(badge.into());
202 self
203 }
204}
205
206pub struct SidebarBuilder<'a> {
208 items: &'a mut Vec<InternalSidebarItem>,
209 current_depth: usize,
210 expanded_groups: &'a mut std::collections::HashMap<String, bool>,
211}
212
213impl SidebarBuilder<'_> {
214 pub fn item(&mut self, icon: &str, label: &str) -> SidebarItemBuilder<'_> {
220 let id = format!("item_{}_{}", self.current_depth, label);
221 let item = InternalSidebarItem {
222 id,
223 icon: icon.to_string(),
224 label: label.to_string(),
225 active: false,
226 badge: None,
227 depth: self.current_depth,
228 is_group_header: false,
229 is_group_label: false,
230 };
231 self.items.push(item);
232 let idx = self.items.len() - 1;
233 SidebarItemBuilder {
234 item: &mut self.items[idx],
235 }
236 }
237
238 pub fn group_label(&mut self, label: &str) {
240 let id = format!("group_label_{label}");
241 let item = InternalSidebarItem {
242 id,
243 icon: String::new(),
244 label: label.to_string(),
245 active: false,
246 badge: None,
247 depth: self.current_depth,
248 is_group_header: false,
249 is_group_label: true,
250 };
251 self.items.push(item);
252 }
253
254 pub fn group(&mut self, icon: &str, label: &str, content: impl FnOnce(&mut Self)) {
256 let group_id = format!("group_{}_{}", self.current_depth, label);
257
258 let is_expanded = self
260 .expanded_groups
261 .get(&group_id)
262 .copied()
263 .unwrap_or(false);
264
265 let group_item = InternalSidebarItem {
267 id: group_id,
268 icon: icon.to_string(),
269 label: label.to_string(),
270 active: false,
271 badge: None,
272 depth: self.current_depth,
273 is_group_header: true,
274 is_group_label: false,
275 };
276 self.items.push(group_item);
277
278 if is_expanded {
280 self.current_depth += 1;
281 content(self);
282 self.current_depth -= 1;
283 }
284 }
285}
286
287pub struct SidebarResponse {
289 pub response: Response,
291 pub clicked: Option<String>,
293 pub hovered: Option<usize>,
295 pub is_expanded: bool,
297}
298
299pub struct Sidebar<'a> {
334 external_state: Option<&'a mut SidebarState>,
336 initial_open: bool,
338 collapsed_width: Option<f32>,
340 expanded_width: Option<f32>,
342 collapsible: CollapsibleMode,
344 show_icons: bool,
346 variant: SidebarVariant,
348}
349
350struct SidebarLayout {
352 content_width: f32,
354 expansion_ratio: f32,
356 icon_x_base: f32,
358 content_rect: Rect,
360}
361
362impl SidebarLayout {
363 fn compute(
365 current_width: f32,
366 collapsed_width: f32,
367 expanded_width: f32,
368 content_rect: Rect,
369 ) -> Self {
370 let content_width = current_width;
371 let expansion_ratio = ((content_width - collapsed_width)
372 / (expanded_width - collapsed_width))
373 .clamp(0.0, 1.0);
374
375 let icon_left_aligned_x =
376 content_rect.left() + ITEM_PADDING + ITEM_PADDING + ICON_SIZE / 2.0;
377 let icon_centered_x = content_rect.left() + content_width / 2.0;
378 let icon_x_base = if expansion_ratio < 0.5 {
379 icon_centered_x
380 } else {
381 let t = (expansion_ratio - 0.5) * 2.0;
382 icon_centered_x + (icon_left_aligned_x - icon_centered_x) * t
383 };
384
385 Self {
386 content_width,
387 expansion_ratio,
388 icon_x_base,
389 content_rect,
390 }
391 }
392}
393
394impl<'a> Sidebar<'a> {
395 #[must_use]
397 pub const fn new() -> Self {
398 Self {
399 external_state: None,
400 initial_open: true,
401 collapsed_width: None,
402 expanded_width: None,
403 collapsible: CollapsibleMode::Icon,
404 show_icons: true,
405 variant: SidebarVariant::Sidebar,
406 }
407 }
408
409 #[must_use]
413 pub const fn state(mut self, state: &'a mut SidebarState) -> Self {
414 self.external_state = Some(state);
415 self
416 }
417
418 #[must_use]
420 pub const fn collapsed(mut self, collapsed: bool) -> Self {
421 self.initial_open = !collapsed;
422 self
423 }
424
425 #[must_use]
427 pub const fn collapsed_width(mut self, width: f32) -> Self {
428 self.collapsed_width = Some(width);
429 self
430 }
431
432 #[must_use]
434 pub const fn expanded_width(mut self, width: f32) -> Self {
435 self.expanded_width = Some(width);
436 self
437 }
438
439 #[must_use]
441 pub const fn collapsible(mut self, mode: CollapsibleMode) -> Self {
442 self.collapsible = mode;
443 self
444 }
445
446 #[must_use]
448 pub const fn show_icons(mut self, show_icons: bool) -> Self {
449 self.show_icons = show_icons;
450 self
451 }
452
453 #[must_use]
455 pub const fn variant(mut self, variant: SidebarVariant) -> Self {
456 self.variant = variant;
457 self
458 }
459
460 pub fn show<R>(
462 mut self,
463 ui: &mut Ui,
464 content: impl FnOnce(&mut SidebarBuilder) -> R,
465 ) -> SidebarResponse {
466 let theme = ui.ctx().armas_theme();
467 let dt = ui.input(|i| i.stable_dt);
468
469 let collapsed_width = self.collapsed_width.unwrap_or(SIDEBAR_WIDTH_ICON);
471 let expanded_width = self.expanded_width.unwrap_or(SIDEBAR_WIDTH);
472
473 let sidebar_id = ui.id().with("sidebar_state");
475
476 let mut internal_state: SidebarState = if self.external_state.is_none() {
478 ui.ctx().data_mut(|d| {
479 d.get_temp(sidebar_id).unwrap_or_else(|| {
480 let mut state = SidebarState::new(self.initial_open);
481 let target = if self.initial_open {
483 expanded_width
484 } else {
485 collapsed_width
486 };
487 state.width_spring = SpringAnimation::new(target, target)
488 .params(SPRING_STIFFNESS, SPRING_DAMPING);
489 state
490 })
491 })
492 } else {
493 SidebarState::default() };
495
496 let state = self
498 .external_state
499 .as_deref_mut()
500 .map_or(&mut internal_state, |ext| ext);
501
502 state.width_spring.update(dt);
504
505 let mut items = Vec::new();
507 {
508 let mut builder = SidebarBuilder {
509 items: &mut items,
510 current_depth: 0,
511 expanded_groups: &mut state.expanded_groups,
512 };
513 content(&mut builder);
514 }
515
516 let current_width = state.width_spring.value;
517
518 let floating_padding = if matches!(
520 self.variant,
521 SidebarVariant::Floating | SidebarVariant::Inset
522 ) {
523 8.0
524 } else {
525 0.0
526 };
527
528 let outer_width = current_width + floating_padding * 2.0;
529
530 let mut clicked_id: Option<String> = None;
531 let mut hovered_index: Option<usize> = None;
532
533 let outer_response = ui.allocate_ui_with_layout(
536 Vec2::new(outer_width, ui.available_height()),
537 egui::Layout::top_down(egui::Align::Min),
538 |ui| {
539 ui.set_width(outer_width);
540
541 let content_width = current_width;
542
543 let placeholder_rect =
545 Rect::from_min_size(ui.cursor().min, Vec2::new(content_width, 0.0));
546 let layout = SidebarLayout::compute(
547 content_width,
548 collapsed_width,
549 expanded_width,
550 placeholder_rect,
551 );
552
553 ui.add_space(GROUP_PADDING + floating_padding);
555
556 if self.collapsible != CollapsibleMode::None {
558 render_toggle_button_inline(ui, &theme, &layout, state, floating_padding);
559 }
560
561 (clicked_id, hovered_index) = render_items(
563 ui,
564 &theme,
565 self.show_icons,
566 &layout,
567 state,
568 &items,
569 floating_padding,
570 );
571
572 ui.add_space(GROUP_PADDING + floating_padding);
573 },
574 );
575
576 let rect = outer_response.response.rect;
577
578 if ui.is_rect_visible(rect) {
580 let content_rect = if floating_padding > 0.0 {
581 rect.shrink(floating_padding)
582 } else {
583 rect
584 };
585 let layout = SidebarLayout::compute(
586 current_width,
587 collapsed_width,
588 expanded_width,
589 content_rect,
590 );
591 render_background(ui, &theme, self.variant, rect, &layout);
592 }
593
594 if state.is_animating() {
596 ui.ctx().request_repaint();
597 }
598
599 let is_expanded = state.open;
600
601 if self.external_state.is_none() {
603 ui.ctx().data_mut(|d| {
604 d.insert_temp(sidebar_id, internal_state);
605 });
606 }
607
608 let response = outer_response.response;
609
610 SidebarResponse {
611 response,
612 clicked: clicked_id,
613 hovered: hovered_index,
614 is_expanded,
615 }
616 }
617}
618
619impl Default for Sidebar<'_> {
620 fn default() -> Self {
621 Self::new()
622 }
623}
624
625fn render_background(
631 ui: &Ui,
632 theme: &crate::Theme,
633 variant: SidebarVariant,
634 rect: Rect,
635 layout: &SidebarLayout,
636) {
637 let painter = ui.painter();
638 match variant {
639 SidebarVariant::Sidebar => {
640 painter.rect_filled(rect, 0.0, theme.sidebar());
641 painter.line_segment(
642 [rect.right_top(), rect.right_bottom()],
643 Stroke::new(1.0, theme.border()),
644 );
645 }
646 SidebarVariant::Floating | SidebarVariant::Inset => {
647 painter.rect_filled(
649 layout.content_rect.translate(Vec2::new(0.0, 2.0)),
650 CORNER_RADIUS + 2.0,
651 Color32::from_black_alpha(20),
652 );
653 painter.rect_filled(layout.content_rect, CORNER_RADIUS + 2.0, theme.sidebar());
655 painter.rect_stroke(
657 layout.content_rect,
658 CORNER_RADIUS + 2.0,
659 Stroke::new(1.0, theme.sidebar_border()),
660 egui::StrokeKind::Inside,
661 );
662 }
663 }
664}
665
666fn render_toggle_button_inline(
668 ui: &mut Ui,
669 theme: &crate::Theme,
670 layout: &SidebarLayout,
671 state: &mut SidebarState,
672 h_pad: f32,
673) {
674 let (rect, response) = ui.allocate_exact_size(
675 Vec2::new(layout.content_width - ITEM_PADDING * 2.0, ITEM_HEIGHT),
676 Sense::click().union(Sense::hover()),
677 );
678 ui.add_space(ITEM_GAP);
679
680 if response.clicked() {
681 state.toggle();
682 }
683
684 if ui.is_rect_visible(rect) {
685 let icon_x = layout.icon_x_base + h_pad;
687 let painter = ui.painter();
688 if response.hovered() {
689 painter.rect_filled(rect, CORNER_RADIUS, theme.sidebar_accent());
690 }
691 painter.text(
692 Pos2::new(icon_x, rect.center().y),
693 egui::Align2::CENTER_CENTER,
694 "☰",
695 egui::FontId::proportional(ICON_SIZE),
696 if response.hovered() {
697 theme.sidebar_accent_foreground()
698 } else {
699 theme.sidebar_foreground()
700 },
701 );
702 }
703}
704
705fn render_items(
707 ui: &mut Ui,
708 theme: &crate::Theme,
709 show_icons: bool,
710 layout: &SidebarLayout,
711 state: &mut SidebarState,
712 items: &[InternalSidebarItem],
713 h_pad: f32,
714) -> (Option<String>, Option<usize>) {
715 let mut clicked_id: Option<String> = None;
716 let mut hovered_index: Option<usize> = None;
717
718 for (index, item) in items.iter().enumerate() {
719 let item_height = if item.is_group_label || item.depth == 0 {
720 ITEM_HEIGHT
721 } else {
722 ITEM_HEIGHT_SM
723 };
724
725 if item.is_group_label {
727 let (rect, _) = ui
728 .allocate_exact_size(Vec2::new(layout.content_width, item_height), Sense::hover());
729 ui.add_space(ITEM_GAP);
730
731 if ui.is_rect_visible(rect) && layout.expansion_ratio > 0.5 {
732 let opacity = ((layout.expansion_ratio - 0.5) / 0.5).clamp(0.0, 1.0);
733 let color = theme.sidebar_foreground().gamma_multiply(0.7 * opacity);
734 ui.painter().text(
735 Pos2::new(rect.left() + ITEM_PADDING, rect.center().y),
736 egui::Align2::LEFT_CENTER,
737 &item.label,
738 egui::FontId::proportional(12.0),
739 color,
740 );
741 }
742 continue;
743 }
744
745 let indent = if item.depth > 0 {
746 14.0 + (item.depth - 1) as f32 * 12.0
747 } else {
748 0.0
749 };
750 let item_width = layout.content_width - ITEM_PADDING * 2.0 - indent;
751
752 let (rect, response) = ui.allocate_exact_size(
753 Vec2::new(item_width, item_height),
754 Sense::click().union(Sense::hover()),
755 );
756 ui.add_space(ITEM_GAP);
757
758 if response.hovered() {
759 hovered_index = Some(index);
760 }
761
762 if response.clicked() {
763 if item.is_group_header {
764 let was_expanded = state
765 .expanded_groups
766 .get(&item.id)
767 .copied()
768 .unwrap_or(false);
769 state.expanded_groups.insert(item.id.clone(), !was_expanded);
770 } else {
771 clicked_id = Some(item.id.clone());
772 state.active_index = Some(index);
773 }
774 }
775
776 if !ui.is_rect_visible(rect) {
777 continue;
778 }
779
780 let is_active = item.active || state.active_index == Some(index);
781 let is_hovered = response.hovered();
782 let painter = ui.painter();
783
784 if item.depth > 0 {
786 let border_x = rect.left() - indent + 14.0;
787 painter.line_segment(
788 [
789 Pos2::new(border_x, rect.top()),
790 Pos2::new(border_x, rect.bottom()),
791 ],
792 Stroke::new(1.0, theme.sidebar_border()),
793 );
794 }
795
796 if is_active || is_hovered {
797 painter.rect_filled(rect, CORNER_RADIUS, theme.sidebar_accent());
798 }
799
800 let text_color = if is_active || is_hovered {
801 theme.sidebar_accent_foreground()
802 } else {
803 theme.sidebar_foreground()
804 };
805
806 let icon_center = if show_icons && !item.icon.is_empty() {
808 let item_icon_x = if item.depth > 0 {
809 rect.left() + ITEM_PADDING + ICON_SIZE / 2.0
810 } else {
811 layout.icon_x_base + h_pad
812 };
813 painter.text(
814 Pos2::new(item_icon_x, rect.center().y),
815 egui::Align2::CENTER_CENTER,
816 &item.icon,
817 egui::FontId::proportional(ICON_SIZE),
818 text_color,
819 );
820 Some(Pos2::new(item_icon_x, rect.center().y))
821 } else {
822 None
823 };
824
825 if layout.expansion_ratio > 0.3 {
826 let label_opacity = ((layout.expansion_ratio - 0.3) / 0.7).clamp(0.0, 1.0);
827 let label_color = Color32::from_rgba_unmultiplied(
828 text_color.r(),
829 text_color.g(),
830 text_color.b(),
831 (f32::from(text_color.a()) * label_opacity) as u8,
832 );
833
834 let label_x = if show_icons && !item.icon.is_empty() {
835 rect.left() + ITEM_PADDING + ICON_SIZE + 8.0
836 } else {
837 rect.left() + ITEM_PADDING
838 };
839
840 painter.text(
841 Pos2::new(label_x, rect.center().y),
842 egui::Align2::LEFT_CENTER,
843 &item.label,
844 egui::FontId::proportional(14.0),
845 label_color,
846 );
847
848 if item.is_group_header {
849 let is_expanded = state
850 .expanded_groups
851 .get(&item.id)
852 .copied()
853 .unwrap_or(false);
854 painter.text(
855 Pos2::new(rect.right() - ITEM_PADDING - 8.0, rect.center().y),
856 egui::Align2::CENTER_CENTER,
857 if is_expanded { "▼" } else { "▶" },
858 egui::FontId::proportional(10.0),
859 label_color.gamma_multiply(0.7),
860 );
861 }
862
863 if let Some(badge) = &item.badge {
864 if !item.is_group_header {
865 draw_badge(painter, theme, &rect, badge, label_opacity);
866 }
867 }
868 } else if let Some(badge) = &item.badge {
869 if !item.is_group_header {
870 if let Some(icon_pos) = icon_center {
871 draw_collapsed_badge(painter, theme, icon_pos, badge);
872 }
873 }
874 }
875 }
876
877 (clicked_id, hovered_index)
878}
879
880fn draw_collapsed_badge(
882 painter: &egui::Painter,
883 theme: &crate::Theme,
884 icon_center: Pos2,
885 _badge: &str,
886) {
887 let badge_pos = Pos2::new(
889 icon_center.x + ICON_SIZE / 2.0 - 2.0,
890 icon_center.y - ICON_SIZE / 2.0 + 2.0,
891 );
892 let badge_radius = 4.0;
893
894 painter.circle_filled(badge_pos, badge_radius, theme.destructive());
896 painter.circle_stroke(badge_pos, badge_radius, Stroke::new(1.0, theme.sidebar()));
898}
899
900fn draw_badge(
901 painter: &egui::Painter,
902 theme: &crate::Theme,
903 item_rect: &Rect,
904 badge: &str,
905 opacity: f32,
906) {
907 let badge_height = 18.0;
908 let badge_padding_x = 6.0;
909 let badge_min_width = 18.0;
910
911 let text_width = badge.len() as f32 * 6.0 + badge_padding_x * 2.0;
913 let badge_width = text_width.max(badge_min_width);
914
915 let badge_rect = Rect::from_min_size(
916 Pos2::new(
917 item_rect.right() - ITEM_PADDING - badge_width,
918 item_rect.center().y - badge_height / 2.0,
919 ),
920 Vec2::new(badge_width, badge_height),
921 );
922
923 let bg_color = theme.muted().gamma_multiply(opacity);
925 let text_color = theme.muted_foreground().gamma_multiply(opacity);
926
927 painter.rect_filled(badge_rect, badge_height / 2.0, bg_color);
928 painter.text(
929 badge_rect.center(),
930 egui::Align2::CENTER_CENTER,
931 badge,
932 egui::FontId::proportional(11.0),
933 text_color,
934 );
935}