Skip to main content

iced_cube/feedback/
badge.rs

1//! Short status or category markers.
2
3use iced::widget::{container, row, text};
4use iced::{Alignment, Background, Border, Color, Element, Padding, Shadow};
5
6use crate::icon::{Glyph, themed};
7use crate::natural::natural;
8use crate::theme::{Tokens, on, radius, text_size};
9
10const ICON_SIZE: f32 = 12.0;
11
12/// Visual emphasis of a badge.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
14pub enum Variant {
15    #[default]
16    Default,
17    Secondary,
18    Outline,
19    Destructive,
20    Success,
21    Warning,
22}
23
24impl Variant {
25    pub const ALL: [Variant; 6] = [
26        Variant::Default,
27        Variant::Secondary,
28        Variant::Outline,
29        Variant::Destructive,
30        Variant::Success,
31        Variant::Warning,
32    ];
33}
34
35/// A badge builder. Convert it into an [`Element`] to render.
36#[derive(Debug)]
37pub struct Badge<'a> {
38    label: text::Fragment<'a>,
39    icon: Option<Glyph>,
40    variant: Variant,
41}
42
43/// Creates a badge with a text label.
44pub fn badge<'a>(label: impl text::IntoFragment<'a>) -> Badge<'a> {
45    Badge {
46        label: label.into_fragment(),
47        icon: None,
48        variant: Variant::default(),
49    }
50}
51
52impl Badge<'_> {
53    pub fn variant(mut self, variant: Variant) -> Self {
54        self.variant = variant;
55        self
56    }
57
58    /// Adds an icon before the label.
59    pub fn icon(mut self, glyph: Glyph) -> Self {
60        self.icon = Some(glyph);
61        self
62    }
63}
64
65impl<'a, Message: 'a> From<Badge<'a>> for Element<'a, Message> {
66    fn from(badge: Badge<'a>) -> Self {
67        let variant = badge.variant;
68
69        let mut content = row![].spacing(4).align_y(Alignment::Center);
70        if let Some(glyph) = badge.icon {
71            content = content.push(themed(glyph, ICON_SIZE, 1.0, move |theme| {
72                colours(&Tokens::of(theme), variant).foreground
73            }));
74        }
75        content = content.push(
76            text(badge.label)
77                .size(text_size::XS)
78                .wrapping(text::Wrapping::None),
79        );
80
81        natural(
82            container(content)
83                .padding(Padding::from([2.0, 10.0]))
84                .style(move |theme| style(&Tokens::of(theme), variant)),
85        )
86    }
87}
88
89/// Background, foreground and border colours for a variant.
90#[derive(Debug, Clone, Copy, PartialEq)]
91pub struct Colours {
92    pub background: Option<Color>,
93    pub foreground: Color,
94    pub border: Option<Color>,
95}
96
97/// Resolves the colours of a variant.
98pub fn colours(tokens: &Tokens, variant: Variant) -> Colours {
99    let filled = |background: Color, foreground: Color| Colours {
100        background: Some(background),
101        foreground,
102        border: None,
103    };
104
105    match variant {
106        Variant::Default => filled(tokens.primary, tokens.primary_foreground),
107        Variant::Secondary => filled(tokens.secondary, tokens.secondary_foreground),
108        Variant::Outline => Colours {
109            background: None,
110            foreground: tokens.foreground,
111            border: Some(tokens.border),
112        },
113        Variant::Destructive => filled(tokens.destructive, tokens.destructive_foreground),
114        Variant::Success => filled(tokens.success, on(tokens.success)),
115        Variant::Warning => filled(tokens.warning, on(tokens.warning)),
116    }
117}
118
119/// The container style for a variant.
120pub fn style(tokens: &Tokens, variant: Variant) -> container::Style {
121    let colours = colours(tokens, variant);
122    container::Style {
123        text_color: Some(colours.foreground),
124        background: colours.background.map(Background::Color),
125        border: Border {
126            color: colours.border.unwrap_or(Color::TRANSPARENT),
127            width: if colours.border.is_some() { 1.0 } else { 0.0 },
128            radius: radius::FULL.into(),
129        },
130        shadow: Shadow::default(),
131        snap: true,
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::theme::{dark, light};
139
140    #[test]
141    fn default_builder_is_default_variant_without_icon() {
142        let b = badge("New");
143        assert_eq!(b.variant, Variant::Default);
144        assert!(b.icon.is_none());
145    }
146
147    #[test]
148    fn icon_and_variant_are_kept() {
149        let b = badge("Live")
150            .icon(crate::lucide!(CircleDot))
151            .variant(Variant::Success);
152        assert_eq!(b.variant, Variant::Success);
153        assert_eq!(b.icon.map(Glyph::name), Some("circle-dot"));
154    }
155
156    #[test]
157    fn only_outline_has_a_border_and_no_fill() {
158        for theme in [light(), dark()] {
159            let tokens = Tokens::of(&theme);
160            for variant in Variant::ALL {
161                let style = style(&tokens, variant);
162                let outline = variant == Variant::Outline;
163                assert_eq!(style.border.width > 0.0, outline, "{variant:?}");
164                assert_eq!(style.background.is_none(), outline, "{variant:?}");
165            }
166        }
167    }
168
169    #[test]
170    fn foreground_differs_from_background_in_every_variant() {
171        for theme in [light(), dark()] {
172            let tokens = Tokens::of(&theme);
173            for variant in Variant::ALL {
174                let colours = colours(&tokens, variant);
175                let behind = colours.background.unwrap_or(tokens.background);
176                assert_ne!(colours.foreground, behind, "{variant:?}");
177            }
178        }
179    }
180
181    #[test]
182    fn status_variants_use_status_tokens() {
183        for theme in [light(), dark()] {
184            let tokens = Tokens::of(&theme);
185            assert_eq!(
186                colours(&tokens, Variant::Success).background,
187                Some(tokens.success)
188            );
189            assert_eq!(
190                colours(&tokens, Variant::Warning).background,
191                Some(tokens.warning)
192            );
193            assert_eq!(
194                colours(&tokens, Variant::Destructive).background,
195                Some(tokens.destructive)
196            );
197        }
198    }
199
200    #[test]
201    fn badges_are_pills() {
202        let tokens = Tokens::of(&light());
203        for variant in Variant::ALL {
204            assert_eq!(style(&tokens, variant).border.radius, radius::FULL.into());
205        }
206    }
207}