1use egui::{Align2, Color32, Rect, Stroke, TextWrapMode, TextStyle, Ui, Vec2, WidgetText};
27
28#[cfg(test)]
29use egui::Pos2;
30
31use crate::theme::Theme;
32
33const DEFAULT_MIN_DIAMETER: f32 = 16.0;
35const DEFAULT_INSET: f32 = 2.0;
37const DEFAULT_PADDING: f32 = 3.0;
39
40#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43pub enum BadgeCorner {
44 TopLeft,
45 #[default]
47 TopRight,
48 BottomLeft,
49 BottomRight,
50}
51
52impl BadgeCorner {
53 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#[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 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 pub fn dot() -> Self {
115 Self::new("")
116 }
117
118 #[inline]
120 pub fn color(mut self, color: Color32) -> Self {
121 self.color = Some(color);
122 self
123 }
124
125 #[inline]
127 pub fn corner(mut self, corner: BadgeCorner) -> Self {
128 self.corner = corner;
129 self
130 }
131
132 #[inline]
136 pub fn text_color(mut self, text_color: Color32) -> Self {
137 self.text_color = Some(text_color);
138 self
139 }
140
141 #[inline]
146 pub fn min_diameter(mut self, min_diameter: f32) -> Self {
147 self.min_diameter = min_diameter;
148 self
149 }
150
151 #[inline]
155 pub fn inset(mut self, inset: f32) -> Self {
156 self.inset = inset;
157 self
158 }
159
160 #[inline]
162 pub fn padding(mut self, padding: f32) -> Self {
163 self.padding = padding;
164 self
165 }
166
167 #[inline]
170 pub fn outline(mut self, outline: impl Into<Stroke>) -> Self {
171 self.outline = Some(outline.into());
172 self
173 }
174
175 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 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 #[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}