Skip to main content

egui_elements/widgets/
badge.rs

1//! Corner badges — small notification circles painted over a themed widget.
2//!
3//! A [`Badge`] is a circle with optional text, anchored to one of the four
4//! corners of a widget. Attach it to a [`Button`](crate::widgets::Button) to
5//! signal that the control opens something with unseen content (balance or
6//! approval changes, unread messages, …):
7//!
8//! ```
9//! # use egui::__run_test_ui;
10//! # use egui_elements::widgets::{Badge, BadgeCorner, Button};
11//! # __run_test_ui(|ui| {
12//! let changes = 3;
13//! ui.add(
14//!    Button::new("Balance & approvals")
15//!       .badge(Badge::new(changes.to_string()).corner(BadgeCorner::TopRight)),
16//! );
17//! # });
18//! ```
19//!
20//! The badge is an *overlay*: it is painted on top of the widget and never
21//! changes its size or layout. The fill defaults to
22//! [`ThemeColors::error`](crate::theme::ThemeColors::error) and the text to
23//! [`ThemeColors::bg`](crate::theme::ThemeColors::bg), matching the theme rule
24//! that chromatic fills carry `bg`-colored glyphs.
25
26use egui::{Align2, Color32, Rect, Stroke, TextWrapMode, TextStyle, Ui, Vec2, WidgetText};
27
28#[cfg(test)]
29use egui::Pos2;
30
31use crate::theme::Theme;
32
33/// Default size (in points) of a badge with an empty text — a plain dot.
34const DEFAULT_MIN_DIAMETER: f32 = 16.0;
35/// Default distance (in points) the circle is inset from the widget corner.
36const DEFAULT_INSET: f32 = 2.0;
37/// Default gap (in points) between the badge text and the circle edge.
38const DEFAULT_PADDING: f32 = 3.0;
39
40/// Which corner of a widget a [`Badge`] is anchored to.
41#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43pub enum BadgeCorner {
44   TopLeft,
45   /// Where notification badges usually live; the default.
46   #[default]
47   TopRight,
48   BottomLeft,
49   BottomRight,
50}
51
52impl BadgeCorner {
53   /// Anchor used to place the circle inside the widget rect.
54   pub const fn align2(self) -> Align2 {
55      match self {
56         Self::TopLeft => Align2::LEFT_TOP,
57         Self::TopRight => Align2::RIGHT_TOP,
58         Self::BottomLeft => Align2::LEFT_BOTTOM,
59         Self::BottomRight => Align2::RIGHT_BOTTOM,
60      }
61   }
62
63   pub const fn to_str(self) -> &'static str {
64      match self {
65         Self::TopLeft => "Top left",
66         Self::TopRight => "Top right",
67         Self::BottomLeft => "Bottom left",
68         Self::BottomRight => "Bottom right",
69      }
70   }
71
72   pub const fn to_vec() -> &'static [Self] {
73      &[Self::TopLeft, Self::TopRight, Self::BottomLeft, Self::BottomRight]
74   }
75}
76
77/// A notification circle painted in a corner of a widget.
78///
79/// Build one with [`Badge::new`] and attach it with
80/// [`Button::badge`](crate::widgets::Button::badge). The colour and corner are
81/// the two knobs you will reach for; everything else has a sane default.
82///
83/// Empty text ([`Badge::dot`]) renders a plain coloured dot.
84#[derive(Clone, Debug)]
85#[must_use = "Badges should be shown on a widget, e.g. `Button::new(\"…\").badge(badge)`"]
86pub struct Badge {
87   text: WidgetText,
88   color: Option<Color32>,
89   corner: BadgeCorner,
90   text_color: Option<Color32>,
91   min_diameter: f32,
92   inset: f32,
93   padding: f32,
94   outline: Option<Stroke>,
95}
96
97impl Badge {
98   /// Create a badge showing `text` (e.g. a change count) in the top-right
99   /// corner, filled with [`ThemeColors::error`](crate::theme::ThemeColors::error).
100   pub fn new(text: impl Into<WidgetText>) -> Self {
101      Self {
102         text: text.into(),
103         color: None,
104         corner: BadgeCorner::default(),
105         text_color: None,
106         min_diameter: DEFAULT_MIN_DIAMETER,
107         inset: DEFAULT_INSET,
108         padding: DEFAULT_PADDING,
109         outline: None,
110      }
111   }
112
113   /// Create a textless badge: a plain coloured dot.
114   pub fn dot() -> Self {
115      Self::new("")
116   }
117
118   /// Circle fill. Defaults to the theme error colour.
119   #[inline]
120   pub fn color(mut self, color: Color32) -> Self {
121      self.color = Some(color);
122      self
123   }
124
125   /// Which corner of the widget the badge sits in. Default: top-right.
126   #[inline]
127   pub fn corner(mut self, corner: BadgeCorner) -> Self {
128      self.corner = corner;
129      self
130   }
131
132   /// Colour of the badge text. Defaults to
133   /// [`ThemeColors::bg`](crate::theme::ThemeColors::bg) so glyphs stay
134   /// readable on chromatic fills.
135   #[inline]
136   pub fn text_color(mut self, text_color: Color32) -> Self {
137      self.text_color = Some(text_color);
138      self
139   }
140
141   /// Smallest diameter the circle will take, in points.
142   ///
143   /// The circle grows beyond this when the text needs the room. Default:
144   /// `16.0`. Lower it for [`Button::small`](crate::widgets::Button::small).
145   #[inline]
146   pub fn min_diameter(mut self, min_diameter: f32) -> Self {
147      self.min_diameter = min_diameter;
148      self
149   }
150
151   /// Distance between the circle and the widget corner, in points.
152   ///
153   /// Negative values let the badge straddle the edge. Default: `2.0`.
154   #[inline]
155   pub fn inset(mut self, inset: f32) -> Self {
156      self.inset = inset;
157      self
158   }
159
160   /// Gap between the text and the circle edge, in points. Default: `3.0`.
161   #[inline]
162   pub fn padding(mut self, padding: f32) -> Self {
163      self.padding = padding;
164      self
165   }
166
167   /// Ring drawn around the circle, e.g. `Stroke::new(1.5, theme.colors.bg)`
168   /// to lift the badge off a busy button fill.
169   #[inline]
170   pub fn outline(mut self, outline: impl Into<Stroke>) -> Self {
171      self.outline = Some(outline.into());
172      self
173   }
174
175   /// Rect the circle occupies for a laid-out text of `text_size`, inside
176   /// `widget_rect`. Pure geometry — shared by painting and tests.
177   fn circle_rect(&self, text_size: Vec2, widget_rect: Rect) -> Rect {
178      let diameter = (text_size + Vec2::splat(2.0 * self.padding))
179         .max_elem()
180         .max(self.min_diameter);
181      self
182         .corner
183         .align2()
184         .align_size_within_rect(Vec2::splat(diameter), widget_rect.shrink(self.inset))
185   }
186
187   /// Paint the badge on top of `widget_rect` (usually a widget's response rect).
188   ///
189   /// Public so any widget can host a badge without re-implementing the
190   /// placement and text metrics.
191   pub fn paint_at(&self, ui: &Ui, widget_rect: Rect) {
192      if !ui.is_rect_visible(widget_rect) {
193         return;
194      }
195
196      let theme = Theme::current(ui.ctx());
197      let fill = self.color.unwrap_or(theme.colors.error);
198      let text_color = self.text_color.unwrap_or(theme.colors.bg);
199
200      let galley = self.text.clone().into_galley(
201         ui,
202         Some(TextWrapMode::Extend),
203         f32::INFINITY,
204         TextStyle::Small,
205      );
206
207      let circle = self.circle_rect(galley.size(), widget_rect);
208      let center = circle.center();
209
210      let painter = ui.painter();
211      painter.circle(
212         center,
213         circle.width() * 0.5,
214         fill,
215         self.outline.unwrap_or(Stroke::NONE),
216      );
217      if !galley.is_empty() {
218         painter.galley(center - galley.size() * 0.5, galley, text_color);
219      }
220   }
221
222   /// Center of the circle for a laid-out text of `text_size`. Test helper.
223   #[cfg(test)]
224   fn center_for(&self, text_size: Vec2, widget_rect: Rect) -> Pos2 {
225      self.circle_rect(text_size, widget_rect).center()
226   }
227}
228
229#[cfg(test)]
230mod tests {
231   use super::*;
232   use egui::vec2;
233
234   fn rect() -> Rect {
235      Rect::from_min_size(Pos2::new(100.0, 100.0), vec2(200.0, 40.0))
236   }
237
238   const TEXT: Vec2 = Vec2::new(8.0, 12.0);
239
240   #[test]
241   fn corners_anchor_to_the_matching_side() {
242      let r = rect();
243      let inset = DEFAULT_INSET;
244      let pad = DEFAULT_PADDING;
245      let min = DEFAULT_MIN_DIAMETER;
246
247      let expected_diameter = (TEXT.x + 2.0 * pad).max(TEXT.y + 2.0 * pad).max(min);
248      let radius = expected_diameter * 0.5;
249
250      let cases = [
251         (BadgeCorner::TopLeft, r.left() + inset + radius, r.top() + inset + radius),
252         (BadgeCorner::TopRight, r.right() - inset - radius, r.top() + inset + radius),
253         (BadgeCorner::BottomLeft, r.left() + inset + radius, r.bottom() - inset - radius),
254         (
255            BadgeCorner::BottomRight,
256            r.right() - inset - radius,
257            r.bottom() - inset - radius,
258         ),
259      ];
260
261      for (corner, x, y) in cases {
262         let center = Badge::dot().corner(corner).center_for(TEXT, r);
263         assert!(
264            (center.x - x).abs() < 0.01 && (center.y - y).abs() < 0.01,
265            "{corner:?}: expected ({x}, {y}), got {center:?}"
266         );
267      }
268   }
269
270   #[test]
271   fn circle_covers_text_and_min_diameter() {
272      let badge = Badge::new("12");
273      let small = badge.clone().circle_rect(Vec2::ZERO, rect());
274      assert_eq!(small.width(), DEFAULT_MIN_DIAMETER);
275
276      let wide = badge.circle_rect(vec2(40.0, 12.0), rect());
277      assert_eq!(wide.width(), 40.0 + 2.0 * DEFAULT_PADDING);
278   }
279
280   #[test]
281   fn negative_inset_lets_the_badge_straddle_the_corner() {
282      let inside = Badge::dot().center_for(TEXT, rect());
283      let outside = Badge::dot().inset(-6.0).center_for(TEXT, rect());
284      assert!(outside.x > inside.x);
285   }
286
287   #[test]
288   fn defaults_are_top_right_error_and_bg_text() {
289      let badge = Badge::dot();
290      assert_eq!(badge.corner, BadgeCorner::TopRight);
291      assert_eq!(badge.color, None);
292      assert_eq!(badge.text_color, None);
293   }
294
295   #[test]
296   fn paints_without_panicking() {
297      egui::__run_test_ui(|ui| {
298         let response = ui.add(crate::widgets::Button::new("Balance & approvals").badge(
299            Badge::new("3")
300               .corner(BadgeCorner::TopRight)
301               .outline(Stroke::new(1.5, Color32::BLACK)),
302         ));
303         assert!(response.rect.width() > 0.0);
304      });
305   }
306}