1use 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#[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#[derive(Debug)]
37pub struct Badge<'a> {
38 label: text::Fragment<'a>,
39 icon: Option<Glyph>,
40 variant: Variant,
41}
42
43pub 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 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#[derive(Debug, Clone, Copy, PartialEq)]
91pub struct Colours {
92 pub background: Option<Color>,
93 pub foreground: Color,
94 pub border: Option<Color>,
95}
96
97pub 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
119pub 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}