1use egui::{Align2, Color32, Rect, Stroke, TextStyle, TextWrapMode, 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 &[
74 Self::TopLeft,
75 Self::TopRight,
76 Self::BottomLeft,
77 Self::BottomRight,
78 ]
79 }
80}
81
82#[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 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 pub fn dot() -> Self {
120 Self::new("")
121 }
122
123 #[inline]
125 pub fn color(mut self, color: Color32) -> Self {
126 self.color = Some(color);
127 self
128 }
129
130 #[inline]
132 pub fn corner(mut self, corner: BadgeCorner) -> Self {
133 self.corner = corner;
134 self
135 }
136
137 #[inline]
141 pub fn text_color(mut self, text_color: Color32) -> Self {
142 self.text_color = Some(text_color);
143 self
144 }
145
146 #[inline]
151 pub fn min_diameter(mut self, min_diameter: f32) -> Self {
152 self.min_diameter = min_diameter;
153 self
154 }
155
156 #[inline]
160 pub fn inset(mut self, inset: f32) -> Self {
161 self.inset = inset;
162 self
163 }
164
165 #[inline]
167 pub fn padding(mut self, padding: f32) -> Self {
168 self.padding = padding;
169 self
170 }
171
172 #[inline]
175 pub fn outline(mut self, outline: impl Into<Stroke>) -> Self {
176 self.outline = Some(outline.into());
177 self
178 }
179
180 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 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 #[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}