Skip to main content

egui_material3/
badge.rs

1use crate::theme::get_global_color;
2use egui::{
3    ecolor::Color32, pos2, FontId, Rect, Response,
4    Sense, Ui, Vec2, Widget,
5};
6
7/// Badge color variants following Material Design 3 specifications
8#[derive(Clone, Copy, PartialEq, Debug)]
9pub enum BadgeColor {
10    /// Primary color badge (uses primary theme color)
11    Primary,
12    /// Error/danger color badge (red, for alerts and errors)
13    Error,
14    /// Success color badge (green, for positive states)
15    Success,
16    /// Warning color badge (yellow/orange, for caution)
17    Warning,
18    /// Neutral/gray color badge (for general information)
19    Neutral,
20    /// Custom color badge
21    Custom(Color32, Color32), // (background, text)
22}
23
24/// Badge size variants
25#[derive(Clone, Copy, PartialEq, Debug)]
26pub enum BadgeSize {
27    /// Small badge - 16px height, suitable for compact layouts
28    Small,
29    /// Regular badge - 20px height, standard size
30    Regular,
31    /// Large badge - 24px height, for more prominent display
32    Large,
33}
34
35/// Badge positioning relative to parent element
36#[derive(Clone, Copy, PartialEq, Debug)]
37pub enum BadgePosition {
38    /// Top-right corner
39    TopRight,
40    /// Top-left corner
41    TopLeft,
42    /// Bottom-right corner
43    BottomRight,
44    /// Bottom-left corner
45    BottomLeft,
46    /// Custom position with offset
47    Custom(Vec2),
48}
49
50/// Material Design badge component.
51///
52/// Badges are small status descriptors for UI elements. They typically appear
53/// as small circles or rounded rectangles with text or numbers, positioned
54/// on or near other elements to provide context or notifications.
55///
56/// # Example
57/// ```rust
58/// # egui::__run_test_ui(|ui| {
59/// // Simple numeric badge
60/// ui.add(MaterialBadge::new("5")
61///     .color(BadgeColor::Error));
62///
63/// // Text badge
64/// ui.add(MaterialBadge::new("NEW")
65///     .color(BadgeColor::Success)
66///     .size(BadgeSize::Regular));
67///
68/// // Badge on an icon (standalone positioning)
69/// ui.add(MaterialBadge::new("3")
70///     .color(BadgeColor::Primary)
71///     .size(BadgeSize::Small));
72/// # });
73/// ```
74#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
75pub struct MaterialBadge {
76    /// Text content of the badge
77    content: String,
78    /// Color variant
79    color: BadgeColor,
80    /// Size variant
81    size: BadgeSize,
82    /// Whether to show as a dot (no text, just indicator)
83    dot: bool,
84    /// Custom position offset when used as overlay
85    position_offset: Vec2,
86}
87
88impl MaterialBadge {
89    /// Create a new badge with the specified content
90    ///
91    /// # Arguments
92    /// * `content` - Text or number to display in the badge
93    pub fn new(content: impl Into<String>) -> Self {
94        Self {
95            content: content.into(),
96            color: BadgeColor::Error,
97            size: BadgeSize::Regular,
98            dot: false,
99            position_offset: Vec2::new(0.0, 0.0),
100        }
101    }
102
103    /// Create a badge showing just a dot (no text)
104    pub fn dot() -> Self {
105        Self {
106            content: String::new(),
107            color: BadgeColor::Error,
108            size: BadgeSize::Small,
109            dot: true,
110            position_offset: Vec2::new(0.0, 0.0),
111        }
112    }
113
114    /// Set the color variant of the badge
115    pub fn color(mut self, color: BadgeColor) -> Self {
116        self.color = color;
117        self
118    }
119
120    /// Set the size of the badge
121    pub fn size(mut self, size: BadgeSize) -> Self {
122        self.size = size;
123        self
124    }
125
126    /// Set whether to show as a dot indicator
127    pub fn as_dot(mut self, dot: bool) -> Self {
128        self.dot = dot;
129        self
130    }
131
132    /// Set a custom position offset for overlay positioning
133    pub fn position_offset(mut self, offset: Vec2) -> Self {
134        self.position_offset = offset;
135        self
136    }
137
138    /// Draw the badge as an overlay on a specific rectangle
139    ///
140    /// This is useful for adding badges to other UI elements like buttons or icons.
141    ///
142    /// # Arguments
143    /// * `ui` - The UI context
144    /// * `target_rect` - The rectangle of the element to badge
145    /// * `position` - Where to position the badge relative to the target
146    pub fn draw_on(
147        &self,
148        ui: &mut Ui,
149        target_rect: Rect,
150        position: BadgePosition,
151    ) -> Response {
152        let (bg_color, text_color) = self.get_colors();
153        let (min_width, min_height, font_size) = self.get_dimensions();
154
155        let painter = ui.painter();
156
157        // Calculate badge size
158        let text_galley = if !self.dot && !self.content.is_empty() {
159            Some(painter.layout_no_wrap(
160                self.content.clone(),
161                FontId::proportional(font_size),
162                text_color,
163            ))
164        } else {
165            None
166        };
167
168        let badge_width = if let Some(ref galley) = text_galley {
169            (galley.size().x + min_width).max(min_height)
170        } else {
171            min_height
172        };
173        let badge_height = min_height;
174
175        // Calculate badge position - positioned to overlap the icon edges
176        // Using 95% overlap to make badges appear extremely close and tightly over the icon
177        let overlap_factor = 0.95;
178        let badge_pos = match position {
179            BadgePosition::TopRight => {
180                pos2(
181                    target_rect.max.x - badge_width * (1.0 - overlap_factor),
182                    target_rect.min.y - badge_height * (1.0 - overlap_factor),
183                )
184            }
185            BadgePosition::TopLeft => {
186                pos2(
187                    target_rect.min.x - badge_width * overlap_factor,
188                    target_rect.min.y - badge_height * (1.0 - overlap_factor),
189                )
190            }
191            BadgePosition::BottomRight => {
192                pos2(
193                    target_rect.max.x - badge_width * (1.0 - overlap_factor),
194                    target_rect.max.y - badge_height * (1.0 - overlap_factor),
195                )
196            }
197            BadgePosition::BottomLeft => {
198                pos2(
199                    target_rect.min.x - badge_width * overlap_factor,
200                    target_rect.max.y - badge_height * (1.0 - overlap_factor),
201                )
202            }
203            BadgePosition::Custom(offset) => {
204                pos2(target_rect.center().x + offset.x, target_rect.center().y + offset.y)
205            }
206        };
207
208        let badge_pos = pos2(
209            badge_pos.x + self.position_offset.x,
210            badge_pos.y + self.position_offset.y,
211        );
212
213        let badge_rect = Rect::from_center_size(badge_pos, Vec2::new(badge_width, badge_height));
214
215        // Draw badge background
216        painter.rect_filled(badge_rect, badge_height / 2.0, bg_color);
217
218        // Draw text if not a dot
219        if let Some(galley) = text_galley {
220            painter.galley(
221                pos2(
222                    badge_rect.center().x - galley.size().x / 2.0,
223                    badge_rect.center().y - galley.size().y / 2.0,
224                ),
225                galley,
226                text_color,
227            );
228        }
229
230        ui.interact(badge_rect, ui.id().with("badge"), Sense::hover())
231    }
232
233    fn get_colors(&self) -> (Color32, Color32) {
234        match self.color {
235            BadgeColor::Primary => {
236                let bg = get_global_color("primary");
237                let text = get_global_color("onPrimary");
238                (bg, text)
239            }
240            BadgeColor::Error => (
241                Color32::from_rgb(239, 68, 68), // red-500
242                Color32::WHITE,
243            ),
244            BadgeColor::Success => (
245                Color32::from_rgb(34, 197, 94), // green-500
246                Color32::WHITE,
247            ),
248            BadgeColor::Warning => (
249                Color32::from_rgb(234, 179, 8), // yellow-500
250                Color32::WHITE,
251            ),
252            BadgeColor::Neutral => (
253                Color32::from_rgb(107, 114, 128), // gray-500
254                Color32::WHITE,
255            ),
256            BadgeColor::Custom(bg, text) => (bg, text),
257        }
258    }
259
260    fn get_dimensions(&self) -> (f32, f32, f32) {
261        // Returns (padding_width, min_height, font_size)
262        match self.size {
263            BadgeSize::Small => {
264                if self.dot {
265                    (0.0, 8.0, 0.0) // 8px dot
266                } else {
267                    (4.0, 16.0, 10.0) // text-2xs equivalent
268                }
269            }
270            BadgeSize::Regular => {
271                if self.dot {
272                    (0.0, 10.0, 0.0) // 10px dot
273                } else {
274                    (6.0, 20.0, 12.0) // text-xs equivalent
275                }
276            }
277            BadgeSize::Large => {
278                if self.dot {
279                    (0.0, 12.0, 0.0) // 12px dot
280                } else {
281                    (8.0, 24.0, 14.0) // text-sm equivalent
282                }
283            }
284        }
285    }
286}
287
288impl Widget for MaterialBadge {
289    fn ui(self, ui: &mut Ui) -> Response {
290        let (bg_color, text_color) = self.get_colors();
291        let (min_width, min_height, font_size) = self.get_dimensions();
292
293        // Calculate badge size
294        let text_galley = if !self.dot && !self.content.is_empty() {
295            Some(ui.painter().layout_no_wrap(
296                self.content.clone(),
297                FontId::proportional(font_size),
298                text_color,
299            ))
300        } else {
301            None
302        };
303
304        let badge_width = if let Some(ref galley) = text_galley {
305            (galley.size().x + min_width).max(min_height)
306        } else {
307            min_height
308        };
309        let badge_height = min_height;
310
311        let desired_size = Vec2::new(badge_width, badge_height);
312        let (rect, response) = ui.allocate_exact_size(desired_size, Sense::hover());
313
314        // Draw badge background
315        ui.painter()
316            .rect_filled(rect, badge_height / 2.0, bg_color);
317
318        // Draw text if not a dot
319        if let Some(galley) = text_galley {
320            ui.painter().galley(
321                pos2(
322                    rect.center().x - galley.size().x / 2.0,
323                    rect.center().y - galley.size().y / 2.0,
324                ),
325                galley,
326                text_color,
327            );
328        }
329
330        response
331    }
332}
333
334/// Convenience function to create a badge
335///
336/// # Example
337/// ```rust
338/// # egui::__run_test_ui(|ui| {
339/// ui.add(badge("5").color(BadgeColor::Error));
340/// # });
341/// ```
342pub fn badge(content: impl Into<String>) -> MaterialBadge {
343    MaterialBadge::new(content)
344}
345
346/// Convenience function to create a dot badge
347///
348/// # Example
349/// ```rust
350/// # egui::__run_test_ui(|ui| {
351/// ui.add(badge_dot().color(BadgeColor::Success));
352/// # });
353/// ```
354pub fn badge_dot() -> MaterialBadge {
355    MaterialBadge::dot()
356}