Skip to main content

frus_widgets/
progressbar.rs

1//! [`LinearProgressIndicator`]: a progress bar — **determinate**, filled to a value, or
2//! **indeterminate**, two lines running along the track while the length of the work is
3//! unknown (`progress_indicator.dart:414`).
4
5use frus_core::{BorderRadius, Color, Curve, Rect, Scene, TextDirection};
6use frus_layout::{Dimension, Style};
7
8use crate::interaction::Status;
9use crate::theme::Theme;
10use crate::widget::Widget;
11
12/// **Bar height**, in logical pixels (`progress_indicator.dart:1624`).
13const HEIGHT: f32 = 4.0;
14/// The room left between a line and the track, in the 2024 look
15/// (`progress_indicator.dart:1636`).
16const TRACK_GAP: f32 = 4.0;
17/// The radius of the dot at the far end of the track, in the 2024 look
18/// (`progress_indicator.dart:1633`).
19const STOP_RADIUS: f32 = 2.0;
20/// The corners of the bar in the 2024 look: half the **default** height, whatever the
21/// height (`progress_indicator.dart:1627`).
22const RADIUS_2024: f32 = 2.0;
23/// One run of the indeterminate animation, in seconds (`progress_indicator.dart:23`).
24const INDETERMINATE_PERIOD: f32 = 1.8;
25/// Below this value the gap shrinks with the value, so that it does not appear out of
26/// nothing at the first percent (`progress_indicator.dart:30`).
27const GAP_RAMP: f32 = 0.01;
28
29/// A progress bar: a track, and either a fill proportional to a value or two lines that
30/// run along it while the length of the work is unknown.
31///
32/// The look is the reference's **default** one, its 2023 appearance: square ends, the fill
33/// meeting the track. [`year2023(false)`](Self::year2023) is its newer one — rounded ends,
34/// a gap between the fill and the track, and a dot at the far end saying where the bar is
35/// headed — which the reference says will become its default in time. Every piece of
36/// either is a builder.
37pub struct LinearProgressIndicator {
38    value: Option<f32>,
39    width: Dimension,
40    /// Whether the width was given; if not, the bar takes the width on offer, as the
41    /// reference's does (milestone 590).
42    sized: bool,
43    color: Option<Color>,
44    track_color: Option<Color>,
45    min_height: Option<f32>,
46    radius: Option<BorderRadius>,
47    stop_indicator_color: Option<Color>,
48    stop_indicator_radius: Option<f32>,
49    track_gap: Option<f32>,
50    year2023: Option<bool>,
51    semantics_label: Option<String>,
52    semantics_value: Option<String>,
53}
54
55impl LinearProgressIndicator {
56    /// A **determinate** bar, filled to `value`, clamped to `0.0..=1.0`.
57    pub fn new(value: f32) -> Self {
58        Self::with_value(Some(value))
59    }
60
61    /// An **indeterminate** bar: the work is under way and how long it will take is not
62    /// known, so two lines run along the track for as long as it is shown.
63    pub fn indeterminate() -> Self {
64        Self::with_value(None)
65    }
66
67    /// A bar filled to `value`, or indeterminate for `None` — the reference's nullable
68    /// `value`.
69    pub fn with_value(value: Option<f32>) -> Self {
70        Self {
71            value: value.map(|v| v.clamp(0.0, 1.0)),
72            width: Dimension::Length(200.0),
73            sized: false,
74            color: None,
75            track_color: None,
76            min_height: None,
77            radius: None,
78            stop_indicator_color: None,
79            stop_indicator_radius: None,
80            track_gap: None,
81            year2023: None,
82            semantics_label: None,
83            semantics_value: None,
84        }
85    }
86
87    /// Sets the width, in logical pixels.
88    pub fn width(mut self, width: f32) -> Self {
89        self.width = Dimension::Length(width);
90        self.sized = true;
91        self
92    }
93
94    /// **The colour of the fill**, over the theme's and `primary`.
95    #[must_use]
96    pub fn color(mut self, color: Color) -> Self {
97        self.color = Some(color);
98        self
99    }
100
101    /// **The colour of the track** — the reference's `backgroundColor` — over the theme's
102    /// and `secondary_container`.
103    #[must_use]
104    pub fn track_color(mut self, color: Color) -> Self {
105        self.track_color = Some(color);
106        self
107    }
108
109    /// **How tall the bar is**, over the theme's and the reference's four.
110    #[must_use]
111    pub fn min_height(mut self, height: f32) -> Self {
112        self.min_height = Some(height);
113        self
114    }
115
116    /// **The corners of the fill, the lines and the track.** Unset, square in the 2023
117    /// look and 2 px in the newer one.
118    #[must_use]
119    pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
120        self.radius = Some(radius.into());
121        self
122    }
123
124    /// **The dot at the far end of the track**, in the newer look. Unset, the theme's, then
125    /// `primary`.
126    #[must_use]
127    pub fn stop_indicator_color(mut self, color: Color) -> Self {
128        self.stop_indicator_color = Some(color);
129        self
130    }
131
132    /// That dot's radius, in the newer look; never more than half the bar's height.
133    /// **Zero draws none.**
134    #[must_use]
135    pub fn stop_indicator_radius(mut self, radius: f32) -> Self {
136        self.stop_indicator_radius = Some(radius);
137        self
138    }
139
140    /// **The gap between a line and the track**, in the newer look.
141    #[must_use]
142    pub fn track_gap(mut self, gap: f32) -> Self {
143        self.track_gap = Some(gap);
144        self
145    }
146
147    /// **Which look**: the 2023 one (`true`, the default, as the reference's) or the newer
148    /// one (`false`). The stop dot and the track gap exist only in the newer look; in the
149    /// 2023 one they are ignored, as the reference ignores them.
150    #[must_use]
151    pub fn year2023(mut self, year2023: bool) -> Self {
152        self.year2023 = Some(year2023);
153        self
154    }
155
156    /// **What the bar is the progress of**, for whoever cannot see it.
157    #[must_use]
158    pub fn semantics_label(mut self, label: impl Into<String>) -> Self {
159        self.semantics_label = Some(label.into());
160        self
161    }
162
163    /// **The value read out**, in place of the percentage.
164    #[must_use]
165    pub fn semantics_value(mut self, value: impl Into<String>) -> Self {
166        self.semantics_value = Some(value.into());
167        self
168    }
169
170    /// How tall the bar is: the caller's word, then the theme's, then the reference's.
171    fn height(&self, theme: &Theme) -> f32 {
172        self.min_height
173            .or(theme.widgets.progress.linear_min_height)
174            .unwrap_or(HEIGHT)
175    }
176}
177
178/// What the painter needs, resolved: the caller's word, then the theme's, then the
179/// reference's default for the look in force (`progress_indicator.dart:595`).
180struct Resolved {
181    value_color: Color,
182    track_color: Color,
183    radius: Option<BorderRadius>,
184    stop: Option<(Color, f32)>,
185    gap: f32,
186    rtl: bool,
187}
188
189impl LinearProgressIndicator {
190    fn resolve(&self, theme: &Theme) -> Resolved {
191        let t = &theme.widgets.progress;
192        let year2023 = self.year2023.or(t.year2023).unwrap_or(true);
193        let value_color = self.color.or(t.color).unwrap_or(theme.scheme.primary);
194        let newer = |own: Option<f32>, themed: Option<f32>, default: f32| {
195            (!year2023).then(|| own.or(themed).unwrap_or(default))
196        };
197        let stop_radius = newer(
198            self.stop_indicator_radius,
199            t.stop_indicator_radius,
200            STOP_RADIUS,
201        );
202        Resolved {
203            value_color,
204            track_color: self
205                .track_color
206                .or(t.linear_track_color)
207                .unwrap_or(theme.scheme.secondary_container),
208            radius: self
209                .radius
210                .or(t.border_radius)
211                .or((!year2023).then(|| BorderRadius::uniform(RADIUS_2024))),
212            stop: stop_radius.filter(|r| *r > 0.0).map(|r| {
213                let color = self
214                    .stop_indicator_color
215                    .or(t.stop_indicator_color)
216                    .unwrap_or(theme.scheme.primary);
217                (color, r)
218            }),
219            gap: newer(self.track_gap, t.track_gap, TRACK_GAP).unwrap_or(0.0),
220            rtl: theme.direction == TextDirection::Rtl,
221        }
222    }
223}
224
225/// The four ends of the two indeterminate lines, as the reference times them
226/// (`progress_indicator.dart:189`): each an interval of the 1800 ms run on its own cubic.
227fn indeterminate_ends(t: f32) -> [f32; 4] {
228    let ms = |ms: f32| ms / (INDETERMINATE_PERIOD * 1000.0);
229    let curve = |begin: f32, len: f32, x1: f32, y1: f32, x2: f32, y2: f32| Curve::Interval {
230        begin: ms(begin),
231        end: ms(begin + len),
232        inner: Box::new(Curve::Cubic { x1, y1, x2, y2 }),
233    };
234    [
235        curve(0.0, 750.0, 0.2, 0.0, 0.8, 1.0).transform(t),
236        curve(333.0, 750.0, 0.4, 0.0, 1.0, 1.0).transform(t),
237        curve(1000.0, 567.0, 0.0, 0.0, 0.65, 1.0).transform(t),
238        curve(1267.0, 533.0, 0.10, 0.0, 0.45, 1.0).transform(t),
239    ]
240}
241
242/// The reference's painter (`progress_indicator.dart:211`), on fractions of the bar.
243fn paint_bar(
244    scene: &mut Scene,
245    bounds: Rect,
246    value: Option<f32>,
247    animation: f32,
248    r: &Resolved,
249    opacity: f32,
250) {
251    let draw = |scene: &mut Scene, start: f32, end: f32, color: Color| {
252        if end - start <= 0.0 {
253            return;
254        }
255        let (left, right) = if r.rtl {
256            (1.0 - end, 1.0 - start)
257        } else {
258            (start, end)
259        };
260        scene.draw_rect(
261            Rect::new(
262                bounds.x + left * bounds.width,
263                bounds.y,
264                (right - left) * bounds.width,
265                bounds.height,
266            ),
267            color.fade(opacity),
268            r.radius.unwrap_or(BorderRadius::uniform(0.0)),
269            0.0,
270            Color::TRANSPARENT,
271        );
272    };
273    let gap = if bounds.width > 0.0 {
274        r.gap / bounds.width
275    } else {
276        0.0
277    };
278    // The gap grows from nothing over the first percent rather than appearing whole.
279    let ramp = |v: f32| gap * v.clamp(0.0, GAP_RAMP) / GAP_RAMP;
280
281    if let Some(value) = value {
282        let track_start = if gap > 0.0 { value + ramp(value) } else { 0.0 };
283        if track_start < 1.0 {
284            draw(scene, track_start, 1.0, r.track_color);
285        }
286        if let Some((color, radius)) = r.stop {
287            // Never more than half the bar's height, at the far end.
288            let max = bounds.height * 0.5;
289            let radius = radius.min(max);
290            let cx = if r.rtl {
291                bounds.x + max
292            } else {
293                bounds.x + bounds.width - max
294            };
295            let cy = bounds.y + max;
296            scene.draw_rect(
297                Rect::new(cx - radius, cy - radius, 2.0 * radius, 2.0 * radius),
298                color.fade(opacity),
299                radius,
300                0.0,
301                Color::TRANSPARENT,
302            );
303        }
304        if value > 0.0 {
305            draw(scene, 0.0, value, r.value_color);
306        }
307        return;
308    }
309
310    let [head1, tail1, head2, tail2] = indeterminate_ends(animation);
311    // The track ahead of line 1.
312    if head1 < 1.0 - gap {
313        let start = if head1 > 0.0 {
314            head1 + ramp(head1)
315        } else {
316            0.0
317        };
318        draw(scene, start, 1.0, r.track_color);
319    }
320    if head1 - tail1 > 0.0 {
321        draw(scene, tail1, head1, r.value_color);
322    }
323    // The track between line 2 and line 1.
324    if tail1 > gap {
325        let start = if head2 > 0.0 {
326            head2 + ramp(head2)
327        } else {
328            0.0
329        };
330        let end = if tail1 < 1.0 {
331            tail1 - ramp(1.0 - tail1)
332        } else {
333            1.0
334        };
335        draw(scene, start, end, r.track_color);
336    }
337    if head2 - tail2 > 0.0 {
338        draw(scene, tail2, head2, r.value_color);
339    }
340    // The track behind line 2.
341    if tail2 > gap {
342        let end = if tail2 < 1.0 {
343            tail2 - ramp(1.0 - tail2)
344        } else {
345            1.0
346        };
347        draw(scene, 0.0, end, r.track_color);
348    }
349}
350
351impl<Msg> Widget<Msg> for LinearProgressIndicator {
352    fn style(&self) -> Style {
353        Style {
354            width: self.width,
355            height: Dimension::Length(HEIGHT),
356            ..Default::default()
357        }
358    }
359
360    fn style_themed(&self, theme: &Theme) -> Style {
361        Style {
362            width: self.width,
363            height: Dimension::Length(self.height(theme)),
364            ..Default::default()
365        }
366    }
367
368    /// **All the room on offer** on an axis nobody sized, as the reference's takes
369    /// (milestone 590).
370    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
371        crate::widget::FillAxes {
372            horizontal: !self.sized,
373            vertical: false,
374        }
375    }
376
377    /// The default size is only a default: the room replaces it wherever the layout can
378    /// give the room, and it stands where the reference would have nothing to give.
379    fn soft_extent(&self) -> crate::widget::FillAxes {
380        crate::widget::FillAxes {
381            horizontal: !self.sized,
382            vertical: false,
383        }
384    }
385
386    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
387        &[]
388    }
389
390    /// An indeterminate bar moves for as long as it is shown; a determinate one only when
391    /// its value changes.
392    fn continuous(&self) -> bool {
393        self.value.is_none()
394    }
395
396    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
397        let animation = (status.time / INDETERMINATE_PERIOD).rem_euclid(1.0);
398        paint_bar(
399            scene,
400            bounds,
401            self.value,
402            animation,
403            &self.resolve(theme),
404            status.opacity,
405        );
406    }
407
408    fn on_click(&self) -> Option<Msg> {
409        None
410    }
411
412    /// A determinate bar says its value from 0 to 100; an indeterminate one says only that
413    /// it is busy — a progress bar with no value, which is how the platforms' own
414    /// accessibility vocabularies say "indeterminate" (`progress_indicator.dart:146`).
415    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
416        let mut s = frus_core::SemanticsProperties::new(frus_core::Role::ProgressBar);
417        if let Some(label) = &self.semantics_label {
418            s = s.label(label.clone());
419        }
420        if let Some(value) = self.value {
421            let pct = (value * 100.0).round();
422            s = s.range(0.0, pct, 100.0);
423            s = s.value(self.semantics_value.clone().unwrap_or(format!("{pct}")));
424        } else if let Some(said) = &self.semantics_value {
425            s = s.value(said.clone());
426        }
427        Some(s)
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use frus_core::Primitive;
435
436    fn painted_at(
437        bar: &LinearProgressIndicator,
438        theme: &Theme,
439        time: f32,
440    ) -> Vec<(Rect, Color, f32)> {
441        let mut scene = Scene::new();
442        let status = Status {
443            time,
444            ..Status::default()
445        };
446        Widget::<()>::paint(
447            bar,
448            Rect::new(0.0, 0.0, 100.0, HEIGHT),
449            status,
450            theme,
451            &mut scene,
452        );
453        scene
454            .primitives()
455            .iter()
456            .filter_map(|p| match p {
457                Primitive::Rect {
458                    rect,
459                    color,
460                    radius,
461                    ..
462                } => Some((*rect, *color, radius.top_left)),
463                _ => None,
464            })
465            .collect()
466    }
467
468    fn painted(bar: &LinearProgressIndicator, theme: &Theme) -> Vec<(Rect, Color, f32)> {
469        painted_at(bar, theme, 0.0)
470    }
471
472    fn near(a: f32, b: f32) -> bool {
473        (a - b).abs() < 1e-3
474    }
475
476    /// **The default look is the reference's default**, its 2023 one: the track under the
477    /// whole bar, the fill over it, square ends, no gap and no dot
478    /// (`progress_indicator.dart:595`, `:1555`).
479    #[test]
480    fn the_default_look_is_the_reference_s_default() {
481        let theme = Theme::default();
482        let half = painted(&LinearProgressIndicator::new(0.5).width(100.0), &theme);
483        assert_eq!(half.len(), 2, "a track and a fill: {half:#?}");
484        let (track, fill) = (half[0], half[1]);
485        assert_eq!(
486            (track.0.x, track.0.width),
487            (0.0, 100.0),
488            "the track is whole"
489        );
490        assert_eq!(track.1, theme.scheme.secondary_container);
491        assert_eq!((fill.0.x, fill.0.width), (0.0, 50.0));
492        assert_eq!(fill.1, theme.scheme.primary);
493        assert_eq!((track.2, fill.2), (0.0, 0.0), "square ends");
494        // A tiny value is drawn tiny: no fill widened to a dot.
495        let tiny = painted(&LinearProgressIndicator::new(0.001).width(100.0), &theme);
496        assert!(near(tiny[1].0.width, 0.1), "{tiny:#?}");
497        // In the 2023 look the newer pieces are ignored, as the reference ignores them.
498        let asked = LinearProgressIndicator::new(0.5)
499            .width(100.0)
500            .track_gap(10.0)
501            .stop_indicator_radius(2.0);
502        assert_eq!(painted(&asked, &theme).len(), 2);
503    }
504
505    /// **The newer look**: a 2 px corner whatever the height, the track a gap after the
506    /// fill, the dot at the far end drawn under the fill, and the gap growing from nothing
507    /// over the first percent (`progress_indicator.dart:256`, `:1611`).
508    #[test]
509    fn the_newer_look_is_the_reference_s() {
510        let theme = Theme::default();
511        let bar = |v: f32| LinearProgressIndicator::new(v).width(100.0).year2023(false);
512        let half = painted(&bar(0.5), &theme);
513        assert_eq!(half.len(), 3, "track, dot, fill: {half:#?}");
514        let (track, dot, fill) = (half[0], half[1], half[2]);
515        assert!(near(track.0.x, 50.0 + TRACK_GAP));
516        assert_eq!(track.2, RADIUS_2024);
517        assert_eq!(
518            dot.0,
519            Rect::new(96.0, 0.0, 4.0, 4.0),
520            "centred at the far end"
521        );
522        assert_eq!(dot.1, theme.scheme.primary);
523        assert_eq!((fill.0.width, fill.2), (50.0, RADIUS_2024));
524        // Half a percent: half the gap.
525        let ramp = painted(&bar(0.005), &theme);
526        assert!(near(ramp[0].0.x, 0.5 + TRACK_GAP * 0.5), "{ramp:#?}");
527        // At the end the fill covers the dot; there is no track.
528        let full = painted(&bar(1.0), &theme);
529        assert_eq!(full.len(), 2, "the dot and the fill over it: {full:#?}");
530        assert_eq!(full[1].0.width, 100.0);
531        // The dot never outgrows the bar.
532        let big = painted(&bar(0.5).stop_indicator_radius(10.0), &theme);
533        assert_eq!(big[1].0.width, HEIGHT);
534    }
535
536    /// **Right to left, the bar fills from the right** and its dot is at the left
537    /// (`progress_indicator.dart:224`).
538    #[test]
539    fn right_to_left_mirrors_the_bar() {
540        let theme = Theme::default().rtl();
541        let bar = LinearProgressIndicator::new(0.25)
542            .width(100.0)
543            .year2023(false);
544        let p = painted(&bar, &theme);
545        let (track, dot, fill) = (p[0], p[1], p[2]);
546        assert_eq!((fill.0.x, fill.0.width), (75.0, 25.0));
547        assert!(near(track.0.x, 0.0) && near(track.0.width, 75.0 - TRACK_GAP));
548        assert_eq!(dot.0.x, 0.0);
549    }
550
551    /// **An indeterminate bar runs two lines along the track** on the reference's timing:
552    /// one run is 1.8 s, line 1's head leads its tail, line 2 starts at 1 s, and the bar
553    /// keeps the frames coming (`progress_indicator.dart:189`, `:292`).
554    #[test]
555    fn an_indeterminate_bar_runs_the_reference_s_lines() {
556        let theme = Theme::default();
557        let bar = LinearProgressIndicator::indeterminate().width(100.0);
558        assert!(Widget::<()>::continuous(&bar));
559        assert!(!Widget::<()>::continuous(&LinearProgressIndicator::new(
560            0.3
561        )));
562        // At 0.375 s, line 1's head is half way along its own 750 ms cubic, its tail has
563        // barely started, and line 2 has not.
564        let [h1, t1, h2, t2] = indeterminate_ends(0.375 / INDETERMINATE_PERIOD);
565        let cubic = Curve::Cubic {
566            x1: 0.2,
567            y1: 0.0,
568            x2: 0.8,
569            y2: 1.0,
570        };
571        assert!(near(h1, cubic.transform(0.5)));
572        assert!(t1 > 0.0 && t1 < h1);
573        assert_eq!((h2, t2), (0.0, 0.0));
574        let p = painted_at(&bar, &theme, 0.375);
575        let lines: Vec<_> = p.iter().filter(|x| x.1 == theme.scheme.primary).collect();
576        assert_eq!(lines.len(), 1, "only line 1 so far: {p:#?}");
577        assert!(near(lines[0].0.x, t1 * 100.0) && near(lines[0].0.width, (h1 - t1) * 100.0));
578        // Between 1 s and 1.083 s both lines are out: line 2 has started, line 1's tail
579        // has not reached the end.
580        let p = painted_at(&bar, &theme, 1.05);
581        assert_eq!(p.iter().filter(|x| x.1 == theme.scheme.primary).count(), 2);
582        // And the run repeats.
583        assert_eq!(
584            painted_at(&bar, &theme, 0.375 + INDETERMINATE_PERIOD),
585            painted_at(&bar, &theme, 0.375)
586        );
587    }
588
589    /// **Everything the bar draws is reachable**, on the usual rungs: the caller, then the
590    /// theme, then the reference.
591    #[test]
592    fn a_bar_answers_to_its_theme_and_to_its_caller() {
593        let mut theme = Theme::default();
594        theme.widgets.progress.color = Some(Color::rgb(0.1, 0.2, 0.3));
595        theme.widgets.progress.linear_track_color = Some(Color::rgb(0.4, 0.5, 0.6));
596        theme.widgets.progress.linear_min_height = Some(11.0);
597        theme.widgets.progress.year2023 = Some(false);
598        theme.widgets.progress.track_gap = Some(0.0);
599        theme.widgets.progress.stop_indicator_radius = Some(0.0);
600
601        let bar = LinearProgressIndicator::new(0.5).width(100.0);
602        let themed = painted(&bar, &theme);
603        assert_eq!(themed.len(), 2, "no gap and no dot: {themed:#?}");
604        assert_eq!(themed[0].1, Color::rgb(0.4, 0.5, 0.6));
605        assert_eq!(themed[1].1, Color::rgb(0.1, 0.2, 0.3));
606        assert_eq!(themed[0].2, RADIUS_2024, "the theme chose the newer look");
607        assert_eq!(
608            Widget::<()>::style_themed(&bar, &theme).height,
609            Dimension::Length(11.0)
610        );
611
612        let told = LinearProgressIndicator::new(0.5)
613            .width(100.0)
614            .color(Color::rgb(0.9, 0.0, 0.0))
615            .track_color(Color::rgb(0.0, 0.9, 0.0))
616            .track_gap(10.0)
617            .min_height(7.0)
618            .radius(1.0);
619        let painted = painted(&told, &theme);
620        assert_eq!(painted[0].1, Color::rgb(0.0, 0.9, 0.0));
621        assert_eq!(painted[1].1, Color::rgb(0.9, 0.0, 0.0));
622        assert!(near(painted[0].0.x, 60.0));
623        assert_eq!(painted[0].2, 1.0);
624        assert_eq!(
625            Widget::<()>::style_themed(&told, &theme).height,
626            Dimension::Length(7.0)
627        );
628    }
629
630    /// **What a reader hears**: a determinate bar's value from 0 to 100, or the value it
631    /// was told to say; an indeterminate one, no value at all (`progress_indicator.dart:146`).
632    #[test]
633    fn a_reader_hears_the_reference_s_values() {
634        let s =
635            Widget::<()>::semantics(&LinearProgressIndicator::new(0.426).semantics_label("Upload"))
636                .unwrap();
637        assert_eq!(s.label.as_deref(), Some("Upload"));
638        assert_eq!(s.value.as_deref(), Some("43"));
639        let s = Widget::<()>::semantics(&LinearProgressIndicator::indeterminate()).unwrap();
640        assert_eq!(s.value, None);
641        let s =
642            Widget::<()>::semantics(&LinearProgressIndicator::new(0.5).semantics_value("3 of 6"))
643                .unwrap();
644        assert_eq!(s.value.as_deref(), Some("3 of 6"));
645    }
646
647    #[test]
648    fn value_is_clamped() {
649        let theme = Theme::default();
650        let full = painted(&LinearProgressIndicator::new(2.0).width(100.0), &theme);
651        assert_eq!(full[1].0.width, 100.0);
652        let none = painted(&LinearProgressIndicator::new(-1.0).width(100.0), &theme);
653        assert_eq!(none.len(), 1, "only the track");
654    }
655}