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 collapsed_width: f32,
356 expanded_width: f32,
358 expansion_ratio: f32,
360 icon_x_base: f32,
362 content_rect: Rect,
364}
365
366impl SidebarLayout {
367 fn compute(
369 current_width: f32,
370 collapsed_width: f32,
371 expanded_width: f32,
372 content_rect: Rect,
373 ) -> Self {
374 let content_width = current_width;
375 let expansion_ratio = ((content_width - collapsed_width)
376 / (expanded_width - collapsed_width))
377 .clamp(0.0, 1.0);
378
379 let icon_left_aligned_x =
380 content_rect.left() + ITEM_PADDING + ITEM_PADDING + ICON_SIZE / 2.0;
381 let icon_centered_x = content_rect.left() + content_width / 2.0;
382 let icon_x_base = if expansion_ratio < 0.5 {
383 icon_centered_x
384 } else {
385 let t = (expansion_ratio - 0.5) * 2.0;
386 icon_centered_x + (icon_left_aligned_x - icon_centered_x) * t
387 };
388
389 Self {
390 content_width,
391 collapsed_width,
392 expanded_width,
393 expansion_ratio,
394 icon_x_base,
395 content_rect,
396 }
397 }
398}
399
400impl<'a> Sidebar<'a> {
401 #[must_use]
403 pub const fn new() -> Self {
404 Self {
405 external_state: None,
406 initial_open: true,
407 collapsed_width: None,
408 expanded_width: None,
409 collapsible: CollapsibleMode::Icon,
410 show_icons: true,
411 variant: SidebarVariant::Sidebar,
412 }
413 }
414
415 #[must_use]
419 pub const fn state(mut self, state: &'a mut SidebarState) -> Self {
420 self.external_state = Some(state);
421 self
422 }
423
424 #[must_use]
426 pub const fn collapsed(mut self, collapsed: bool) -> Self {
427 self.initial_open = !collapsed;
428 self
429 }
430
431 #[must_use]
433 pub const fn collapsed_width(mut self, width: f32) -> Self {
434 self.collapsed_width = Some(width);
435 self
436 }
437
438 #[must_use]
440 pub const fn expanded_width(mut self, width: f32) -> Self {
441 self.expanded_width = Some(width);
442 self
443 }
444
445 #[must_use]
447 pub const fn collapsible(mut self, mode: CollapsibleMode) -> Self {
448 self.collapsible = mode;
449 self
450 }
451
452 #[must_use]
454 pub const fn show_icons(mut self, show_icons: bool) -> Self {
455 self.show_icons = show_icons;
456 self
457 }
458
459 #[must_use]
461 pub const fn variant(mut self, variant: SidebarVariant) -> Self {
462 self.variant = variant;
463 self
464 }
465
466 pub fn show<R>(
468 mut self,
469 ui: &mut Ui,
470 content: impl FnOnce(&mut SidebarBuilder) -> R,
471 ) -> SidebarResponse {
472 let theme = ui.ctx().armas_theme();
473 let dt = ui.input(|i| i.stable_dt);
474
475 let collapsed_width = self.collapsed_width.unwrap_or(SIDEBAR_WIDTH_ICON);
477 let expanded_width = self.expanded_width.unwrap_or(SIDEBAR_WIDTH);
478
479 let sidebar_id = ui.id().with("sidebar_state");
481
482 let mut internal_state: SidebarState = if self.external_state.is_none() {
484 ui.ctx().data_mut(|d| {
485 d.get_temp(sidebar_id).unwrap_or_else(|| {
486 let mut state = SidebarState::new(self.initial_open);
487 let target = if self.initial_open {
489 expanded_width
490 } else {
491 collapsed_width
492 };
493 state.width_spring = SpringAnimation::new(target, target)
494 .params(SPRING_STIFFNESS, SPRING_DAMPING);
495 state
496 })
497 })
498 } else {
499 SidebarState::default() };
501
502 let state = self
504 .external_state
505 .as_deref_mut()
506 .map_or(&mut internal_state, |ext| ext);
507
508 state.width_spring.update(dt);
510
511 let mut items = Vec::new();
513 {
514 let mut builder = SidebarBuilder {
515 items: &mut items,
516 current_depth: 0,
517 expanded_groups: &mut state.expanded_groups,
518 };
519 content(&mut builder);
520 }
521
522 let current_width = state.width_spring.value;
523
524 let floating_padding = if matches!(
526 self.variant,
527 SidebarVariant::Floating | SidebarVariant::Inset
528 ) {
529 8.0
530 } else {
531 0.0
532 };
533
534 let total_height = calculate_content_height(&items, self.collapsible);
535
536 let outer_width = current_width + floating_padding * 2.0;
538 let outer_height = total_height + floating_padding * 2.0;
539
540 let rect = Rect::from_min_size(ui.cursor().min, Vec2::new(outer_width, outer_height));
541 ui.advance_cursor_after_rect(rect);
542
543 let mut clicked_id: Option<String> = None;
544 let mut hovered_index: Option<usize> = None;
545
546 if ui.is_rect_visible(rect) {
547 let content_rect = if floating_padding > 0.0 {
549 rect.shrink(floating_padding)
550 } else {
551 rect
552 };
553
554 let layout = SidebarLayout::compute(
555 current_width,
556 collapsed_width,
557 expanded_width,
558 content_rect,
559 );
560
561 render_background(ui, &theme, self.variant, rect, &layout);
563
564 let mut current_y = content_rect.top() + GROUP_PADDING;
566 if self.collapsible != CollapsibleMode::None {
567 current_y = render_toggle_button(ui, &theme, &layout, state, current_y);
568 }
569
570 (clicked_id, hovered_index) = render_items(
572 ui,
573 &theme,
574 self.show_icons,
575 &layout,
576 state,
577 &items,
578 current_y,
579 );
580 }
581
582 if state.is_animating() {
584 ui.ctx().request_repaint();
585 }
586
587 let is_expanded = state.open;
588
589 if self.external_state.is_none() {
591 ui.ctx().data_mut(|d| {
592 d.insert_temp(sidebar_id, internal_state);
593 });
594 }
595
596 let response = ui.interact(rect, ui.id().with("sidebar"), Sense::hover());
597
598 SidebarResponse {
599 response,
600 clicked: clicked_id,
601 hovered: hovered_index,
602 is_expanded,
603 }
604 }
605}
606
607impl Default for Sidebar<'_> {
608 fn default() -> Self {
609 Self::new()
610 }
611}
612
613fn calculate_content_height(items: &[InternalSidebarItem], collapsible: CollapsibleMode) -> f32 {
619 let mut total_height = GROUP_PADDING;
620
621 if collapsible != CollapsibleMode::None {
622 total_height += ITEM_HEIGHT + ITEM_GAP;
623 }
624
625 for item in items {
626 if item.is_group_label {
627 total_height += ITEM_HEIGHT + ITEM_GAP;
628 } else if item.depth > 0 {
629 total_height += ITEM_HEIGHT_SM + ITEM_GAP;
630 } else {
631 total_height += ITEM_HEIGHT + ITEM_GAP;
632 }
633 }
634
635 total_height += GROUP_PADDING;
636 total_height
637}
638
639fn render_background(
641 ui: &Ui,
642 theme: &crate::Theme,
643 variant: SidebarVariant,
644 rect: Rect,
645 layout: &SidebarLayout,
646) {
647 let painter = ui.painter();
648 match variant {
649 SidebarVariant::Sidebar => {
650 painter.rect_filled(rect, 0.0, theme.sidebar());
651 painter.line_segment(
652 [rect.right_top(), rect.right_bottom()],
653 Stroke::new(1.0, theme.border()),
654 );
655 }
656 SidebarVariant::Floating | SidebarVariant::Inset => {
657 painter.rect_filled(
659 layout.content_rect.translate(Vec2::new(0.0, 2.0)),
660 CORNER_RADIUS + 2.0,
661 Color32::from_black_alpha(20),
662 );
663 painter.rect_filled(layout.content_rect, CORNER_RADIUS + 2.0, theme.sidebar());
665 painter.rect_stroke(
667 layout.content_rect,
668 CORNER_RADIUS + 2.0,
669 Stroke::new(1.0, theme.sidebar_border()),
670 egui::StrokeKind::Inside,
671 );
672 }
673 }
674}
675
676fn render_toggle_button(
679 ui: &mut Ui,
680 theme: &crate::Theme,
681 layout: &SidebarLayout,
682 state: &mut SidebarState,
683 current_y: f32,
684) -> f32 {
685 let toggle_rect = Rect::from_min_size(
686 Pos2::new(layout.content_rect.left() + ITEM_PADDING, current_y),
687 Vec2::new(layout.content_width - ITEM_PADDING * 2.0, ITEM_HEIGHT),
688 );
689
690 let toggle_response = ui.interact(
691 toggle_rect,
692 ui.id().with("toggle"),
693 Sense::click().union(Sense::hover()),
694 );
695
696 if toggle_response.clicked() {
697 state.toggle();
698 }
699
700 let painter = ui.painter();
701
702 if toggle_response.hovered() {
703 painter.rect_filled(toggle_rect, CORNER_RADIUS, theme.sidebar_accent());
704 }
705
706 painter.text(
707 Pos2::new(layout.icon_x_base, toggle_rect.center().y),
708 egui::Align2::CENTER_CENTER,
709 "☰",
710 egui::FontId::proportional(ICON_SIZE),
711 if toggle_response.hovered() {
712 theme.sidebar_accent_foreground()
713 } else {
714 theme.sidebar_foreground()
715 },
716 );
717
718 current_y + ITEM_HEIGHT + ITEM_GAP
719}
720
721fn render_items(
723 ui: &mut Ui,
724 theme: &crate::Theme,
725 show_icons: bool,
726 layout: &SidebarLayout,
727 state: &mut SidebarState,
728 items: &[InternalSidebarItem],
729 mut current_y: f32,
730) -> (Option<String>, Option<usize>) {
731 let mut clicked_id: Option<String> = None;
732 let mut hovered_index: Option<usize> = None;
733
734 for (index, item) in items.iter().enumerate() {
735 let item_height = if item.is_group_label {
736 ITEM_HEIGHT
737 } else if item.depth > 0 {
738 ITEM_HEIGHT_SM
739 } else {
740 ITEM_HEIGHT
741 };
742
743 if item.is_group_label {
745 let widths = AnimationWidths {
746 current: layout.content_width,
747 collapsed: layout.collapsed_width,
748 expanded: layout.expanded_width,
749 };
750 draw_group_label(
751 ui.painter(),
752 theme,
753 &layout.content_rect,
754 current_y,
755 &widths,
756 &item.label,
757 );
758 current_y += item_height + ITEM_GAP;
759 continue;
760 }
761
762 let indent = if item.depth > 0 {
764 14.0 + (item.depth - 1) as f32 * 12.0
765 } else {
766 0.0
767 };
768
769 let item_rect = Rect::from_min_size(
770 Pos2::new(
771 layout.content_rect.left() + ITEM_PADDING + indent,
772 current_y,
773 ),
774 Vec2::new(
775 layout.content_width - ITEM_PADDING * 2.0 - indent,
776 item_height,
777 ),
778 );
779
780 if item.depth > 0 {
782 let border_x = layout.content_rect.left() + ITEM_PADDING + 14.0;
783 ui.painter().line_segment(
784 [
785 Pos2::new(border_x, current_y),
786 Pos2::new(border_x, current_y + item_height),
787 ],
788 Stroke::new(1.0, theme.sidebar_border()),
789 );
790 }
791
792 let item_response = ui.interact(
793 item_rect,
794 ui.id().with(&item.id),
795 Sense::click().union(Sense::hover()),
796 );
797
798 if item_response.hovered() {
799 hovered_index = Some(index);
800 }
801
802 if item_response.clicked() {
803 if item.is_group_header {
804 let was_expanded = state
805 .expanded_groups
806 .get(&item.id)
807 .copied()
808 .unwrap_or(false);
809 state.expanded_groups.insert(item.id.clone(), !was_expanded);
810 } else {
811 clicked_id = Some(item.id.clone());
812 state.active_index = Some(index);
813 }
814 }
815
816 let is_active = item.active || state.active_index == Some(index);
817 let is_hovered = item_response.hovered();
818
819 let painter = ui.painter();
820
821 if is_active || is_hovered {
822 painter.rect_filled(item_rect, CORNER_RADIUS, theme.sidebar_accent());
823 }
824
825 let text_color = if is_active || is_hovered {
826 theme.sidebar_accent_foreground()
827 } else {
828 theme.sidebar_foreground()
829 };
830
831 let icon_center = if show_icons && !item.icon.is_empty() {
834 let item_icon_x = if item.depth > 0 {
836 item_rect.left() + ITEM_PADDING + ICON_SIZE / 2.0
838 } else {
839 layout.icon_x_base
841 };
842 painter.text(
843 Pos2::new(item_icon_x, item_rect.center().y),
844 egui::Align2::CENTER_CENTER,
845 &item.icon,
846 egui::FontId::proportional(ICON_SIZE),
847 text_color,
848 );
849 Some(Pos2::new(item_icon_x, item_rect.center().y))
850 } else {
851 None
852 };
853
854 if layout.expansion_ratio > 0.3 {
855 let label_opacity = ((layout.expansion_ratio - 0.3) / 0.7).clamp(0.0, 1.0);
856 let label_color = Color32::from_rgba_unmultiplied(
857 text_color.r(),
858 text_color.g(),
859 text_color.b(),
860 (f32::from(text_color.a()) * label_opacity) as u8,
861 );
862
863 let label_x = if show_icons && !item.icon.is_empty() {
864 item_rect.left() + ITEM_PADDING + ICON_SIZE + 8.0
865 } else {
866 item_rect.left() + ITEM_PADDING
867 };
868
869 let font = if is_active {
870 egui::FontId::new(14.0, egui::FontFamily::Proportional)
871 } else {
872 egui::FontId::proportional(14.0)
873 };
874
875 painter.text(
876 Pos2::new(label_x, item_rect.center().y),
877 egui::Align2::LEFT_CENTER,
878 &item.label,
879 font,
880 label_color,
881 );
882
883 if item.is_group_header {
884 let is_group_expanded = state
885 .expanded_groups
886 .get(&item.id)
887 .copied()
888 .unwrap_or(false);
889 let chevron = if is_group_expanded { "▼" } else { "▶" };
890 painter.text(
891 Pos2::new(item_rect.right() - ITEM_PADDING - 8.0, item_rect.center().y),
892 egui::Align2::CENTER_CENTER,
893 chevron,
894 egui::FontId::proportional(10.0),
895 label_color.gamma_multiply(0.7),
896 );
897 }
898
899 if let Some(badge) = &item.badge {
900 if !item.is_group_header {
901 draw_badge(painter, theme, &item_rect, badge, label_opacity);
902 }
903 }
904 } else if let Some(badge) = &item.badge {
905 if !item.is_group_header {
907 if let Some(icon_pos) = icon_center {
908 draw_collapsed_badge(painter, theme, icon_pos, badge);
909 }
910 }
911 }
912
913 current_y += item_height + ITEM_GAP;
914 }
915
916 (clicked_id, hovered_index)
917}
918
919struct AnimationWidths {
921 current: f32,
922 collapsed: f32,
923 expanded: f32,
924}
925
926fn draw_group_label(
927 painter: &egui::Painter,
928 theme: &crate::Theme,
929 content_rect: &Rect,
930 y: f32,
931 widths: &AnimationWidths,
932 label: &str,
933) {
934 let expansion_ratio = ((widths.current - widths.collapsed)
935 / (widths.expanded - widths.collapsed))
936 .clamp(0.0, 1.0);
937
938 if expansion_ratio > 0.5 {
939 let opacity = ((expansion_ratio - 0.5) / 0.5).clamp(0.0, 1.0);
940 let color = theme.sidebar_foreground().gamma_multiply(0.7 * opacity);
941
942 painter.text(
943 Pos2::new(content_rect.left() + ITEM_PADDING, y + ITEM_HEIGHT / 2.0),
944 egui::Align2::LEFT_CENTER,
945 label,
946 egui::FontId::proportional(12.0),
947 color,
948 );
949 }
950}
951
952fn draw_collapsed_badge(
954 painter: &egui::Painter,
955 theme: &crate::Theme,
956 icon_center: Pos2,
957 _badge: &str,
958) {
959 let badge_pos = Pos2::new(
961 icon_center.x + ICON_SIZE / 2.0 - 2.0,
962 icon_center.y - ICON_SIZE / 2.0 + 2.0,
963 );
964 let badge_radius = 4.0;
965
966 painter.circle_filled(badge_pos, badge_radius, theme.destructive());
968 painter.circle_stroke(badge_pos, badge_radius, Stroke::new(1.0, theme.sidebar()));
970}
971
972fn draw_badge(
973 painter: &egui::Painter,
974 theme: &crate::Theme,
975 item_rect: &Rect,
976 badge: &str,
977 opacity: f32,
978) {
979 let badge_height = 18.0;
980 let badge_padding_x = 6.0;
981 let badge_min_width = 18.0;
982
983 let text_width = badge.len() as f32 * 6.0 + badge_padding_x * 2.0;
985 let badge_width = text_width.max(badge_min_width);
986
987 let badge_rect = Rect::from_min_size(
988 Pos2::new(
989 item_rect.right() - ITEM_PADDING - badge_width,
990 item_rect.center().y - badge_height / 2.0,
991 ),
992 Vec2::new(badge_width, badge_height),
993 );
994
995 let bg_color = theme.muted().gamma_multiply(opacity);
997 let text_color = theme.muted_foreground().gamma_multiply(opacity);
998
999 painter.rect_filled(badge_rect, badge_height / 2.0, bg_color);
1000 painter.text(
1001 badge_rect.center(),
1002 egui::Align2::CENTER_CENTER,
1003 badge,
1004 egui::FontId::proportional(11.0),
1005 text_color,
1006 );
1007}