Skip to main content

gpui_kit_material/components/
badge.rs

1//! M3 badge as a configurable component: the small dot or the large label
2//! badge, in any of md3.io's colors, on any corner of the element it marks.
3//! Reference: the Figma kit's Badges page (6dp dot at the icon's corner; a
4//! 16dp label-small pill 12dp across and 14dp down from it) and
5//! <https://m3e.kyralabs.dev/components/badge/>.
6//!
7//! Showing and hiding fades on the fast effects spring; the dot also grows
8//! from its corner on the fast spatial spring (GPUI has no element scale
9//! transform, so a large badge, whose text cannot be scaled, only fades).
10//! Reduced motion keeps the fade and settles the growth at once.
11use 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/// A badge's container and label roles.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
21pub enum BadgeColor {
22    /// The M3 default: error on on-error.
23    #[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    /// The (container, label) roles.
45    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/// The corner of the anchored element a badge sits on.
59#[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
83/// The large badge sits 12dp across from the icon's edge and overhangs it
84/// by 2dp (its far edge 14dp in).
85const LARGE_ACROSS: f32 = 12.;
86const LARGE_OVERHANG: f32 = 2.;
87
88/// The text a large badge shows for `label` with at most `max` as a count:
89/// a number above `max` reads "max+"; other labels pass through.
90pub 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/// An M3 badge.
98///
99/// [`Badge::small`] is the 6dp dot; [`Badge::large`] a label-small pill
100/// around a count or short label ([`Self::max_count`] caps counts as
101/// "99+"). With [`Self::anchor`] it wraps the element it marks and sits on
102/// [`Self::position`]'s corner of that element's [`Self::inset`] box (the
103/// icon inside it); without one it renders alone. [`Self::visible`]
104/// animates it in and out. The caller supplies the label.
105#[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    /// The small badge: a dot without text.
119    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    /// The large badge around `label`, e.g. "3".
133    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    /// Show counts above `max` as "max+".
141    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    /// Controlled visibility; changes animate.
157    pub fn visible(mut self, visible: bool) -> Self {
158        self.visible = visible;
159        self
160    }
161
162    /// How far the marked icon sits inside the anchor, in design px (e.g.
163    /// 8 for a 24dp icon in a 40dp icon button).
164    pub fn inset(mut self, inset: f32) -> Self {
165        self.inset = inset;
166        self
167    }
168
169    /// The element the badge marks.
170    pub fn anchor(mut self, anchor: impl IntoElement) -> Self {
171        self.anchor = Some(anchor.into_any_element());
172        self
173    }
174
175    /// The label as shown.
176    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                // The dot grows from its corner.
241                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        // Labels that are not counts pass through.
303        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        // A dot sits in the corner itself.
389        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}