Skip to main content

iced_cube/feedback/
progress.rs

1//! Determinate progress bars.
2
3use 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/// Colour of the filled part of the bar.
9#[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/// Bar thickness.
28#[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    /// Thickness of the bar in logical pixels.
40    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/// A progress bar builder. Convert it into an [`Element`] to render.
50#[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
60/// Creates a progress bar. `value` runs from 0.0 to 1.0 and is clamped.
61pub 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    /// Shows a caption above the bar.
84    pub fn label(mut self, label: impl text::IntoFragment<'a>) -> Self {
85        self.label = Some(label.into_fragment());
86        self
87    }
88
89    /// Shows the rounded percentage above the bar, on the right.
90    pub fn show_percentage(mut self, show: bool) -> Self {
91        self.show_percentage = show;
92        self
93    }
94
95    /// Overrides the width. Bars fill the available width by default.
96    pub fn width(mut self, width: impl Into<Length>) -> Self {
97        self.width = width.into();
98        self
99    }
100
101    /// The clamped value, from 0.0 to 1.0.
102    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
139/// Clamps a value into 0.0..=1.0. NaN becomes 0.0.
140pub fn clamp(value: f32) -> f32 {
141    if value.is_nan() {
142        return 0.0;
143    }
144    value.clamp(0.0, 1.0)
145}
146
147/// A value from 0.0 to 1.0 as a whole percentage.
148pub fn percentage(value: f32) -> u8 {
149    // The clamped value is within 0.0..=100.0, so the cast cannot truncate.
150    (clamp(value) * 100.0).round() as u8
151}
152
153/// Colours of the track and the filled bar.
154#[derive(Debug, Clone, Copy, PartialEq)]
155pub struct Colours {
156    pub track: Color,
157    pub bar: Color,
158}
159
160/// Resolves the colours of a variant.
161pub 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
175/// The iced progress bar style for a variant.
176pub 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}