Skip to main content

frus_widgets/
progressbar.rs

1//! [`LinearProgressIndicator`]: a **determinate** progress bar (`0..1`).
2
3use frus_core::{BorderRadius, Color, Rect, Scene};
4use frus_layout::{Dimension, Style};
5
6use crate::interaction::Status;
7use crate::theme::Theme;
8use crate::widget::Widget;
9
10/// **Bar height**, in logical pixels (`progress_indicator.dart:1624`). It was eight,
11/// which is twice the specification and reads as a slab rather than a line.
12const HEIGHT: f32 = 4.0;
13/// The room left between the end of the fill and the start of the track
14/// (`progress_indicator.dart:1636`).
15const TRACK_GAP: f32 = 4.0;
16/// The radius of the dot at the far end of the track
17/// (`progress_indicator.dart:1633`).
18const STOP_RADIUS: f32 = 2.0;
19
20/// A progress bar: a track plus a fill proportional to `value`.
21///
22/// The look is the reference's **current** one rather than its default one. The
23/// reference still defaults to the 2023 appearance behind a `year2023` flag it has
24/// already deprecated — square ends, no gap, no stop dot — and says in as many
25/// words that the flag will default to the newer look in time. A framework with no
26/// installed base to keep faith with should start where that one is going: a gap between
27/// the fill and the track, and a dot at the end saying where the bar is headed. Every
28/// piece of it is a builder, so an application that wants the older look can write it.
29pub struct LinearProgressIndicator {
30    value: f32,
31    width: Dimension,
32    color: Option<Color>,
33    track_color: Option<Color>,
34    min_height: Option<f32>,
35    radius: Option<BorderRadius>,
36    stop_indicator_color: Option<Color>,
37    stop_indicator_radius: Option<f32>,
38    track_gap: Option<f32>,
39}
40
41impl LinearProgressIndicator {
42    /// Creates a bar filled to `value`, clamped to `0.0..=1.0`.
43    pub fn new(value: f32) -> Self {
44        Self {
45            value: value.clamp(0.0, 1.0),
46            width: Dimension::Length(200.0),
47            color: None,
48            track_color: None,
49            min_height: None,
50            radius: None,
51            stop_indicator_color: None,
52            stop_indicator_radius: None,
53            track_gap: None,
54        }
55    }
56
57    /// Sets the width, in logical pixels.
58    pub fn width(mut self, width: f32) -> Self {
59        self.width = Dimension::Length(width);
60        self
61    }
62
63    /// **The colour of the fill**, over the theme's and `primary`.
64    #[must_use]
65    pub fn color(mut self, color: Color) -> Self {
66        self.color = Some(color);
67        self
68    }
69
70    /// **The colour of the track**, over the theme's and `secondary_container`.
71    #[must_use]
72    pub fn track_color(mut self, color: Color) -> Self {
73        self.track_color = Some(color);
74        self
75    }
76
77    /// **How tall the bar is**, over the theme's and the reference's four.
78    #[must_use]
79    pub fn min_height(mut self, height: f32) -> Self {
80        self.min_height = Some(height);
81        self
82    }
83
84    /// **The corners of the bar and its track.** Unset, fully rounded;
85    /// `radius(0.0)` is the square-ended bar the reference still draws by default.
86    #[must_use]
87    pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
88        self.radius = Some(radius.into());
89        self
90    }
91
92    /// **The dot at the far end of the track**, which says where the bar is going.
93    #[must_use]
94    pub fn stop_indicator_color(mut self, color: Color) -> Self {
95        self.stop_indicator_color = Some(color);
96        self
97    }
98
99    /// That dot's radius. **Zero draws none**, which is the older look.
100    #[must_use]
101    pub fn stop_indicator_radius(mut self, radius: f32) -> Self {
102        self.stop_indicator_radius = Some(radius);
103        self
104    }
105
106    /// **The gap between the fill and the track.** Zero is the older look, where the two
107    /// meet.
108    #[must_use]
109    pub fn track_gap(mut self, gap: f32) -> Self {
110        self.track_gap = Some(gap);
111        self
112    }
113
114    /// How tall the bar is: the caller's word, then the theme's, then the reference's.
115    fn height(&self, theme: &Theme) -> f32 {
116        self.min_height
117            .or(theme.widgets.progress.linear_min_height)
118            .unwrap_or(HEIGHT)
119    }
120}
121
122impl<Msg> Widget<Msg> for LinearProgressIndicator {
123    fn style(&self) -> Style {
124        Style {
125            width: self.width,
126            height: Dimension::Length(HEIGHT),
127            ..Default::default()
128        }
129    }
130
131    fn style_themed(&self, theme: &Theme) -> Style {
132        Style {
133            width: self.width,
134            height: Dimension::Length(self.height(theme)),
135            ..Default::default()
136        }
137    }
138
139    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
140        &[]
141    }
142
143    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
144        let o = status.opacity;
145        let t = &theme.widgets.progress;
146        let radius = self
147            .radius
148            .or(t.border_radius)
149            .unwrap_or(BorderRadius::uniform(bounds.height * 0.5));
150        let fill_color = self.color.or(t.color).unwrap_or(theme.primary);
151        // `secondary_container`, not a faded `muted`. A track drawn as thirty percent of
152        // something else is a colour nobody chose and no theme could name; the reference
153        // gives it a role (`progress_indicator.dart:1621`).
154        let track_color = self
155            .track_color
156            .or(t.linear_track_color)
157            .unwrap_or(theme.scheme.secondary_container);
158        let gap = self.track_gap.or(t.track_gap).unwrap_or(TRACK_GAP);
159        let stop_radius = self
160            .stop_indicator_radius
161            .or(t.stop_indicator_radius)
162            .unwrap_or(STOP_RADIUS);
163        let stop_color = self
164            .stop_indicator_color
165            .or(t.stop_indicator_color)
166            .unwrap_or(fill_color);
167
168        // The fill: at least a dot when the value is above zero, never wider than the
169        // bar. A bar at 0.1 % that draws nothing tells the reader the work has not
170        // started.
171        let fill_w = match bounds.width * self.value {
172            w if w > 0.0 => w.max(bounds.height).min(bounds.width),
173            _ => 0.0,
174        };
175
176        // The track starts **after** the gap, so the two do not meet. When the fill has
177        // reached the end there is no track left and none is drawn.
178        let track_x = match fill_w {
179            0.0 => bounds.x,
180            w => bounds.x + w + gap,
181        };
182        let track_w = (bounds.x + bounds.width - track_x).max(0.0);
183        if track_w > 0.0 {
184            scene.draw_rect(
185                Rect::new(track_x, bounds.y, track_w, bounds.height),
186                track_color.fade(o),
187                radius,
188                0.0,
189                Color::TRANSPARENT,
190            );
191        }
192        if fill_w > 0.0 {
193            scene.draw_rect(
194                Rect::new(bounds.x, bounds.y, fill_w, bounds.height),
195                fill_color.fade(o),
196                radius,
197                0.0,
198                Color::TRANSPARENT,
199            );
200        }
201        // The stop: a dot at the far end of the track, saying where the bar is going. It
202        // is drawn only while there is track wide enough to hold it — at the end of a
203        // run the fill has taken its place.
204        if stop_radius > 0.0 && track_w >= 2.0 * stop_radius {
205            let cx = bounds.x + bounds.width - bounds.height * 0.5;
206            let cy = bounds.y + bounds.height * 0.5;
207            scene.draw_rect(
208                Rect::new(
209                    cx - stop_radius,
210                    cy - stop_radius,
211                    2.0 * stop_radius,
212                    2.0 * stop_radius,
213                ),
214                stop_color.fade(o),
215                stop_radius,
216                0.0,
217                Color::TRANSPARENT,
218            );
219        }
220    }
221
222    fn on_click(&self) -> Option<Msg> {
223        None
224    }
225
226    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
227        let pct = (self.value * 100.0).round();
228        Some(
229            frus_core::SemanticsProperties::new(frus_core::Role::ProgressBar)
230                .value(format!("{pct}%"))
231                .range(0.0, self.value, 1.0),
232        )
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use frus_core::Primitive;
240
241    fn painted(bar: &LinearProgressIndicator, theme: &Theme) -> Vec<(Rect, frus_core::Color)> {
242        let mut scene = Scene::new();
243        Widget::<()>::paint(
244            bar,
245            Rect::new(0.0, 0.0, 100.0, HEIGHT),
246            Status::default(),
247            theme,
248            &mut scene,
249        );
250        scene
251            .primitives()
252            .iter()
253            .filter_map(|p| match p {
254                Primitive::Rect { rect, color, .. } => Some((*rect, *color)),
255                _ => None,
256            })
257            .collect()
258    }
259
260    fn fill_and_track(value: f32) -> (f32, f32) {
261        let bar = LinearProgressIndicator::new(value).width(100.0);
262        let widths: Vec<f32> = painted(&bar, &Theme::default())
263            .into_iter()
264            .map(|(rect, _)| rect.width)
265            .collect();
266        // [track, fill]
267        (widths[0], *widths.get(1).unwrap_or(&0.0))
268    }
269
270    /// **A bar was drawn to no specification.** Its height was eight, twice the
271    /// reference's four (`progress_indicator.dart:1624`), and its track was `muted` at
272    /// thirty percent — a colour nobody chose and no theme could name, where the
273    /// reference gives it the `secondary_container` role
274    /// (`progress_indicator.dart:1621`).
275    #[test]
276    fn a_bar_is_drawn_to_a_specification() {
277        assert_eq!(HEIGHT, 4.0, "the reference's height, not twice it");
278        let theme = Theme::default();
279        let bar = LinearProgressIndicator::new(0.5).width(100.0);
280        let painted = painted(&bar, &theme);
281        assert_eq!(
282            painted[0].1, theme.scheme.secondary_container,
283            "the track has a role"
284        );
285        assert_eq!(painted[1].1, theme.primary, "and the fill is the accent");
286    }
287
288    /// **The track stops short of the fill, and ends in a dot.** Both are the
289    /// reference's current appearance (`progress_indicator.dart:1633` and `:1636`), the
290    /// one it says in as many words it is moving to; the gap is what keeps a bar from
291    /// reading as one solid rule, and the dot says where it is going.
292    ///
293    /// At the end of a run there is neither: the fill has taken the whole width, so
294    /// there is no track to leave a gap in and nowhere to put the dot.
295    #[test]
296    fn a_track_leaves_room_for_the_fill_and_a_dot() {
297        let theme = Theme::default();
298        let half = painted(&LinearProgressIndicator::new(0.5).width(100.0), &theme);
299        assert_eq!(half.len(), 3, "a track, a fill and a dot: {half:#?}");
300        assert_eq!(half[1].0.width, 50.0, "the fill is half the bar");
301        assert_eq!(
302            half[0].0.x,
303            50.0 + TRACK_GAP,
304            "and the track starts a gap after it"
305        );
306        assert_eq!(half[0].0.width, 100.0 - 50.0 - TRACK_GAP);
307        assert_eq!(
308            half[2].0.width,
309            2.0 * STOP_RADIUS,
310            "the dot at the far end: {half:#?}"
311        );
312        assert!(half[2].0.x + half[2].0.width <= 100.0, "and inside the bar");
313
314        let full = painted(&LinearProgressIndicator::new(1.0).width(100.0), &theme);
315        assert_eq!(
316            full.len(),
317            1,
318            "at the end there is only the fill: {full:#?}"
319        );
320        assert_eq!(full[0].0.width, 100.0);
321    }
322
323    /// **Everything the bar draws is now reachable**, on the usual rungs. Not one of
324    /// these was: the widget had a single builder, a width, and decided the rest inside
325    /// `paint`.
326    #[test]
327    fn a_bar_answers_to_its_theme_and_to_its_caller() {
328        let mut theme = Theme::default();
329        theme.widgets.progress.color = Some(frus_core::Color::rgb(0.1, 0.2, 0.3));
330        theme.widgets.progress.linear_track_color = Some(frus_core::Color::rgb(0.4, 0.5, 0.6));
331        theme.widgets.progress.track_gap = Some(0.0);
332        theme.widgets.progress.stop_indicator_radius = Some(0.0);
333        theme.widgets.progress.linear_min_height = Some(11.0);
334
335        let bar = LinearProgressIndicator::new(0.5).width(100.0);
336        let themed = painted(&bar, &theme);
337        assert_eq!(themed.len(), 2, "no gap and no dot is the older look");
338        assert_eq!(themed[0].1, frus_core::Color::rgb(0.4, 0.5, 0.6));
339        assert_eq!(themed[1].1, frus_core::Color::rgb(0.1, 0.2, 0.3));
340        assert_eq!(themed[0].0.x, 50.0, "the track meets the fill");
341        assert_eq!(
342            Widget::<()>::style_themed(&bar, &theme).height,
343            frus_layout::Dimension::Length(11.0),
344            "and the box is the theme's height"
345        );
346
347        // The caller outranks all of it.
348        let told = LinearProgressIndicator::new(0.5)
349            .width(100.0)
350            .color(frus_core::Color::rgb(0.9, 0.0, 0.0))
351            .track_color(frus_core::Color::rgb(0.0, 0.9, 0.0))
352            .track_gap(10.0)
353            .min_height(7.0);
354        let painted = painted(&told, &theme);
355        assert_eq!(painted[0].1, frus_core::Color::rgb(0.0, 0.9, 0.0));
356        assert_eq!(painted[1].1, frus_core::Color::rgb(0.9, 0.0, 0.0));
357        assert_eq!(painted[0].0.x, 60.0);
358        assert_eq!(
359            Widget::<()>::style_themed(&told, &theme).height,
360            frus_layout::Dimension::Length(7.0)
361        );
362    }
363
364    #[test]
365    fn fill_is_proportional_to_value() {
366        let (track, fill) = fill_and_track(0.5);
367        // The track is what is **left**, now that it stops short of the fill.
368        assert_eq!(track, 100.0 - 50.0 - TRACK_GAP);
369        assert_eq!(fill, 50.0);
370    }
371
372    #[test]
373    fn value_is_clamped() {
374        // value > 1 → a full fill (= the bar's width), no more. There is no track left
375        // to draw at that point, so the fill is the only thing in the frame.
376        let full = painted(
377            &LinearProgressIndicator::new(2.0).width(100.0),
378            &Theme::default(),
379        );
380        assert_eq!(full.len(), 1);
381        assert_eq!(full[0].0.width, 100.0);
382        // value 0 → no fill primitive at all.
383        let none = painted(
384            &LinearProgressIndicator::new(0.0).width(100.0),
385            &Theme::default(),
386        );
387        assert_eq!(none.len(), 2, "the whole track, and the dot at its end");
388        assert_eq!(none[0].0.width, 100.0);
389    }
390}