Skip to main content

armas_basic/components/
sidebar.rs

1//! Sidebar Component
2//!
3//! Animated sidebar styled like shadcn/ui Sidebar.
4//! Features smooth spring-based expand/collapse animations, multiple variants,
5//! group labels, collapsible groups, badges, and icon-only collapsed mode.
6//!
7//! # Example
8//!
9//! ```rust,no_run
10//! # use egui::Ui;
11//! # fn example(ui: &mut Ui) {
12//! use armas_basic::components::{Sidebar, SidebarState, SidebarVariant};
13//!
14//! // Controlled mode with external state
15//! let mut state = SidebarState::new(true);
16//!
17//! Sidebar::new()
18//!     .state(&mut state)
19//!     .variant(SidebarVariant::Floating)
20//!     .show(ui, |sidebar| {
21//!         sidebar.group_label("Platform");
22//!         sidebar.item("Home", "Home").active(true);
23//!         sidebar.item("Messages", "Messages").badge("5");
24//!         sidebar.group("Settings", "Settings", |group| {
25//!             group.item("Profile", "Profile");
26//!             group.item("Security", "Security");
27//!         });
28//!     });
29//! # }
30//! ```
31
32use crate::animation::SpringAnimation;
33use crate::ext::ArmasContextExt;
34use egui::{Color32, Pos2, Rect, Response, Sense, Stroke, Ui, Vec2};
35
36// shadcn sidebar dimensions
37const SIDEBAR_WIDTH: f32 = 256.0; // 16rem
38const SIDEBAR_WIDTH_ICON: f32 = 48.0; // 3rem
39
40// shadcn item dimensions
41const ITEM_HEIGHT: f32 = 32.0; // h-8
42const ITEM_HEIGHT_SM: f32 = 28.0; // h-7 for sub-items
43const ITEM_GAP: f32 = 4.0; // gap-1
44const ITEM_PADDING: f32 = 8.0; // p-2
45const ICON_SIZE: f32 = 16.0; // size-4
46const CORNER_RADIUS: f32 = 6.0; // rounded-md
47const GROUP_PADDING: f32 = 8.0; // p-2 for groups
48
49// Spring animation parameters (snappy but smooth)
50const SPRING_STIFFNESS: f32 = 300.0;
51const SPRING_DAMPING: f32 = 25.0;
52
53// ============================================================================
54// SIDEBAR STATE (for external control)
55// ============================================================================
56
57/// Sidebar state that can be stored externally for controlled mode
58#[derive(Clone, Debug)]
59pub struct SidebarState {
60    /// Whether the sidebar is expanded
61    pub open: bool,
62    /// Width spring animation
63    width_spring: SpringAnimation,
64    /// Expanded groups
65    expanded_groups: std::collections::HashMap<String, bool>,
66    /// Currently active item index
67    active_index: Option<usize>,
68}
69
70impl Default for SidebarState {
71    fn default() -> Self {
72        Self::new(true)
73    }
74}
75
76impl SidebarState {
77    /// Create new sidebar state
78    #[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    /// Toggle the sidebar open/closed
95    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    /// Set the sidebar open state
106    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    /// Check if sidebar is expanded
119    #[must_use]
120    pub const fn is_open(&self) -> bool {
121        self.open
122    }
123
124    /// Get current animated width
125    #[must_use]
126    pub const fn width(&self) -> f32 {
127        self.width_spring.value
128    }
129
130    /// Check if animation is still running
131    #[must_use]
132    pub fn is_animating(&self) -> bool {
133        !self.width_spring.is_settled(0.5, 0.5)
134    }
135}
136
137// ============================================================================
138// SIDEBAR VARIANT
139// ============================================================================
140
141/// Sidebar visual variant
142#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
143pub enum SidebarVariant {
144    /// Standard sidebar with border
145    #[default]
146    Sidebar,
147    /// Floating sidebar with rounded corners and shadow
148    Floating,
149    /// Inset sidebar (similar to floating but for inset layouts)
150    Inset,
151}
152
153/// Collapsible mode for the sidebar
154#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
155pub enum CollapsibleMode {
156    /// Collapse to icon-only view
157    #[default]
158    Icon,
159    /// Slide completely off screen
160    Offcanvas,
161    /// Not collapsible
162    None,
163}
164
165// ============================================================================
166// INTERNAL TYPES
167// ============================================================================
168
169/// Internal representation of a sidebar item for rendering
170#[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
182/// Builder for configuring individual sidebar items
183///
184/// This holds a mutable reference to the item in the list, so modifications
185/// like `.active()` and `.badge()` are applied directly.
186pub struct SidebarItemBuilder<'a> {
187    item: &'a mut InternalSidebarItem,
188}
189
190impl SidebarItemBuilder<'_> {
191    /// Mark this item as active
192    #[must_use]
193    pub const fn active(self, active: bool) -> Self {
194        self.item.active = active;
195        self
196    }
197
198    /// Set badge text (e.g., notification count)
199    #[must_use]
200    pub fn badge(self, badge: impl Into<String>) -> Self {
201        self.item.badge = Some(badge.into());
202        self
203    }
204}
205
206/// Builder for adding items to the sidebar
207pub 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    /// Add an item to the sidebar
215    ///
216    /// Returns a builder that can be used to configure the item with chained methods.
217    /// The builder holds a mutable reference to the item, so changes like `.badge()` and `.active()`
218    /// are applied directly to the item in the list.
219    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    /// Add a group label (non-interactive header text)
239    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    /// Add a collapsible group with nested items
255    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        // Get expanded state
259        let is_expanded = self
260            .expanded_groups
261            .get(&group_id)
262            .copied()
263            .unwrap_or(false);
264
265        // Add the group header as an item
266        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 expanded, add nested items with increased depth
279        if is_expanded {
280            self.current_depth += 1;
281            content(self);
282            self.current_depth -= 1;
283        }
284    }
285}
286
287/// Response from sidebar interaction
288pub struct SidebarResponse {
289    /// The overall sidebar response
290    pub response: Response,
291    /// ID of the clicked item, if any
292    pub clicked: Option<String>,
293    /// Index of the hovered item, if any
294    pub hovered: Option<usize>,
295    /// Whether the sidebar is currently expanded
296    pub is_expanded: bool,
297}
298
299/// Animated sidebar component styled to match shadcn/ui
300///
301/// Supports both controlled mode (with external `SidebarState`) and uncontrolled mode.
302///
303/// # Controlled Mode Example
304///
305/// ```ignore
306/// // Store state somewhere persistent
307/// let mut sidebar_state = SidebarState::new(true);
308///
309/// // In your UI code:
310/// Sidebar::new()
311///     .state(&mut sidebar_state)
312///     .show(ui, |sidebar| {
313///         sidebar.item("Home", "Home").active(true);
314///     });
315///
316/// // Toggle from anywhere:
317/// if some_button_clicked {
318///     sidebar_state.toggle();
319/// }
320/// ```
321///
322/// # Uncontrolled Mode Example
323///
324/// ```ignore
325/// Sidebar::new()
326///     .collapsed(false)
327///     .show(ui, |sidebar| {
328///         sidebar.group_label("Platform");
329///         sidebar.item("Home", "Home").active(true);
330///         sidebar.item("Messages", "Messages").badge("5");
331///     })
332/// ```
333pub struct Sidebar<'a> {
334    /// External state for controlled mode
335    external_state: Option<&'a mut SidebarState>,
336    /// Initial expanded state (for uncontrolled mode)
337    initial_open: bool,
338    /// Collapsed width override
339    collapsed_width: Option<f32>,
340    /// Expanded width override
341    expanded_width: Option<f32>,
342    /// Collapsible mode
343    collapsible: CollapsibleMode,
344    /// Show icons
345    show_icons: bool,
346    /// Visual variant
347    variant: SidebarVariant,
348}
349
350/// Pre-computed layout values shared across sidebar rendering helpers.
351struct SidebarLayout {
352    /// The animated content width
353    content_width: f32,
354    /// Width when collapsed
355    collapsed_width: f32,
356    /// Width when expanded
357    expanded_width: f32,
358    /// Expansion ratio 0.0 (collapsed) to 1.0 (expanded)
359    expansion_ratio: f32,
360    /// Base x-coordinate for icon placement (animated between centered and left-aligned)
361    icon_x_base: f32,
362    /// The rect where sidebar content is drawn (inside floating padding)
363    content_rect: Rect,
364}
365
366impl SidebarLayout {
367    /// Compute layout from current animation width and content rect.
368    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    /// Create a new sidebar with shadcn defaults
402    #[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    /// Use external state for controlled mode
416    ///
417    /// This allows you to control the sidebar from outside and persist state.
418    #[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    /// Set whether the sidebar starts collapsed (uncontrolled mode only)
425    #[must_use]
426    pub const fn collapsed(mut self, collapsed: bool) -> Self {
427        self.initial_open = !collapsed;
428        self
429    }
430
431    /// Set the collapsed width (default: 48px / 3rem)
432    #[must_use]
433    pub const fn collapsed_width(mut self, width: f32) -> Self {
434        self.collapsed_width = Some(width);
435        self
436    }
437
438    /// Set the expanded width (default: 256px / 16rem)
439    #[must_use]
440    pub const fn expanded_width(mut self, width: f32) -> Self {
441        self.expanded_width = Some(width);
442        self
443    }
444
445    /// Set the collapsible mode
446    #[must_use]
447    pub const fn collapsible(mut self, mode: CollapsibleMode) -> Self {
448        self.collapsible = mode;
449        self
450    }
451
452    /// Set whether to show icons
453    #[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    /// Set the visual variant
460    #[must_use]
461    pub const fn variant(mut self, variant: SidebarVariant) -> Self {
462        self.variant = variant;
463        self
464    }
465
466    /// Show the sidebar
467    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        // Get width bounds
476        let collapsed_width = self.collapsed_width.unwrap_or(SIDEBAR_WIDTH_ICON);
477        let expanded_width = self.expanded_width.unwrap_or(SIDEBAR_WIDTH);
478
479        // Handle state (controlled vs uncontrolled)
480        let sidebar_id = ui.id().with("sidebar_state");
481
482        // Get or create internal state for uncontrolled mode
483        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                    // Apply custom widths
488                    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() // Won't be used
500        };
501
502        // Get mutable reference to the actual state we're using
503        let state = self
504            .external_state
505            .as_deref_mut()
506            .map_or(&mut internal_state, |ext| ext);
507
508        // Update spring animation
509        state.width_spring.update(dt);
510
511        // Collect items from closure
512        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        // For floating/inset variants, add padding to the outer dimensions
525        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        // Add padding to outer rect for floating variants
537        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            // Content rect is where items are drawn
548            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            // Draw sidebar background
562            render_background(ui, &theme, self.variant, rect, &layout);
563
564            // Draw toggle button if collapsible
565            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            // Draw all items
571            (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        // Request repaint if animating
583        if state.is_animating() {
584            ui.ctx().request_repaint();
585        }
586
587        let is_expanded = state.open;
588
589        // Save internal state if using uncontrolled mode
590        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
613// ============================================================================
614// STANDALONE DRAWING & LAYOUT FUNCTIONS
615// ============================================================================
616
617/// Calculate total content height for the sidebar based on its items.
618fn 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
639/// Paint the sidebar background, border, and optional shadow.
640fn 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            // Shadow
658            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            // Background
664            painter.rect_filled(layout.content_rect, CORNER_RADIUS + 2.0, theme.sidebar());
665            // Border
666            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
676/// Render the collapse/expand toggle button. Returns the new `current_y`
677/// after drawing the button (advanced by one item height + gap).
678fn 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
721/// Render all sidebar items. Returns `(clicked_id, hovered_index)`.
722fn 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        // Group labels
744        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        // Calculate indent for sub-items
763        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        // Draw left border for sub-items
781        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        // Draw icon using the same position as toggle for consistency
832        // Adjust for indent if this is a sub-item
833        let icon_center = if show_icons && !item.icon.is_empty() {
834            // For sub-items, offset from base position
835            let item_icon_x = if item.depth > 0 {
836                // Sub-items: always left-aligned with indent
837                item_rect.left() + ITEM_PADDING + ICON_SIZE / 2.0
838            } else {
839                // Top-level items: use same animated position as toggle
840                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            // When collapsed, show badge indicator on icon
906            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
919/// Width parameters for sidebar animation
920struct 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
952/// Draw badge when collapsed (small indicator on icon)
953fn draw_collapsed_badge(
954    painter: &egui::Painter,
955    theme: &crate::Theme,
956    icon_center: Pos2,
957    _badge: &str,
958) {
959    // Draw a small dot indicator at top-right of icon
960    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    // Background circle (destructive color for notification feel)
967    painter.circle_filled(badge_pos, badge_radius, theme.destructive());
968    // Border
969    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    // Calculate text width more accurately
984    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    // Use a more visible background - muted foreground color
996    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}