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, TextStyle, TextWrapMode, 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      &[
74         Self::TopLeft,
75         Self::TopRight,
76         Self::BottomLeft,
77         Self::BottomRight,
78      ]
79   }
80}
81
82/// A notification circle painted in a corner of a widget.
83///
84/// Build one with [`Badge::new`] and attach it with
85/// [`Button::badge`](crate::widgets::Button::badge). The colour and corner are
86/// the two knobs you will reach for; everything else has a sane default.
87///
88/// Empty text ([`Badge::dot`]) renders a plain coloured dot.
89#[derive(Clone, Debug)]
90#[must_use = "Badges should be shown on a widget, e.g. `Button::new(\"…\").badge(badge)`"]
91pub struct Badge {
92   text: WidgetText,
93   color: Option<Color32>,
94   corner: BadgeCorner,
95   text_color: Option<Color32>,
96   min_diameter: f32,
97   inset: f32,
98   padding: f32,
99   outline: Option<Stroke>,
100}
101
102impl Badge {
103   /// Create a badge showing `text` (e.g. a change count) in the top-right
104   /// corner, filled with [`ThemeColors::error`](crate::theme::ThemeColors::error).
105   pub fn new(text: impl Into<WidgetText>) -> Self {
106      Self {
107         text: text.into(),
108         color: None,
109         corner: BadgeCorner::default(),
110         text_color: None,
111         min_diameter: DEFAULT_MIN_DIAMETER,
112         inset: DEFAULT_INSET,
113         padding: DEFAULT_PADDING,
114         outline: None,
115      }
116   }
117
118   /// Create a textless badge: a plain coloured dot.
119   pub fn dot() -> Self {
120      Self::new("")
121   }
122
123   /// Circle fill. Defaults to the theme error colour.
124   #[inline]
125   pub fn color(mut self, color: Color32) -> Self {
126      self.color = Some(color);
127      self
128   }
129
130   /// Which corner of the widget the badge sits in. Default: top-right.
131   #[inline]
132   pub fn corner(mut self, corner: BadgeCorner) -> Self {
133      self.corner = corner;
134      self
135   }
136
137   /// Colour of the badge text. Defaults to
138   /// [`ThemeColors::bg`](crate::theme::ThemeColors::bg) so glyphs stay
139   /// readable on chromatic fills.
140   #[inline]
141   pub fn text_color(mut self, text_color: Color32) -> Self {
142      self.text_color = Some(text_color);
143      self
144   }
145
146   /// Smallest diameter the circle will take, in points.
147   ///
148   /// The circle grows beyond this when the text needs the room. Default:
149   /// `16.0`. Lower it for [`Button::small`](crate::widgets::Button::small).
150   #[inline]
151   pub fn min_diameter(mut self, min_diameter: f32) -> Self {
152      self.min_diameter = min_diameter;
153      self
154   }
155
156   /// Distance between the circle and the widget corner, in points.
157   ///
158   /// Negative values let the badge straddle the edge. Default: `2.0`.
159   #[inline]
160   pub fn inset(mut self, inset: f32) -> Self {
161      self.inset = inset;
162      self
163   }
164
165   /// Gap between the text and the circle edge, in points. Default: `3.0`.
166   #[inline]
167   pub fn padding(mut self, padding: f32) -> Self {
168      self.padding = padding;
169      self
170   }
171
172   /// Ring drawn around the circle, e.g. `Stroke::new(1.5, theme.colors.bg)`
173   /// to lift the badge off a busy button fill.
174   #[inline]
175   pub fn outline(mut self, outline: impl Into<Stroke>) -> Self {
176      self.outline = Some(outline.into());
177      self
178   }
179
180   /// Rect the circle occupies for a laid-out text of `text_size`, inside
181   /// `widget_rect`. Pure geometry — shared by painting and tests.
182   fn circle_rect(&self, text_size: Vec2, widget_rect: Rect) -> Rect {
183      let diameter =
184         (text_size + Vec2::splat(2.0 * self.padding)).max_elem().max(self.min_diameter);
185      self.corner.align2().align_size_within_rect(
186         Vec2::splat(diameter),
187         widget_rect.shrink(self.inset),
188      )
189   }
190
191   /// Paint the badge on top of `widget_rect` (usually a widget's response rect).
192   ///
193   /// Public so any widget can host a badge without re-implementing the
194   /// placement and text metrics.
195   pub fn paint_at(&self, ui: &Ui, widget_rect: Rect) {
196      if !ui.is_rect_visible(widget_rect) {
197         return;
198      }
199
200      let theme = Theme::current(ui.ctx());
201      let fill = self.color.unwrap_or(theme.colors.error);
202      let text_color = self.text_color.unwrap_or(theme.colors.bg);
203
204      let galley = self.text.clone().into_galley(
205         ui,
206         Some(TextWrapMode::Extend),
207         f32::INFINITY,
208         TextStyle::Small,
209      );
210
211      let circle = self.circle_rect(galley.size(), widget_rect);
212      let center = circle.center();
213
214      let painter = ui.painter();
215      painter.circle(
216         center,
217         circle.width() * 0.5,
218         fill,
219         self.outline.unwrap_or(Stroke::NONE),
220      );
221      if !galley.is_empty() {
222         painter.galley(center - galley.size() * 0.5, galley, text_color);
223      }
224   }
225
226   /// Center of the circle for a laid-out text of `text_size`. Test helper.
227   #[cfg(test)]
228   fn center_for(&self, text_size: Vec2, widget_rect: Rect) -> Pos2 {
229      self.circle_rect(text_size, widget_rect).center()
230   }
231}
232
233#[cfg(test)]
234mod tests {
235   use super::*;
236   use egui::vec2;
237
238   fn rect() -> Rect {
239      Rect::from_min_size(Pos2::new(100.0, 100.0), vec2(200.0, 40.0))
240   }
241
242   const TEXT: Vec2 = Vec2::new(8.0, 12.0);
243
244   #[test]
245   fn corners_anchor_to_the_matching_side() {
246      let r = rect();
247      let inset = DEFAULT_INSET;
248      let pad = DEFAULT_PADDING;
249      let min = DEFAULT_MIN_DIAMETER;
250
251      let expected_diameter = (TEXT.x + 2.0 * pad).max(TEXT.y + 2.0 * pad).max(min);
252      let radius = expected_diameter * 0.5;
253
254      let cases = [
255         (
256            BadgeCorner::TopLeft,
257            r.left() + inset + radius,
258            r.top() + inset + radius,
259         ),
260         (
261            BadgeCorner::TopRight,
262            r.right() - inset - radius,
263            r.top() + inset + radius,
264         ),
265         (
266            BadgeCorner::BottomLeft,
267            r.left() + inset + radius,
268            r.bottom() - inset - radius,
269         ),
270         (
271            BadgeCorner::BottomRight,
272            r.right() - inset - radius,
273            r.bottom() - inset - radius,
274         ),
275      ];
276
277      for (corner, x, y) in cases {
278         let center = Badge::dot().corner(corner).center_for(TEXT, r);
279         assert!(
280            (center.x - x).abs() < 0.01 && (center.y - y).abs() < 0.01,
281            "{corner:?}: expected ({x}, {y}), got {center:?}"
282         );
283      }
284   }
285
286   #[test]
287   fn circle_covers_text_and_min_diameter() {
288      let badge = Badge::new("12");
289      let small = badge.clone().circle_rect(Vec2::ZERO, rect());
290      assert_eq!(small.width(), DEFAULT_MIN_DIAMETER);
291
292      let wide = badge.circle_rect(vec2(40.0, 12.0), rect());
293      assert_eq!(wide.width(), 40.0 + 2.0 * DEFAULT_PADDING);
294   }
295
296   #[test]
297   fn negative_inset_lets_the_badge_straddle_the_corner() {
298      let inside = Badge::dot().center_for(TEXT, rect());
299      let outside = Badge::dot().inset(-6.0).center_for(TEXT, rect());
300      assert!(outside.x > inside.x);
301   }
302
303   #[test]
304   fn defaults_are_top_right_error_and_bg_text() {
305      let badge = Badge::dot();
306      assert_eq!(badge.corner, BadgeCorner::TopRight);
307      assert_eq!(badge.color, None);
308      assert_eq!(badge.text_color, None);
309   }
310
311   #[test]
312   fn paints_without_panicking() {
313      egui::__run_test_ui(|ui| {
314         let response = ui.add(
315            crate::widgets::Button::new("Balance & approvals").badge(
316               Badge::new("3")
317                  .corner(BadgeCorner::TopRight)
318                  .outline(Stroke::new(1.5, Color32::BLACK)),
319            ),
320         );
321         assert!(response.rect.width() > 0.0);
322      });
323   }
324}