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    /// Expansion ratio 0.0 (collapsed) to 1.0 (expanded)
355    expansion_ratio: f32,
356    /// Base x-coordinate for icon placement (animated between centered and left-aligned)
357    icon_x_base: f32,
358    /// The rect where sidebar content is drawn (inside floating padding)
359    content_rect: Rect,
360}
361
362impl SidebarLayout {
363    /// Compute layout from current animation width and content rect.
364    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    /// Create a new sidebar with shadcn defaults
396    #[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    /// Use external state for controlled mode
410    ///
411    /// This allows you to control the sidebar from outside and persist state.
412    #[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    /// Set whether the sidebar starts collapsed (uncontrolled mode only)
419    #[must_use]
420    pub const fn collapsed(mut self, collapsed: bool) -> Self {
421        self.initial_open = !collapsed;
422        self
423    }
424
425    /// Set the collapsed width (default: 48px / 3rem)
426    #[must_use]
427    pub const fn collapsed_width(mut self, width: f32) -> Self {
428        self.collapsed_width = Some(width);
429        self
430    }
431
432    /// Set the expanded width (default: 256px / 16rem)
433    #[must_use]
434    pub const fn expanded_width(mut self, width: f32) -> Self {
435        self.expanded_width = Some(width);
436        self
437    }
438
439    /// Set the collapsible mode
440    #[must_use]
441    pub const fn collapsible(mut self, mode: CollapsibleMode) -> Self {
442        self.collapsible = mode;
443        self
444    }
445
446    /// Set whether to show icons
447    #[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    /// Set the visual variant
454    #[must_use]
455    pub const fn variant(mut self, variant: SidebarVariant) -> Self {
456        self.variant = variant;
457        self
458    }
459
460    /// Show the sidebar
461    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        // Get width bounds
470        let collapsed_width = self.collapsed_width.unwrap_or(SIDEBAR_WIDTH_ICON);
471        let expanded_width = self.expanded_width.unwrap_or(SIDEBAR_WIDTH);
472
473        // Handle state (controlled vs uncontrolled)
474        let sidebar_id = ui.id().with("sidebar_state");
475
476        // Get or create internal state for uncontrolled mode
477        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                    // Apply custom widths
482                    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() // Won't be used
494        };
495
496        // Get mutable reference to the actual state we're using
497        let state = self
498            .external_state
499            .as_deref_mut()
500            .map_or(&mut internal_state, |ext| ext);
501
502        // Update spring animation
503        state.width_spring.update(dt);
504
505        // Collect items from closure
506        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        // For floating/inset variants, add padding to the outer dimensions
519        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        // Use allocate_ui so items are laid out via egui's cursor — this makes
534        // the sidebar composable inside ScrollAreas (interaction rects follow scroll offset).
535        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                // Compute layout (needed for icon positions and expansion ratio)
544                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                // Top padding
554                ui.add_space(GROUP_PADDING + floating_padding);
555
556                // Draw toggle button if collapsible
557                if self.collapsible != CollapsibleMode::None {
558                    render_toggle_button_inline(ui, &theme, &layout, state, floating_padding);
559                }
560
561                // Draw all items via per-item allocation
562                (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        // Draw sidebar background behind all items
579        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        // Request repaint if animating
595        if state.is_animating() {
596            ui.ctx().request_repaint();
597        }
598
599        let is_expanded = state.open;
600
601        // Save internal state if using uncontrolled mode
602        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
625// ============================================================================
626// STANDALONE DRAWING & LAYOUT FUNCTIONS
627// ============================================================================
628
629/// Paint the sidebar background, border, and optional shadow.
630fn 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            // Shadow
648            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            // Background
654            painter.rect_filled(layout.content_rect, CORNER_RADIUS + 2.0, theme.sidebar());
655            // Border
656            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
666/// Render the collapse/expand toggle button using egui's cursor-based layout.
667fn 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        // Offset icon_x_base to account for the h_pad that shifts cursor
686        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
705/// Render all sidebar items using per-item allocation so scroll areas work correctly.
706fn 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        // Group labels — non-interactive
726        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        // Left border for sub-items
785        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        // Icon (offset icon_x_base by h_pad since cursor is already indented)
807        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
880/// Draw badge when collapsed (small indicator on icon)
881fn draw_collapsed_badge(
882    painter: &egui::Painter,
883    theme: &crate::Theme,
884    icon_center: Pos2,
885    _badge: &str,
886) {
887    // Draw a small dot indicator at top-right of icon
888    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    // Background circle (destructive color for notification feel)
895    painter.circle_filled(badge_pos, badge_radius, theme.destructive());
896    // Border
897    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    // Calculate text width more accurately
912    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    // Use a more visible background - muted foreground color
924    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}