Skip to main content

sequoia_elements/
badge.rs

1use sequoia_core::{Color, Edges, Length, Size};
2use sequoia_layout::{LayoutCtx, LayoutStyle};
3use sequoia_style::BadgeStyle;
4use sequoia_view::{FontWeight, PaintCtx, Unit, View};
5
6const PAD_X: f32 = 8.0;
7const PAD_Y: f32 = 2.0;
8const RADIUS: f32 = 999.0;
9const FONT_SIZE: f32 = 12.0;
10const BORDER_W: f32 = 1.0;
11const DOT_PX: f32 = 8.0;
12
13pub fn badge_label(count: usize, max: usize) -> String {
14    if count <= max {
15        count.to_string()
16    } else {
17        format!("{max}+")
18    }
19}
20
21pub struct Badge {
22    label: String,
23    dot: bool,
24    style: Option<BadgeStyle>,
25    background: Option<Color>,
26    border: Option<Color>,
27    text_color: Option<Color>,
28    font_size: Option<f32>,
29    radius: Option<f32>,
30    padding: Option<(f32, f32)>,
31    margin: Edges,
32}
33
34impl Badge {
35    pub fn new(label: impl Into<String>) -> Self {
36        Self {
37            label: label.into(),
38            dot: false,
39            style: None,
40            background: None,
41            border: None,
42            text_color: None,
43            font_size: None,
44            radius: None,
45            padding: None,
46            margin: Edges::ZERO,
47        }
48    }
49
50    pub fn count(count: usize) -> Self {
51        Self::new(count.to_string())
52    }
53
54    pub fn count_capped(count: usize, max: usize) -> Self {
55        Self::new(badge_label(count, max))
56    }
57
58    pub fn dot() -> Self {
59        Self {
60            label: String::new(),
61            dot: true,
62            style: None,
63            background: None,
64            border: None,
65            text_color: None,
66            font_size: None,
67            radius: None,
68            padding: None,
69            margin: Edges::ZERO,
70        }
71    }
72
73    pub fn style(mut self, style: BadgeStyle) -> Self {
74        self.style = Some(style);
75        self
76    }
77
78    pub fn background(mut self, color: Color) -> Self {
79        self.background = Some(color);
80        self
81    }
82
83    pub fn border(mut self, color: Color) -> Self {
84        self.border = Some(color);
85        self
86    }
87
88    pub fn text_color(mut self, color: Color) -> Self {
89        self.text_color = Some(color);
90        self
91    }
92
93    pub fn font_size(mut self, size: f32) -> Self {
94        self.font_size = Some(size);
95        self
96    }
97
98    pub fn radius(mut self, radius: f32) -> Self {
99        self.radius = Some(radius);
100        self
101    }
102
103    pub fn padding_xy(mut self, pad_x: f32, pad_y: f32) -> Self {
104        self.padding = Some((pad_x, pad_y));
105        self
106    }
107
108    pub fn margin(mut self, margin: impl Into<Edges>) -> Self {
109        let m = margin.into();
110        self.margin = Edges::only(
111            sanitize_margin(m.left),
112            sanitize_margin(m.top),
113            sanitize_margin(m.right),
114            sanitize_margin(m.bottom),
115        );
116        self
117    }
118
119    pub fn margin_xy(self, x: f32, y: f32) -> Self {
120        self.margin(Edges::xy(x, y))
121    }
122
123    pub fn margin_x(mut self, x: f32) -> Self {
124        let x = sanitize_margin(x);
125        self.margin.left = x;
126        self.margin.right = x;
127        self
128    }
129
130    pub fn margin_y(mut self, y: f32) -> Self {
131        let y = sanitize_margin(y);
132        self.margin.top = y;
133        self.margin.bottom = y;
134        self
135    }
136
137    pub fn margin_left(mut self, v: f32) -> Self {
138        self.margin.left = sanitize_margin(v);
139        self
140    }
141
142    pub fn margin_right(mut self, v: f32) -> Self {
143        self.margin.right = sanitize_margin(v);
144        self
145    }
146
147    pub fn margin_top(mut self, v: f32) -> Self {
148        self.margin.top = sanitize_margin(v);
149        self
150    }
151
152    pub fn margin_bottom(mut self, v: f32) -> Self {
153        self.margin.bottom = sanitize_margin(v);
154        self
155    }
156
157    fn font_size_resolved(&self, style: BadgeStyle) -> f32 {
158        self.font_size.or(style.font_size).unwrap_or(FONT_SIZE)
159    }
160
161    fn padding_resolved(&self, style: BadgeStyle) -> (f32, f32) {
162        self.padding.or(style.padding).unwrap_or((PAD_X, PAD_Y))
163    }
164}
165
166fn sanitize_margin(v: f32) -> f32 {
167    if v.is_finite() { v.max(0.0) } else { 0.0 }
168}
169
170impl View for Badge {
171    fn body(&self) -> impl View + 'static {
172        Unit
173    }
174    fn is_leaf(&self) -> bool {
175        true
176    }
177    fn hit_testable(&self) -> bool {
178        false
179    }
180    fn layout_style(&self) -> LayoutStyle {
181        LayoutStyle {
182            width: Length::Fit,
183            height: Length::Fit,
184            margin: self.margin,
185            ..LayoutStyle::default().hit_testable(false)
186        }
187    }
188    fn intrinsic_size(&self, ctx: &mut LayoutCtx) -> Option<Size> {
189        if self.dot {
190            return Some(Size::new(DOT_PX, DOT_PX));
191        }
192        let style = self.style.unwrap_or_default();
193        let size = self.font_size_resolved(style);
194        let (pad_x, pad_y) = self.padding_resolved(style);
195        let w = ctx.text_width_weighted(&self.label, size, FontWeight::SEMIBOLD);
196        Some(Size::new(
197            w + pad_x * 2.0,
198            LayoutCtx::text_line_height(size) + pad_y * 2.0,
199        ))
200    }
201    fn paint_self(&self, ctx: &mut PaintCtx) {
202        let rect = ctx.rect;
203        if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
204            return;
205        }
206        let theme = ctx.theme();
207        let style = self.style.unwrap_or_default();
208
209        let background = self
210            .background
211            .or(style.background)
212            .unwrap_or(Color::hex(0x3A3A3A));
213        if self.dot {
214            ctx.quad(
215                rect,
216                background,
217                rect.size.width.min(rect.size.height) * 0.5,
218            );
219            return;
220        }
221        if self.label.is_empty() {
222            return;
223        }
224        let size = self.font_size_resolved(style);
225        let (pad_x, _) = self.padding_resolved(style);
226        let border = self.border.or(style.border).unwrap_or(Color::TRANSPARENT);
227        let text = self.text_color.or(style.text).unwrap_or(theme.text);
228        let radius = self.radius.or(style.radius).unwrap_or(RADIUS);
229        if border.a > 0.0 {
230            ctx.bordered_quad(rect, background, radius, BORDER_W, border);
231        } else {
232            ctx.quad(rect, background, radius);
233        }
234        let x = rect.left() + pad_x;
235        let y = rect.top() + rect.size.height * 0.5 + size * 0.35;
236        ctx.draw_text_weighted(&self.label, (x, y), size, FontWeight::SEMIBOLD, text);
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn label_caps_at_max() {
246        assert_eq!(badge_label(7, 99), "7");
247        assert_eq!(badge_label(99, 99), "99");
248        assert_eq!(badge_label(100, 99), "99+");
249        assert_eq!(badge_label(0, 9), "0");
250    }
251}