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}