1use iced::widget::{column, progress_bar, row, space, text};
4use iced::{Background, Border, Color, Element, Length};
5
6use crate::theme::{Tokens, mix, radius, space as gap, text_size};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
10pub enum Variant {
11 #[default]
12 Default,
13 Success,
14 Warning,
15 Destructive,
16}
17
18impl Variant {
19 pub const ALL: [Variant; 4] = [
20 Variant::Default,
21 Variant::Success,
22 Variant::Warning,
23 Variant::Destructive,
24 ];
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
29pub enum Size {
30 Sm,
31 #[default]
32 Md,
33 Lg,
34}
35
36impl Size {
37 pub const ALL: [Size; 3] = [Size::Sm, Size::Md, Size::Lg];
38
39 pub const fn girth(self) -> f32 {
41 match self {
42 Size::Sm => 4.0,
43 Size::Md => 8.0,
44 Size::Lg => 12.0,
45 }
46 }
47}
48
49#[derive(Debug)]
51pub struct Progress<'a> {
52 value: f32,
53 label: Option<text::Fragment<'a>>,
54 show_percentage: bool,
55 variant: Variant,
56 size: Size,
57 width: Length,
58}
59
60pub fn progress<'a>(value: f32) -> Progress<'a> {
62 Progress {
63 value: clamp(value),
64 label: None,
65 show_percentage: false,
66 variant: Variant::default(),
67 size: Size::default(),
68 width: Length::Fill,
69 }
70}
71
72impl<'a> Progress<'a> {
73 pub fn variant(mut self, variant: Variant) -> Self {
74 self.variant = variant;
75 self
76 }
77
78 pub fn size(mut self, size: Size) -> Self {
79 self.size = size;
80 self
81 }
82
83 pub fn label(mut self, label: impl text::IntoFragment<'a>) -> Self {
85 self.label = Some(label.into_fragment());
86 self
87 }
88
89 pub fn show_percentage(mut self, show: bool) -> Self {
91 self.show_percentage = show;
92 self
93 }
94
95 pub fn width(mut self, width: impl Into<Length>) -> Self {
97 self.width = width.into();
98 self
99 }
100
101 pub fn value(&self) -> f32 {
103 self.value
104 }
105}
106
107impl<'a, Message: 'a> From<Progress<'a>> for Element<'a, Message> {
108 fn from(progress: Progress<'a>) -> Self {
109 let variant = progress.variant;
110 let bar = progress_bar(0.0..=1.0, progress.value)
111 .length(progress.width)
112 .girth(progress.size.girth())
113 .style(move |theme| style(&Tokens::of(theme), variant));
114
115 if progress.label.is_none() && !progress.show_percentage {
116 return bar.into();
117 }
118
119 let muted = |theme: &iced::Theme| text::Style {
120 color: Some(Tokens::of(theme).muted_foreground),
121 };
122 let mut header = row![].width(progress.width);
123 if let Some(label) = progress.label {
124 header = header.push(text(label).size(text_size::SM));
125 }
126 header = header.push(space::horizontal());
127 if progress.show_percentage {
128 header = header.push(
129 text(format!("{}%", percentage(progress.value)))
130 .size(text_size::SM)
131 .style(muted),
132 );
133 }
134
135 column![header, bar].spacing(gap::SM).into()
136 }
137}
138
139pub fn clamp(value: f32) -> f32 {
141 if value.is_nan() {
142 return 0.0;
143 }
144 value.clamp(0.0, 1.0)
145}
146
147pub fn percentage(value: f32) -> u8 {
149 (clamp(value) * 100.0).round() as u8
151}
152
153#[derive(Debug, Clone, Copy, PartialEq)]
155pub struct Colours {
156 pub track: Color,
157 pub bar: Color,
158}
159
160pub fn colours(tokens: &Tokens, variant: Variant) -> Colours {
162 let bar = match variant {
163 Variant::Default => tokens.primary,
164 Variant::Success => tokens.success,
165 Variant::Warning => tokens.warning,
166 Variant::Destructive => tokens.destructive,
167 };
168 let track_mix = if tokens.is_dark { 0.2 } else { 0.15 };
169 Colours {
170 track: mix(tokens.background, bar, track_mix),
171 bar,
172 }
173}
174
175pub fn style(tokens: &Tokens, variant: Variant) -> progress_bar::Style {
177 let colours = colours(tokens, variant);
178 progress_bar::Style {
179 background: Background::Color(colours.track),
180 bar: Background::Color(colours.bar),
181 border: Border {
182 color: Color::TRANSPARENT,
183 width: 0.0,
184 radius: radius::FULL.into(),
185 },
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::theme::{dark, light};
193
194 #[test]
195 fn default_builder_is_medium_default_fill_without_caption() {
196 let p = progress(0.5);
197 assert_eq!(p.variant, Variant::Default);
198 assert_eq!(p.size, Size::Md);
199 assert_eq!(p.width, Length::Fill);
200 assert!(p.label.is_none());
201 assert!(!p.show_percentage);
202 }
203
204 #[test]
205 fn clamp_bounds_values() {
206 assert_eq!(clamp(-0.5), 0.0);
207 assert_eq!(clamp(0.0), 0.0);
208 assert_eq!(clamp(0.42), 0.42);
209 assert_eq!(clamp(1.0), 1.0);
210 assert_eq!(clamp(3.0), 1.0);
211 assert_eq!(clamp(f32::INFINITY), 1.0);
212 assert_eq!(clamp(f32::NEG_INFINITY), 0.0);
213 }
214
215 #[test]
216 fn clamp_maps_nan_to_zero() {
217 assert_eq!(clamp(f32::NAN), 0.0);
218 assert_eq!(progress(f32::NAN).value(), 0.0);
219 }
220
221 #[test]
222 fn builder_clamps_its_value() {
223 assert_eq!(progress(-1.0).value(), 0.0);
224 assert_eq!(progress(2.0).value(), 1.0);
225 }
226
227 #[test]
228 fn percentage_rounds_and_clamps() {
229 assert_eq!(percentage(0.0), 0);
230 assert_eq!(percentage(0.424), 42);
231 assert_eq!(percentage(0.425), 43);
232 assert_eq!(percentage(1.0), 100);
233 assert_eq!(percentage(7.0), 100);
234 assert_eq!(percentage(-7.0), 0);
235 assert_eq!(percentage(f32::NAN), 0);
236 }
237
238 #[test]
239 fn sizes_grow_monotonically() {
240 let girths = Size::ALL.map(Size::girth);
241 assert!(girths.windows(2).all(|w| w[0] < w[1]));
242 }
243
244 #[test]
245 fn track_is_a_faint_version_of_the_bar() {
246 for theme in [light(), dark()] {
247 let tokens = Tokens::of(&theme);
248 for variant in Variant::ALL {
249 let colours = colours(&tokens, variant);
250 assert_ne!(colours.track, colours.bar, "{variant:?}");
251 assert_ne!(colours.track, tokens.background, "{variant:?}");
252 }
253 }
254 }
255
256 #[test]
257 fn variants_use_their_tokens() {
258 for theme in [light(), dark()] {
259 let tokens = Tokens::of(&theme);
260 assert_eq!(colours(&tokens, Variant::Default).bar, tokens.primary);
261 assert_eq!(colours(&tokens, Variant::Success).bar, tokens.success);
262 assert_eq!(colours(&tokens, Variant::Warning).bar, tokens.warning);
263 assert_eq!(
264 colours(&tokens, Variant::Destructive).bar,
265 tokens.destructive
266 );
267 assert_eq!(
268 style(&tokens, Variant::Default).border.radius,
269 radius::FULL.into()
270 );
271 }
272 }
273}