Skip to main content

armas_basic/components/
badge.rs

1//! Badge Component
2//!
3//! Small status indicator styled like shadcn/ui Badge.
4//! Provides variants for different contexts:
5//! - Default (primary colored)
6//! - Secondary (muted)
7//! - Destructive (red)
8//! - Outline (border only)
9
10use super::content::ContentContext;
11use crate::ext::ArmasContextExt;
12use crate::Theme;
13use egui::{Color32, Pos2, Response, Ui, Vec2};
14
15// shadcn Badge constants
16const CORNER_RADIUS: f32 = 9999.0; // rounded-full (pill shape)
17const PADDING_X: f32 = 10.0; // px-2.5
18const PADDING_Y: f32 = 2.0; // py-0.5
19                            // Font size resolved from theme.typography.sm at show-time
20
21/// Badge variant styles (shadcn/ui)
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum BadgeVariant {
24    /// Primary background (default)
25    #[default]
26    Default,
27    /// Secondary/muted background
28    Secondary,
29    /// Destructive/error style
30    Destructive,
31    /// Outline only
32    Outline,
33}
34
35// Backwards compatibility aliases
36#[allow(non_upper_case_globals)]
37impl BadgeVariant {
38    /// Alias for Default (backwards compatibility)
39    pub const Filled: Self = Self::Default;
40    /// Alias for Outline (backwards compatibility)
41    pub const Outlined: Self = Self::Outline;
42    /// Alias for Secondary (backwards compatibility)
43    pub const Soft: Self = Self::Secondary;
44}
45
46/// Small status indicator badge styled like shadcn/ui
47///
48/// # Example
49///
50/// ```rust,no_run
51/// use armas_basic::components::{Badge, BadgeVariant};
52///
53/// fn ui(ui: &mut egui::Ui) {
54///     // Default badge
55///     Badge::new("New").show(ui);
56///
57///     // Secondary badge
58///     Badge::new("Draft").variant(BadgeVariant::Secondary).show(ui);
59///
60///     // Destructive badge
61///     Badge::new("Error").variant(BadgeVariant::Destructive).show(ui);
62///
63///     // Outline badge
64///     Badge::new("Outline").variant(BadgeVariant::Outline).show(ui);
65/// }
66/// ```
67pub struct Badge {
68    text: String,
69    variant: BadgeVariant,
70    custom_color: Option<Color32>,
71    show_dot: bool,
72    removable: bool,
73    is_selected: bool,
74    custom_font_size: Option<f32>,
75    custom_corner_radius: Option<f32>,
76    custom_vertical_padding: Option<f32>,
77    custom_height: Option<f32>,
78    min_width: Option<f32>,
79}
80
81impl Badge {
82    /// Create a new badge
83    pub fn new(text: impl Into<String>) -> Self {
84        Self {
85            text: text.into(),
86            variant: BadgeVariant::default(),
87            custom_color: None,
88            show_dot: false,
89            removable: false,
90            is_selected: false,
91            custom_font_size: None,
92            custom_corner_radius: None,
93            custom_vertical_padding: None,
94            custom_height: None,
95            min_width: None,
96        }
97    }
98
99    /// Set badge variant
100    #[must_use]
101    pub const fn variant(mut self, variant: BadgeVariant) -> Self {
102        self.variant = variant;
103        self
104    }
105
106    /// Set custom color (overrides variant colors)
107    #[must_use]
108    pub const fn color(mut self, color: Color32) -> Self {
109        self.custom_color = Some(color);
110        self
111    }
112
113    /// Make this a destructive badge (shorthand)
114    #[must_use]
115    pub const fn destructive(mut self) -> Self {
116        self.variant = BadgeVariant::Destructive;
117        self
118    }
119
120    /// Show dot indicator
121    #[must_use]
122    pub const fn dot(mut self) -> Self {
123        self.show_dot = true;
124        self
125    }
126
127    /// Set text size
128    #[must_use]
129    pub const fn size(mut self, size: f32) -> Self {
130        self.custom_font_size = Some(size);
131        self
132    }
133
134    /// Make badge removable
135    #[must_use]
136    pub const fn removable(mut self) -> Self {
137        self.removable = true;
138        self
139    }
140
141    /// Set corner radius
142    #[must_use]
143    pub const fn corner_radius(mut self, radius: f32) -> Self {
144        self.custom_corner_radius = Some(radius);
145        self
146    }
147
148    /// Set vertical padding
149    #[must_use]
150    pub const fn vertical_padding(mut self, padding: f32) -> Self {
151        self.custom_vertical_padding = Some(padding);
152        self
153    }
154
155    /// Set explicit height (overrides computed height)
156    #[must_use]
157    pub const fn height(mut self, height: f32) -> Self {
158        self.custom_height = Some(height);
159        self
160    }
161
162    /// Set minimum width
163    #[must_use]
164    pub const fn min_width(mut self, width: f32) -> Self {
165        self.min_width = Some(width);
166        self
167    }
168
169    /// Set selected state (for interactive badge use)
170    #[must_use]
171    pub const fn selected(mut self, selected: bool) -> Self {
172        self.is_selected = selected;
173        self
174    }
175
176    /// Show the badge
177    pub fn show(self, ui: &mut Ui) -> BadgeResponse {
178        let theme = ui.ctx().armas_theme();
179        let (bg_color, text_color, border_color) = self.get_colors(&theme);
180
181        // Resolve effective values (custom overrides or defaults)
182        let font_size = self.custom_font_size.unwrap_or(theme.typography.sm);
183        let corner_radius = self.custom_corner_radius.unwrap_or(CORNER_RADIUS);
184        let padding_y = self.custom_vertical_padding.unwrap_or(PADDING_Y);
185
186        // Calculate size
187        let font_id = egui::FontId::proportional(font_size);
188        let text_galley =
189            ui.painter()
190                .layout_no_wrap(self.text.clone(), font_id.clone(), text_color);
191        let text_width = text_galley.rect.width();
192
193        let dot_space = if self.show_dot { 12.0 } else { 0.0 };
194        let remove_space = if self.removable { 16.0 } else { 0.0 };
195
196        let content_width = text_width + dot_space + remove_space + PADDING_X * 2.0;
197        let width = self
198            .min_width
199            .map_or(content_width, |min_w| content_width.max(min_w));
200        let height = self
201            .custom_height
202            .unwrap_or(font_size + padding_y * 2.0 + 4.0);
203
204        // Use click sense for interactive badges
205        let (rect, response) =
206            ui.allocate_exact_size(Vec2::new(width, height), egui::Sense::click());
207
208        // Draw background
209        match self.variant {
210            BadgeVariant::Outline => {
211                // Outline: no fill, just border
212                ui.painter().rect_stroke(
213                    rect,
214                    corner_radius,
215                    egui::Stroke::new(1.0, border_color),
216                    egui::StrokeKind::Inside,
217                );
218            }
219            _ => {
220                // All other variants: filled background
221                ui.painter().rect_filled(rect, corner_radius, bg_color);
222            }
223        }
224
225        let mut x = rect.min.x + PADDING_X;
226
227        // Dot indicator
228        if self.show_dot {
229            let dot_center = Pos2::new(x + 3.0, rect.center().y);
230            ui.painter().circle_filled(dot_center, 3.0, text_color);
231            x += 12.0;
232        }
233
234        // Text (centered)
235        let text_pos = if self.show_dot || self.removable {
236            Pos2::new(x, rect.center().y)
237        } else {
238            rect.center()
239        };
240        let text_align = if self.show_dot || self.removable {
241            egui::Align2::LEFT_CENTER
242        } else {
243            egui::Align2::CENTER_CENTER
244        };
245
246        ui.painter()
247            .text(text_pos, text_align, &self.text, font_id, text_color);
248
249        // Remove button
250        let mut was_clicked = false;
251        if self.removable {
252            x += text_width + 4.0;
253            let remove_rect = egui::Rect::from_center_size(
254                Pos2::new(x + 6.0, rect.center().y),
255                Vec2::splat(12.0),
256            );
257
258            let is_hovered = ui.rect_contains_pointer(remove_rect);
259
260            if is_hovered {
261                ui.painter().circle_filled(
262                    remove_rect.center(),
263                    6.0,
264                    text_color.gamma_multiply(0.2),
265                );
266            }
267
268            // Draw X
269            let cross_size = 3.0;
270            let center = remove_rect.center();
271            ui.painter().line_segment(
272                [
273                    Pos2::new(center.x - cross_size, center.y - cross_size),
274                    Pos2::new(center.x + cross_size, center.y + cross_size),
275                ],
276                egui::Stroke::new(1.5, text_color),
277            );
278            ui.painter().line_segment(
279                [
280                    Pos2::new(center.x + cross_size, center.y - cross_size),
281                    Pos2::new(center.x - cross_size, center.y + cross_size),
282                ],
283                egui::Stroke::new(1.5, text_color),
284            );
285
286            if is_hovered && ui.input(|i| i.pointer.primary_clicked()) {
287                was_clicked = true;
288            }
289        }
290
291        BadgeResponse {
292            clicked: response.clicked(),
293            removed: was_clicked,
294            response,
295        }
296    }
297
298    /// Show the badge with custom content instead of a text label.
299    ///
300    /// The closure receives a `&mut Ui` (with override text color set) and a
301    /// [`ContentContext`] with the state-dependent color.
302    ///
303    /// Use [`min_width`](Self::min_width) to control the badge width.
304    /// Dot and removable features still work alongside custom content.
305    ///
306    /// # Example
307    ///
308    /// ```rust,no_run
309    /// # use egui::Ui;
310    /// # fn example(ui: &mut Ui) {
311    /// use armas_basic::components::Badge;
312    ///
313    /// Badge::new("")
314    ///     .min_width(60.0)
315    ///     .show_ui(ui, |ui, ctx| {
316    ///         // Render icon + text using ctx.color
317    ///         ui.label("New");
318    ///     });
319    /// # }
320    /// ```
321    pub fn show_ui(
322        self,
323        ui: &mut Ui,
324        content: impl FnOnce(&mut Ui, &ContentContext),
325    ) -> BadgeResponse {
326        let theme = ui.ctx().armas_theme();
327        let (bg_color, text_color, border_color) = self.get_colors(&theme);
328
329        let font_size = self.custom_font_size.unwrap_or(theme.typography.sm);
330        let corner_radius = self.custom_corner_radius.unwrap_or(CORNER_RADIUS);
331        let padding_y = self.custom_vertical_padding.unwrap_or(PADDING_Y);
332
333        let dot_space = if self.show_dot { 12.0 } else { 0.0 };
334        let remove_space = if self.removable { 16.0 } else { 0.0 };
335        let height = self
336            .custom_height
337            .unwrap_or(font_size + padding_y * 2.0 + 4.0);
338
339        // Width: use min_width or a fallback
340        let base_width = dot_space + remove_space + PADDING_X * 2.0 + height;
341        let width = self
342            .min_width
343            .map_or(base_width, |min_w| base_width.max(min_w));
344
345        let (rect, response) =
346            ui.allocate_exact_size(Vec2::new(width, height), egui::Sense::click());
347
348        // Draw background
349        match self.variant {
350            BadgeVariant::Outline => {
351                ui.painter().rect_stroke(
352                    rect,
353                    corner_radius,
354                    egui::Stroke::new(1.0, border_color),
355                    egui::StrokeKind::Inside,
356                );
357            }
358            _ => {
359                ui.painter().rect_filled(rect, corner_radius, bg_color);
360            }
361        }
362
363        let mut x = rect.min.x + PADDING_X;
364
365        // Dot indicator
366        if self.show_dot {
367            let dot_center = Pos2::new(x + 3.0, rect.center().y);
368            ui.painter().circle_filled(dot_center, 3.0, text_color);
369            x += 12.0;
370        }
371
372        // Custom content area
373        let content_right = if self.removable {
374            rect.max.x - PADDING_X - 16.0
375        } else {
376            rect.max.x - PADDING_X
377        };
378        let content_rect = egui::Rect::from_min_max(
379            Pos2::new(x, rect.min.y),
380            Pos2::new(content_right, rect.max.y),
381        );
382
383        let mut child_ui = ui.new_child(
384            egui::UiBuilder::new()
385                .max_rect(content_rect)
386                .layout(egui::Layout::left_to_right(egui::Align::Center)),
387        );
388        child_ui.style_mut().visuals.override_text_color = Some(text_color);
389
390        let ctx = ContentContext {
391            color: text_color,
392            font_size,
393            is_active: self.is_selected,
394        };
395        content(&mut child_ui, &ctx);
396
397        // Remove button
398        let mut was_clicked = false;
399        if self.removable {
400            let remove_x = content_right + 4.0;
401            let remove_rect = egui::Rect::from_center_size(
402                Pos2::new(remove_x + 6.0, rect.center().y),
403                Vec2::splat(12.0),
404            );
405
406            let is_hovered = ui.rect_contains_pointer(remove_rect);
407            if is_hovered {
408                ui.painter().circle_filled(
409                    remove_rect.center(),
410                    6.0,
411                    text_color.gamma_multiply(0.2),
412                );
413            }
414
415            let cross_size = 3.0;
416            let center = remove_rect.center();
417            ui.painter().line_segment(
418                [
419                    Pos2::new(center.x - cross_size, center.y - cross_size),
420                    Pos2::new(center.x + cross_size, center.y + cross_size),
421                ],
422                egui::Stroke::new(1.5, text_color),
423            );
424            ui.painter().line_segment(
425                [
426                    Pos2::new(center.x + cross_size, center.y - cross_size),
427                    Pos2::new(center.x - cross_size, center.y + cross_size),
428                ],
429                egui::Stroke::new(1.5, text_color),
430            );
431
432            if is_hovered && ui.input(|i| i.pointer.primary_clicked()) {
433                was_clicked = true;
434            }
435        }
436
437        BadgeResponse {
438            clicked: response.clicked(),
439            removed: was_clicked,
440            response,
441        }
442    }
443
444    /// Get colors based on variant (shadcn/ui style)
445    const fn get_colors(&self, theme: &Theme) -> (Color32, Color32, Color32) {
446        // Custom color overrides everything
447        if let Some(color) = self.custom_color {
448            return (color, theme.primary_foreground(), color);
449        }
450
451        // When selected, use primary filled style
452        if self.is_selected {
453            return (theme.primary(), theme.primary_foreground(), theme.primary());
454        }
455
456        match self.variant {
457            BadgeVariant::Default => (theme.primary(), theme.primary_foreground(), theme.primary()),
458            BadgeVariant::Secondary => (
459                theme.secondary(),
460                theme.secondary_foreground(),
461                theme.secondary(),
462            ),
463            BadgeVariant::Destructive => (
464                theme.destructive(),
465                theme.destructive_foreground(),
466                theme.destructive(),
467            ),
468            BadgeVariant::Outline => (Color32::TRANSPARENT, theme.foreground(), theme.border()),
469        }
470    }
471}
472
473impl Default for Badge {
474    fn default() -> Self {
475        Self::new("")
476    }
477}
478
479/// Response from a badge
480#[derive(Debug, Clone)]
481pub struct BadgeResponse {
482    /// Whether the badge was clicked
483    pub clicked: bool,
484    /// Whether the remove button was clicked (only relevant if badge is removable)
485    pub removed: bool,
486    /// The underlying egui response
487    pub response: egui::Response,
488}
489
490/// Notification badge (typically shows a count)
491pub struct NotificationBadge {
492    /// Count to display
493    count: usize,
494    /// Maximum count to show (e.g., 99+ for counts > max)
495    max_count: Option<usize>,
496    /// Badge color (None = use theme destructive color)
497    color: Option<Color32>,
498    /// Size
499    size: f32,
500}
501
502impl NotificationBadge {
503    /// Create a new notification badge with count
504    /// Color defaults to theme destructive color
505    #[must_use]
506    pub const fn new(count: usize) -> Self {
507        Self {
508            count,
509            max_count: Some(99),
510            color: None,
511            size: 18.0,
512        }
513    }
514
515    /// Set maximum count display
516    #[must_use]
517    pub const fn max_count(mut self, max: usize) -> Self {
518        self.max_count = Some(max);
519        self
520    }
521
522    /// Set badge color (overrides theme)
523    #[must_use]
524    pub const fn color(mut self, color: Color32) -> Self {
525        self.color = Some(color);
526        self
527    }
528
529    /// Set badge size
530    #[must_use]
531    pub const fn size(mut self, size: f32) -> Self {
532        self.size = size;
533        self
534    }
535
536    /// Show the notification badge
537    pub fn show(self, ui: &mut Ui) -> Response {
538        let theme = ui.ctx().armas_theme();
539        let color = self.color.unwrap_or_else(|| theme.destructive());
540
541        let text = self.max_count.map_or_else(
542            || self.count.to_string(),
543            |max| {
544                if self.count > max {
545                    format!("{max}+")
546                } else {
547                    self.count.to_string()
548                }
549            },
550        );
551
552        let (rect, response) = ui.allocate_exact_size(Vec2::splat(self.size), egui::Sense::hover());
553
554        // Circle background
555        ui.painter()
556            .circle_filled(rect.center(), self.size / 2.0, color);
557
558        // Count text
559        ui.painter().text(
560            rect.center(),
561            egui::Align2::CENTER_CENTER,
562            &text,
563            egui::FontId::proportional(self.size * 0.6),
564            theme.primary_foreground(),
565        );
566
567        response
568    }
569}