gpui_kit_material/components/
badge.rs1use super::feedback::{LARGE_BADGE_PADDING, LARGE_BADGE_SIZE, SMALL_BADGE_SIZE};
12use super::text::accessible_text;
13use crate::color::ColorRole;
14use crate::motion::{self, MotionToken};
15use crate::theme::color;
16use crate::tokens::{shape::RADIUS_FULL, typography::*};
17use gpui::{prelude::*, *};
18
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
21pub enum BadgeColor {
22 #[default]
24 Error,
25 Primary,
26 Secondary,
27 Tertiary,
28 Success,
29 Warning,
30 Info,
31}
32
33impl BadgeColor {
34 pub const ALL: [Self; 7] = [
35 Self::Error,
36 Self::Primary,
37 Self::Secondary,
38 Self::Tertiary,
39 Self::Success,
40 Self::Warning,
41 Self::Info,
42 ];
43
44 pub const fn roles(self) -> (ColorRole, ColorRole) {
46 match self {
47 Self::Error => (ColorRole::ERROR, ColorRole::ON_ERROR),
48 Self::Primary => (ColorRole::PRIMARY, ColorRole::ON_PRIMARY),
49 Self::Secondary => (ColorRole::SECONDARY, ColorRole::ON_SECONDARY),
50 Self::Tertiary => (ColorRole::TERTIARY, ColorRole::ON_TERTIARY),
51 Self::Success => (ColorRole::SUCCESS, ColorRole::ON_SUCCESS),
52 Self::Warning => (ColorRole::WARNING, ColorRole::ON_WARNING),
53 Self::Info => (ColorRole::INFO, ColorRole::ON_INFO),
54 }
55 }
56}
57
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
60pub enum BadgePosition {
61 #[default]
62 TopRight,
63 TopLeft,
64 BottomRight,
65 BottomLeft,
66}
67
68impl BadgePosition {
69 pub const ALL: [Self; 4] = [
70 Self::TopRight,
71 Self::TopLeft,
72 Self::BottomRight,
73 Self::BottomLeft,
74 ];
75 const fn top(self) -> bool {
76 matches!(self, Self::TopRight | Self::TopLeft)
77 }
78 const fn right(self) -> bool {
79 matches!(self, Self::TopRight | Self::BottomRight)
80 }
81}
82
83const LARGE_ACROSS: f32 = 12.;
86const LARGE_OVERHANG: f32 = 2.;
87
88pub fn badge_label(label: &str, max: Option<u32>) -> SharedString {
91 match (label.trim().parse::<u64>(), max) {
92 (Ok(count), Some(max)) if count > u64::from(max) => format!("{max}+").into(),
93 _ => SharedString::from(label.to_owned()),
94 }
95}
96
97#[derive(gpui::IntoElement)]
106pub struct Badge {
107 id: ElementId,
108 label: Option<SharedString>,
109 max_count: Option<u32>,
110 color: BadgeColor,
111 position: BadgePosition,
112 visible: bool,
113 inset: f32,
114 anchor: Option<AnyElement>,
115}
116
117impl Badge {
118 pub fn small(id: impl Into<ElementId>) -> Self {
120 Self {
121 id: id.into(),
122 label: None,
123 max_count: None,
124 color: BadgeColor::Error,
125 position: BadgePosition::TopRight,
126 visible: true,
127 inset: 0.,
128 anchor: None,
129 }
130 }
131
132 pub fn large(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
134 Self {
135 label: Some(label.into()),
136 ..Self::small(id)
137 }
138 }
139
140 pub fn max_count(mut self, max: u32) -> Self {
142 self.max_count = Some(max);
143 self
144 }
145
146 pub fn color(mut self, color: BadgeColor) -> Self {
147 self.color = color;
148 self
149 }
150
151 pub fn position(mut self, position: BadgePosition) -> Self {
152 self.position = position;
153 self
154 }
155
156 pub fn visible(mut self, visible: bool) -> Self {
158 self.visible = visible;
159 self
160 }
161
162 pub fn inset(mut self, inset: f32) -> Self {
165 self.inset = inset;
166 self
167 }
168
169 pub fn anchor(mut self, anchor: impl IntoElement) -> Self {
171 self.anchor = Some(anchor.into_any_element());
172 self
173 }
174
175 pub fn text(&self) -> Option<SharedString> {
177 self.label
178 .as_deref()
179 .map(|label| badge_label(label, self.max_count))
180 }
181}
182
183impl RenderOnce for Badge {
184 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
185 let text = self.text();
186 let (container, ink) = self.color.roles();
187 let id = self.id;
188 let child = |name: &'static str| ElementId::NamedChild(id.clone().into(), name.into());
189 let target = if self.visible { 1. } else { 0. };
190 let fade = motion::token_value(child("fade"), target, MotionToken::FastEffects, window, cx)
191 .clamp(0., 1.);
192 let grow = motion::token_value(child("grow"), target, MotionToken::FastSpatial, window, cx)
193 .max(0.);
194 let hidden = !self.visible && fade <= 0.;
195 let position = self.position;
196 let inset = self.inset;
197 let badge = match text {
198 Some(text) => {
199 let scale = TypeRole::LabelSmall.scale();
200 accessible_text(child("label"), text)
201 .flex()
202 .flex_shrink_0()
203 .items_center()
204 .justify_center()
205 .h(LARGE_BADGE_SIZE)
206 .min_w(LARGE_BADGE_SIZE)
207 .px(LARGE_BADGE_PADDING)
208 .rounded(RADIUS_FULL)
209 .bg(color(container))
210 .text_color(color(ink))
211 .text_size(scale.font_size)
212 .line_height(scale.line_height)
213 .font_weight(FontWeight(scale.font_weight as f32))
214 .whitespace_nowrap()
215 .when(cfg!(test), |b| b.debug_selector(|| "badge-label".into()))
216 .opacity(fade)
217 .when(self.anchor.is_some(), |badge| {
218 let across = rem(inset + LARGE_ACROSS);
219 let overhang = rem(inset - LARGE_OVERHANG);
220 badge
221 .absolute()
222 .map(|b| {
223 if position.top() {
224 b.top(overhang)
225 } else {
226 b.bottom(overhang)
227 }
228 })
229 .map(|b| {
230 if position.right() {
231 b.left(across)
232 } else {
233 b.right(across)
234 }
235 })
236 })
237 .into_any_element()
238 }
239 None => {
240 let size = SMALL_BADGE_SIZE * grow;
242 div()
243 .id(child("dot"))
244 .flex_shrink_0()
245 .size(size)
246 .rounded(RADIUS_FULL)
247 .bg(color(container))
248 .when(cfg!(test), |b| b.debug_selector(|| "badge-dot".into()))
249 .opacity(fade)
250 .when(self.anchor.is_some(), |dot| {
251 let edge = rem(inset);
252 dot.absolute()
253 .map(|d| {
254 if position.top() {
255 d.top(edge)
256 } else {
257 d.bottom(edge)
258 }
259 })
260 .map(|d| {
261 if position.right() {
262 d.right(edge)
263 } else {
264 d.left(edge)
265 }
266 })
267 })
268 .into_any_element()
269 }
270 };
271 let badge = (!hidden).then_some(badge);
272 match self.anchor {
273 Some(anchor) => div()
274 .id(id)
275 .relative()
276 .flex_shrink_0()
277 .child(anchor)
278 .children(badge)
279 .into_any_element(),
280 None => div()
281 .id(id)
282 .flex_shrink_0()
283 .children(badge)
284 .into_any_element(),
285 }
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use core::prelude::v1::test;
293 use gpui::{Context, TestAppContext};
294
295 #[test]
296 fn counts_above_the_maximum_read_max_plus() {
297 assert_eq!(badge_label("8", Some(99)), "8");
298 assert_eq!(badge_label("100", Some(99)), "99+");
299 assert_eq!(badge_label("10", Some(9)), "9+");
300 assert_eq!(badge_label("999", Some(999)), "999");
301 assert_eq!(badge_label("1000", Some(999)), "999+");
302 assert_eq!(badge_label("New", Some(9)), "New");
304 assert_eq!(badge_label("100", None), "100");
305 }
306
307 #[test]
308 fn every_color_pairs_a_container_with_its_on_role() {
309 assert_eq!(
310 BadgeColor::default().roles(),
311 (ColorRole::ERROR, ColorRole::ON_ERROR)
312 );
313 let mut containers: Vec<_> = BadgeColor::ALL.iter().map(|c| c.roles().0).collect();
314 containers.dedup();
315 assert_eq!(containers.len(), BadgeColor::ALL.len());
316 assert_eq!(
317 BadgeColor::Success.roles(),
318 (ColorRole::SUCCESS, ColorRole::ON_SUCCESS)
319 );
320 }
321
322 struct Harness {
323 position: BadgePosition,
324 large: bool,
325 visible: bool,
326 }
327
328 impl Render for Harness {
329 fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
330 window.set_rem_size(px(BASE_PX));
331 let badge = if self.large {
332 Badge::large("badge", "1234").max_count(999)
333 } else {
334 Badge::small("badge")
335 };
336 div().flex().items_start().p(px(100.)).child(
337 badge
338 .position(self.position)
339 .visible(self.visible)
340 .anchor(div().debug_selector(|| "icon".into()).size(px(24.))),
341 )
342 }
343 }
344
345 #[gpui::test]
346 fn the_badge_sits_on_the_requested_corner_and_hides(cx: &mut TestAppContext) {
347 cx.update(|cx| {
348 gpui_base::init(cx);
349 cx.set_reduce_motion(true);
350 });
351 let (view, cx) = cx.add_window_view(|_, _| Harness {
352 position: BadgePosition::TopRight,
353 large: true,
354 visible: true,
355 });
356 let draw = |cx: &mut gpui::VisualTestContext| {
357 cx.update(|window, cx| {
358 window.refresh();
359 window.draw(cx).clear(cx);
360 });
361 cx.run_until_parked();
362 };
363 draw(cx);
364 let icon = cx.debug_bounds("icon").unwrap();
365 let label = cx
366 .debug_bounds("badge-label")
367 .expect("a large badge's label");
368 assert_eq!(label.left(), icon.left() + px(LARGE_ACROSS));
369 assert_eq!(label.top(), icon.top() - px(LARGE_OVERHANG));
370 view.update(cx, |h, cx| {
371 h.position = BadgePosition::BottomLeft;
372 cx.notify();
373 });
374 draw(cx);
375 let label = cx.debug_bounds("badge-label").unwrap();
376 assert_eq!(label.right(), icon.right() - px(LARGE_ACROSS));
377 assert_eq!(label.bottom(), icon.bottom() + px(LARGE_OVERHANG));
378 view.update(cx, |h, cx| {
379 h.visible = false;
380 cx.notify();
381 });
382 for _ in 0..3 {
383 cx.executor()
384 .advance_clock(std::time::Duration::from_secs(1));
385 draw(cx);
386 }
387 assert!(cx.debug_bounds("badge-label").is_none(), "hidden");
388 view.update(cx, |h, cx| {
390 h.large = false;
391 h.visible = true;
392 h.position = BadgePosition::TopRight;
393 cx.notify();
394 });
395 for _ in 0..3 {
396 cx.executor()
397 .advance_clock(std::time::Duration::from_secs(1));
398 draw(cx);
399 }
400 let dot = cx.debug_bounds("badge-dot").expect("a shown dot");
401 assert_eq!(dot.size, size(SMALL_BADGE_SIZE, SMALL_BADGE_SIZE));
402 assert_eq!((dot.right(), dot.top()), (icon.right(), icon.top()));
403 }
404}