1use super::content::ContentContext;
11use crate::ext::ArmasContextExt;
12use crate::Theme;
13use egui::{Color32, Pos2, Response, Ui, Vec2};
14
15const CORNER_RADIUS: f32 = 9999.0; const PADDING_X: f32 = 10.0; const PADDING_Y: f32 = 2.0; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum BadgeVariant {
24 #[default]
26 Default,
27 Secondary,
29 Destructive,
31 Outline,
33}
34
35#[allow(non_upper_case_globals)]
37impl BadgeVariant {
38 pub const Filled: Self = Self::Default;
40 pub const Outlined: Self = Self::Outline;
42 pub const Soft: Self = Self::Secondary;
44}
45
46pub struct Badge {
68 text: String,
69 variant: BadgeVariant,
70 custom_color: Option<Color32>,
71 show_dot: bool,
72 removable: bool,
73 is_selected: bool,
74 custom_font_size: Option<f32>,
75 custom_corner_radius: Option<f32>,
76 custom_vertical_padding: Option<f32>,
77 custom_height: Option<f32>,
78 min_width: Option<f32>,
79}
80
81impl Badge {
82 pub fn new(text: impl Into<String>) -> Self {
84 Self {
85 text: text.into(),
86 variant: BadgeVariant::default(),
87 custom_color: None,
88 show_dot: false,
89 removable: false,
90 is_selected: false,
91 custom_font_size: None,
92 custom_corner_radius: None,
93 custom_vertical_padding: None,
94 custom_height: None,
95 min_width: None,
96 }
97 }
98
99 #[must_use]
101 pub const fn variant(mut self, variant: BadgeVariant) -> Self {
102 self.variant = variant;
103 self
104 }
105
106 #[must_use]
108 pub const fn color(mut self, color: Color32) -> Self {
109 self.custom_color = Some(color);
110 self
111 }
112
113 #[must_use]
115 pub const fn destructive(mut self) -> Self {
116 self.variant = BadgeVariant::Destructive;
117 self
118 }
119
120 #[must_use]
122 pub const fn dot(mut self) -> Self {
123 self.show_dot = true;
124 self
125 }
126
127 #[must_use]
129 pub const fn size(mut self, size: f32) -> Self {
130 self.custom_font_size = Some(size);
131 self
132 }
133
134 #[must_use]
136 pub const fn removable(mut self) -> Self {
137 self.removable = true;
138 self
139 }
140
141 #[must_use]
143 pub const fn corner_radius(mut self, radius: f32) -> Self {
144 self.custom_corner_radius = Some(radius);
145 self
146 }
147
148 #[must_use]
150 pub const fn vertical_padding(mut self, padding: f32) -> Self {
151 self.custom_vertical_padding = Some(padding);
152 self
153 }
154
155 #[must_use]
157 pub const fn height(mut self, height: f32) -> Self {
158 self.custom_height = Some(height);
159 self
160 }
161
162 #[must_use]
164 pub const fn min_width(mut self, width: f32) -> Self {
165 self.min_width = Some(width);
166 self
167 }
168
169 #[must_use]
171 pub const fn selected(mut self, selected: bool) -> Self {
172 self.is_selected = selected;
173 self
174 }
175
176 pub fn show(self, ui: &mut Ui) -> BadgeResponse {
178 let theme = ui.ctx().armas_theme();
179 let (bg_color, text_color, border_color) = self.get_colors(&theme);
180
181 let font_size = self.custom_font_size.unwrap_or(theme.typography.sm);
183 let corner_radius = self.custom_corner_radius.unwrap_or(CORNER_RADIUS);
184 let padding_y = self.custom_vertical_padding.unwrap_or(PADDING_Y);
185
186 let font_id = egui::FontId::proportional(font_size);
188 let text_galley =
189 ui.painter()
190 .layout_no_wrap(self.text.clone(), font_id.clone(), text_color);
191 let text_width = text_galley.rect.width();
192
193 let dot_space = if self.show_dot { 12.0 } else { 0.0 };
194 let remove_space = if self.removable { 16.0 } else { 0.0 };
195
196 let content_width = text_width + dot_space + remove_space + PADDING_X * 2.0;
197 let width = self
198 .min_width
199 .map_or(content_width, |min_w| content_width.max(min_w));
200 let height = self
201 .custom_height
202 .unwrap_or(font_size + padding_y * 2.0 + 4.0);
203
204 let (rect, response) =
206 ui.allocate_exact_size(Vec2::new(width, height), egui::Sense::click());
207
208 match self.variant {
210 BadgeVariant::Outline => {
211 ui.painter().rect_stroke(
213 rect,
214 corner_radius,
215 egui::Stroke::new(1.0, border_color),
216 egui::StrokeKind::Inside,
217 );
218 }
219 _ => {
220 ui.painter().rect_filled(rect, corner_radius, bg_color);
222 }
223 }
224
225 let mut x = rect.min.x + PADDING_X;
226
227 if self.show_dot {
229 let dot_center = Pos2::new(x + 3.0, rect.center().y);
230 ui.painter().circle_filled(dot_center, 3.0, text_color);
231 x += 12.0;
232 }
233
234 let text_pos = if self.show_dot || self.removable {
236 Pos2::new(x, rect.center().y)
237 } else {
238 rect.center()
239 };
240 let text_align = if self.show_dot || self.removable {
241 egui::Align2::LEFT_CENTER
242 } else {
243 egui::Align2::CENTER_CENTER
244 };
245
246 ui.painter()
247 .text(text_pos, text_align, &self.text, font_id, text_color);
248
249 let mut was_clicked = false;
251 if self.removable {
252 x += text_width + 4.0;
253 let remove_rect = egui::Rect::from_center_size(
254 Pos2::new(x + 6.0, rect.center().y),
255 Vec2::splat(12.0),
256 );
257
258 let is_hovered = ui.rect_contains_pointer(remove_rect);
259
260 if is_hovered {
261 ui.painter().circle_filled(
262 remove_rect.center(),
263 6.0,
264 text_color.gamma_multiply(0.2),
265 );
266 }
267
268 let cross_size = 3.0;
270 let center = remove_rect.center();
271 ui.painter().line_segment(
272 [
273 Pos2::new(center.x - cross_size, center.y - cross_size),
274 Pos2::new(center.x + cross_size, center.y + cross_size),
275 ],
276 egui::Stroke::new(1.5, text_color),
277 );
278 ui.painter().line_segment(
279 [
280 Pos2::new(center.x + cross_size, center.y - cross_size),
281 Pos2::new(center.x - cross_size, center.y + cross_size),
282 ],
283 egui::Stroke::new(1.5, text_color),
284 );
285
286 if is_hovered && ui.input(|i| i.pointer.primary_clicked()) {
287 was_clicked = true;
288 }
289 }
290
291 BadgeResponse {
292 clicked: response.clicked(),
293 removed: was_clicked,
294 response,
295 }
296 }
297
298 pub fn show_ui(
322 self,
323 ui: &mut Ui,
324 content: impl FnOnce(&mut Ui, &ContentContext),
325 ) -> BadgeResponse {
326 let theme = ui.ctx().armas_theme();
327 let (bg_color, text_color, border_color) = self.get_colors(&theme);
328
329 let font_size = self.custom_font_size.unwrap_or(theme.typography.sm);
330 let corner_radius = self.custom_corner_radius.unwrap_or(CORNER_RADIUS);
331 let padding_y = self.custom_vertical_padding.unwrap_or(PADDING_Y);
332
333 let dot_space = if self.show_dot { 12.0 } else { 0.0 };
334 let remove_space = if self.removable { 16.0 } else { 0.0 };
335 let height = self
336 .custom_height
337 .unwrap_or(font_size + padding_y * 2.0 + 4.0);
338
339 let base_width = dot_space + remove_space + PADDING_X * 2.0 + height;
341 let width = self
342 .min_width
343 .map_or(base_width, |min_w| base_width.max(min_w));
344
345 let (rect, response) =
346 ui.allocate_exact_size(Vec2::new(width, height), egui::Sense::click());
347
348 match self.variant {
350 BadgeVariant::Outline => {
351 ui.painter().rect_stroke(
352 rect,
353 corner_radius,
354 egui::Stroke::new(1.0, border_color),
355 egui::StrokeKind::Inside,
356 );
357 }
358 _ => {
359 ui.painter().rect_filled(rect, corner_radius, bg_color);
360 }
361 }
362
363 let mut x = rect.min.x + PADDING_X;
364
365 if self.show_dot {
367 let dot_center = Pos2::new(x + 3.0, rect.center().y);
368 ui.painter().circle_filled(dot_center, 3.0, text_color);
369 x += 12.0;
370 }
371
372 let content_right = if self.removable {
374 rect.max.x - PADDING_X - 16.0
375 } else {
376 rect.max.x - PADDING_X
377 };
378 let content_rect = egui::Rect::from_min_max(
379 Pos2::new(x, rect.min.y),
380 Pos2::new(content_right, rect.max.y),
381 );
382
383 let mut child_ui = ui.new_child(
384 egui::UiBuilder::new()
385 .max_rect(content_rect)
386 .layout(egui::Layout::left_to_right(egui::Align::Center)),
387 );
388 child_ui.style_mut().visuals.override_text_color = Some(text_color);
389
390 let ctx = ContentContext {
391 color: text_color,
392 font_size,
393 is_active: self.is_selected,
394 };
395 content(&mut child_ui, &ctx);
396
397 let mut was_clicked = false;
399 if self.removable {
400 let remove_x = content_right + 4.0;
401 let remove_rect = egui::Rect::from_center_size(
402 Pos2::new(remove_x + 6.0, rect.center().y),
403 Vec2::splat(12.0),
404 );
405
406 let is_hovered = ui.rect_contains_pointer(remove_rect);
407 if is_hovered {
408 ui.painter().circle_filled(
409 remove_rect.center(),
410 6.0,
411 text_color.gamma_multiply(0.2),
412 );
413 }
414
415 let cross_size = 3.0;
416 let center = remove_rect.center();
417 ui.painter().line_segment(
418 [
419 Pos2::new(center.x - cross_size, center.y - cross_size),
420 Pos2::new(center.x + cross_size, center.y + cross_size),
421 ],
422 egui::Stroke::new(1.5, text_color),
423 );
424 ui.painter().line_segment(
425 [
426 Pos2::new(center.x + cross_size, center.y - cross_size),
427 Pos2::new(center.x - cross_size, center.y + cross_size),
428 ],
429 egui::Stroke::new(1.5, text_color),
430 );
431
432 if is_hovered && ui.input(|i| i.pointer.primary_clicked()) {
433 was_clicked = true;
434 }
435 }
436
437 BadgeResponse {
438 clicked: response.clicked(),
439 removed: was_clicked,
440 response,
441 }
442 }
443
444 const fn get_colors(&self, theme: &Theme) -> (Color32, Color32, Color32) {
446 if let Some(color) = self.custom_color {
448 return (color, theme.primary_foreground(), color);
449 }
450
451 if self.is_selected {
453 return (theme.primary(), theme.primary_foreground(), theme.primary());
454 }
455
456 match self.variant {
457 BadgeVariant::Default => (theme.primary(), theme.primary_foreground(), theme.primary()),
458 BadgeVariant::Secondary => (
459 theme.secondary(),
460 theme.secondary_foreground(),
461 theme.secondary(),
462 ),
463 BadgeVariant::Destructive => (
464 theme.destructive(),
465 theme.destructive_foreground(),
466 theme.destructive(),
467 ),
468 BadgeVariant::Outline => (Color32::TRANSPARENT, theme.foreground(), theme.border()),
469 }
470 }
471}
472
473impl Default for Badge {
474 fn default() -> Self {
475 Self::new("")
476 }
477}
478
479#[derive(Debug, Clone)]
481pub struct BadgeResponse {
482 pub clicked: bool,
484 pub removed: bool,
486 pub response: egui::Response,
488}
489
490pub struct NotificationBadge {
492 count: usize,
494 max_count: Option<usize>,
496 color: Option<Color32>,
498 size: f32,
500}
501
502impl NotificationBadge {
503 #[must_use]
506 pub const fn new(count: usize) -> Self {
507 Self {
508 count,
509 max_count: Some(99),
510 color: None,
511 size: 18.0,
512 }
513 }
514
515 #[must_use]
517 pub const fn max_count(mut self, max: usize) -> Self {
518 self.max_count = Some(max);
519 self
520 }
521
522 #[must_use]
524 pub const fn color(mut self, color: Color32) -> Self {
525 self.color = Some(color);
526 self
527 }
528
529 #[must_use]
531 pub const fn size(mut self, size: f32) -> Self {
532 self.size = size;
533 self
534 }
535
536 pub fn show(self, ui: &mut Ui) -> Response {
538 let theme = ui.ctx().armas_theme();
539 let color = self.color.unwrap_or_else(|| theme.destructive());
540
541 let text = self.max_count.map_or_else(
542 || self.count.to_string(),
543 |max| {
544 if self.count > max {
545 format!("{max}+")
546 } else {
547 self.count.to_string()
548 }
549 },
550 );
551
552 let (rect, response) = ui.allocate_exact_size(Vec2::splat(self.size), egui::Sense::hover());
553
554 ui.painter()
556 .circle_filled(rect.center(), self.size / 2.0, color);
557
558 ui.painter().text(
560 rect.center(),
561 egui::Align2::CENTER_CENTER,
562 &text,
563 egui::FontId::proportional(self.size * 0.6),
564 theme.primary_foreground(),
565 );
566
567 response
568 }
569}