use egui::{Align2, Color32, Rect, Stroke, TextWrapMode, TextStyle, Ui, Vec2, WidgetText};
#[cfg(test)]
use egui::Pos2;
use crate::theme::Theme;
const DEFAULT_MIN_DIAMETER: f32 = 16.0;
const DEFAULT_INSET: f32 = 2.0;
const DEFAULT_PADDING: f32 = 3.0;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum BadgeCorner {
TopLeft,
#[default]
TopRight,
BottomLeft,
BottomRight,
}
impl BadgeCorner {
pub const fn align2(self) -> Align2 {
match self {
Self::TopLeft => Align2::LEFT_TOP,
Self::TopRight => Align2::RIGHT_TOP,
Self::BottomLeft => Align2::LEFT_BOTTOM,
Self::BottomRight => Align2::RIGHT_BOTTOM,
}
}
pub const fn to_str(self) -> &'static str {
match self {
Self::TopLeft => "Top left",
Self::TopRight => "Top right",
Self::BottomLeft => "Bottom left",
Self::BottomRight => "Bottom right",
}
}
pub const fn to_vec() -> &'static [Self] {
&[Self::TopLeft, Self::TopRight, Self::BottomLeft, Self::BottomRight]
}
}
#[derive(Clone, Debug)]
#[must_use = "Badges should be shown on a widget, e.g. `Button::new(\"…\").badge(badge)`"]
pub struct Badge {
text: WidgetText,
color: Option<Color32>,
corner: BadgeCorner,
text_color: Option<Color32>,
min_diameter: f32,
inset: f32,
padding: f32,
outline: Option<Stroke>,
}
impl Badge {
pub fn new(text: impl Into<WidgetText>) -> Self {
Self {
text: text.into(),
color: None,
corner: BadgeCorner::default(),
text_color: None,
min_diameter: DEFAULT_MIN_DIAMETER,
inset: DEFAULT_INSET,
padding: DEFAULT_PADDING,
outline: None,
}
}
pub fn dot() -> Self {
Self::new("")
}
#[inline]
pub fn color(mut self, color: Color32) -> Self {
self.color = Some(color);
self
}
#[inline]
pub fn corner(mut self, corner: BadgeCorner) -> Self {
self.corner = corner;
self
}
#[inline]
pub fn text_color(mut self, text_color: Color32) -> Self {
self.text_color = Some(text_color);
self
}
#[inline]
pub fn min_diameter(mut self, min_diameter: f32) -> Self {
self.min_diameter = min_diameter;
self
}
#[inline]
pub fn inset(mut self, inset: f32) -> Self {
self.inset = inset;
self
}
#[inline]
pub fn padding(mut self, padding: f32) -> Self {
self.padding = padding;
self
}
#[inline]
pub fn outline(mut self, outline: impl Into<Stroke>) -> Self {
self.outline = Some(outline.into());
self
}
fn circle_rect(&self, text_size: Vec2, widget_rect: Rect) -> Rect {
let diameter = (text_size + Vec2::splat(2.0 * self.padding))
.max_elem()
.max(self.min_diameter);
self
.corner
.align2()
.align_size_within_rect(Vec2::splat(diameter), widget_rect.shrink(self.inset))
}
pub fn paint_at(&self, ui: &Ui, widget_rect: Rect) {
if !ui.is_rect_visible(widget_rect) {
return;
}
let theme = Theme::current(ui.ctx());
let fill = self.color.unwrap_or(theme.colors.error);
let text_color = self.text_color.unwrap_or(theme.colors.bg);
let galley = self.text.clone().into_galley(
ui,
Some(TextWrapMode::Extend),
f32::INFINITY,
TextStyle::Small,
);
let circle = self.circle_rect(galley.size(), widget_rect);
let center = circle.center();
let painter = ui.painter();
painter.circle(
center,
circle.width() * 0.5,
fill,
self.outline.unwrap_or(Stroke::NONE),
);
if !galley.is_empty() {
painter.galley(center - galley.size() * 0.5, galley, text_color);
}
}
#[cfg(test)]
fn center_for(&self, text_size: Vec2, widget_rect: Rect) -> Pos2 {
self.circle_rect(text_size, widget_rect).center()
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui::vec2;
fn rect() -> Rect {
Rect::from_min_size(Pos2::new(100.0, 100.0), vec2(200.0, 40.0))
}
const TEXT: Vec2 = Vec2::new(8.0, 12.0);
#[test]
fn corners_anchor_to_the_matching_side() {
let r = rect();
let inset = DEFAULT_INSET;
let pad = DEFAULT_PADDING;
let min = DEFAULT_MIN_DIAMETER;
let expected_diameter = (TEXT.x + 2.0 * pad).max(TEXT.y + 2.0 * pad).max(min);
let radius = expected_diameter * 0.5;
let cases = [
(BadgeCorner::TopLeft, r.left() + inset + radius, r.top() + inset + radius),
(BadgeCorner::TopRight, r.right() - inset - radius, r.top() + inset + radius),
(BadgeCorner::BottomLeft, r.left() + inset + radius, r.bottom() - inset - radius),
(
BadgeCorner::BottomRight,
r.right() - inset - radius,
r.bottom() - inset - radius,
),
];
for (corner, x, y) in cases {
let center = Badge::dot().corner(corner).center_for(TEXT, r);
assert!(
(center.x - x).abs() < 0.01 && (center.y - y).abs() < 0.01,
"{corner:?}: expected ({x}, {y}), got {center:?}"
);
}
}
#[test]
fn circle_covers_text_and_min_diameter() {
let badge = Badge::new("12");
let small = badge.clone().circle_rect(Vec2::ZERO, rect());
assert_eq!(small.width(), DEFAULT_MIN_DIAMETER);
let wide = badge.circle_rect(vec2(40.0, 12.0), rect());
assert_eq!(wide.width(), 40.0 + 2.0 * DEFAULT_PADDING);
}
#[test]
fn negative_inset_lets_the_badge_straddle_the_corner() {
let inside = Badge::dot().center_for(TEXT, rect());
let outside = Badge::dot().inset(-6.0).center_for(TEXT, rect());
assert!(outside.x > inside.x);
}
#[test]
fn defaults_are_top_right_error_and_bg_text() {
let badge = Badge::dot();
assert_eq!(badge.corner, BadgeCorner::TopRight);
assert_eq!(badge.color, None);
assert_eq!(badge.text_color, None);
}
#[test]
fn paints_without_panicking() {
egui::__run_test_ui(|ui| {
let response = ui.add(crate::widgets::Button::new("Balance & approvals").badge(
Badge::new("3")
.corner(BadgeCorner::TopRight)
.outline(Stroke::new(1.5, Color32::BLACK)),
));
assert!(response.rect.width() > 0.0);
});
}
}