Skip to main content

armas_basic/components/
breadcrumb.rs

1//! Breadcrumb Component
2//!
3//! Navigation path indicator styled like shadcn/ui Breadcrumb.
4//! Shows the current location in a hierarchy with clickable navigation items.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! # use egui::Ui;
10//! # fn example(ui: &mut Ui) {
11//! use armas_basic::Breadcrumb;
12//!
13//! Breadcrumb::new()
14//!     .show(ui, |breadcrumbs| {
15//!         breadcrumbs.item("Home", None);
16//!         breadcrumbs.item("Projects", None);
17//!         breadcrumbs.item("Armas", None).current();
18//!     });
19//! # }
20//! ```
21
22use crate::ext::ArmasContextExt;
23use egui::{Sense, Ui};
24
25// shadcn Breadcrumb constants
26const ITEM_GAP: f32 = 6.0; // gap-1.5
27const SEPARATOR_SIZE: f32 = 14.0; // size-3.5
28
29/// Breadcrumb navigation component
30///
31/// Shows a navigation path with clickable items, styled like shadcn/ui.
32///
33/// # Example
34///
35/// ```rust,no_run
36/// # use egui::Ui;
37/// # fn example(ui: &mut Ui) {
38/// use armas_basic::Breadcrumb;
39///
40/// Breadcrumb::new()
41///     .show(ui, |breadcrumbs| {
42///         breadcrumbs.item("Home", None);
43///         breadcrumbs.item("Projects", None);
44///         breadcrumbs.item("Armas", None).current();
45///     });
46/// # }
47/// ```
48pub struct Breadcrumb {
49    spacing: f32,
50}
51
52impl Breadcrumb {
53    /// Create a new breadcrumbs component
54    #[must_use]
55    pub const fn new() -> Self {
56        Self { spacing: ITEM_GAP }
57    }
58
59    /// Set spacing between items (default: 6.0)
60    #[must_use]
61    pub const fn spacing(mut self, spacing: f32) -> Self {
62        self.spacing = spacing;
63        self
64    }
65
66    /// Show the breadcrumbs with closure-based API
67    pub fn show<R>(
68        self,
69        ui: &mut Ui,
70        content: impl FnOnce(&mut BreadcrumbBuilder) -> R,
71    ) -> BreadcrumbResponse {
72        let mut clicked: Option<usize> = None;
73
74        let inner_response = ui.horizontal(|ui| {
75            ui.spacing_mut().item_spacing.x = self.spacing;
76
77            let mut builder = BreadcrumbBuilder {
78                ui,
79                spacing: self.spacing,
80                item_index: 0,
81                clicked: &mut clicked,
82            };
83
84            content(&mut builder);
85        });
86
87        BreadcrumbResponse {
88            response: inner_response.response,
89            clicked,
90        }
91    }
92}
93
94impl Default for Breadcrumb {
95    fn default() -> Self {
96        Self::new()
97    }
98}
99
100/// Builder for adding breadcrumb items
101pub struct BreadcrumbBuilder<'a> {
102    ui: &'a mut Ui,
103    spacing: f32,
104    item_index: usize,
105    clicked: &'a mut Option<usize>,
106}
107
108impl BreadcrumbBuilder<'_> {
109    /// Add a breadcrumb item with optional icon
110    pub fn item(&mut self, label: &str, icon: Option<&str>) -> ItemBuilder<'_> {
111        let theme = self.ui.ctx().armas_theme();
112
113        // Show separator before this item (if not first)
114        if self.item_index > 0 {
115            // ChevronRight separator - shadcn uses lucide ChevronRight at size-3.5
116            self.ui.add_space(self.spacing);
117
118            // Draw chevron right icon
119            let (rect, _) = self
120                .ui
121                .allocate_exact_size(egui::vec2(SEPARATOR_SIZE, SEPARATOR_SIZE), Sense::hover());
122
123            if self.ui.is_rect_visible(rect) {
124                let painter = self.ui.painter();
125                let color = theme.muted_foreground();
126                let stroke = egui::Stroke::new(1.5, color);
127
128                // Draw > shape
129                let center = rect.center();
130                let half = SEPARATOR_SIZE * 0.2;
131                painter.line_segment(
132                    [
133                        egui::pos2(center.x - half, center.y - half * 1.5),
134                        egui::pos2(center.x + half, center.y),
135                    ],
136                    stroke,
137                );
138                painter.line_segment(
139                    [
140                        egui::pos2(center.x + half, center.y),
141                        egui::pos2(center.x - half, center.y + half * 1.5),
142                    ],
143                    stroke,
144                );
145            }
146
147            self.ui.add_space(self.spacing);
148        }
149
150        let item_builder = ItemBuilder {
151            ui: self.ui,
152            label: label.to_string(),
153            icon: icon.map(std::string::ToString::to_string),
154            is_current: false,
155            item_index: self.item_index,
156            clicked: self.clicked,
157            rendered: false,
158        };
159
160        self.item_index += 1;
161        item_builder
162    }
163}
164
165/// Builder for chaining item modifiers
166pub struct ItemBuilder<'a> {
167    ui: &'a mut Ui,
168    label: String,
169    icon: Option<String>,
170    is_current: bool,
171    item_index: usize,
172    clicked: &'a mut Option<usize>,
173    rendered: bool,
174}
175
176impl ItemBuilder<'_> {
177    /// Mark this item as the current/active item (non-clickable)
178    #[must_use]
179    pub const fn current(mut self) -> Self {
180        self.is_current = true;
181        self
182    }
183
184    fn render(&mut self) {
185        if self.rendered {
186            return;
187        }
188        self.rendered = true;
189
190        let theme = self.ui.ctx().armas_theme();
191
192        // Build label with optional icon
193        let display_label = if let Some(icon) = &self.icon {
194            format!("{} {}", icon, self.label)
195        } else {
196            self.label.clone()
197        };
198
199        // Current item: text-foreground font-normal (non-clickable)
200        if self.is_current {
201            self.ui.label(
202                egui::RichText::new(&display_label)
203                    .size(theme.typography.base)
204                    .color(theme.foreground()),
205            );
206        } else {
207            // Clickable items: text-muted-foreground, hover:text-foreground
208            let response = self.ui.add(
209                egui::Label::new(
210                    egui::RichText::new(&display_label)
211                        .size(theme.typography.base)
212                        .color(theme.muted_foreground()),
213                )
214                .sense(Sense::click()),
215            );
216
217            // Apply hover color
218            if response.hovered() {
219                // Re-render with foreground color on hover
220                let rect = response.rect;
221                self.ui
222                    .painter()
223                    .rect_filled(rect, 0.0, egui::Color32::TRANSPARENT);
224                self.ui.painter().text(
225                    rect.left_center(),
226                    egui::Align2::LEFT_CENTER,
227                    &display_label,
228                    egui::FontId::proportional(theme.typography.base),
229                    theme.foreground(),
230                );
231            }
232
233            if response.clicked() {
234                *self.clicked = Some(self.item_index);
235            }
236        }
237    }
238}
239
240impl Drop for ItemBuilder<'_> {
241    fn drop(&mut self) {
242        self.render();
243    }
244}
245
246/// Response from breadcrumbs
247pub struct BreadcrumbResponse {
248    /// The UI response
249    pub response: egui::Response,
250    /// Index of clicked item (if any)
251    pub clicked: Option<usize>,
252}