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