qframe/widgets/
progress_bar.rs1use super::eighths;
4use super::shimmer_text::sweep_light;
5use crate::color::Rgb;
6use crate::geometry::{Rect, Size};
7use crate::text;
8use crate::widget::{MeasureCx, PaintCx, Widget};
9
10const BAND: f32 = 6.0;
12
13#[derive(Debug, Clone, PartialEq)]
21pub struct ProgressBar {
22 value: Option<f32>,
23 percent: bool,
24 variant: Option<String>,
25}
26
27impl ProgressBar {
28 #[must_use]
30 pub fn new(value: f32) -> Self {
31 Self { value: Some(value.clamp(0.0, 1.0)), percent: true, variant: None }
32 }
33
34 #[must_use]
36 pub fn indeterminate() -> Self {
37 Self { value: None, percent: false, variant: None }
38 }
39
40 #[must_use]
42 pub fn percent(mut self, show: bool) -> Self {
43 self.percent = show;
44 self
45 }
46
47 #[must_use]
49 pub fn variant(mut self, variant: impl Into<String>) -> Self {
50 self.variant = Some(variant.into());
51 self
52 }
53}
54
55impl<Msg: 'static> Widget<Msg> for ProgressBar {
56 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
57 Size::new(available.width, 1.min(available.height))
58 }
59
60 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
61 let style = cx.style("progress", self.variant.as_deref(), &[]);
62 let track = style.color("track").unwrap_or_else(|| cx.color("raised"));
63 let fill = style.color("fill").unwrap_or_else(|| cx.color("accent"));
64 let Some(value) = self.value else {
65 self.paint_sweep(cx, area, track, fill);
66 return;
67 };
68 let label = if self.percent { format!(" {:>3.0}%", value * 100.0) } else { String::new() };
69 let bar_width = area.width.saturating_sub(text::width(&label));
70 let bar = Rect::new(area.x, area.y, bar_width, 1);
71 cx.clear(bar, track);
72 eighths::horizontal(cx, bar, eighths::eighths(value, bar_width), fill);
73 if !label.is_empty() {
74 let label_style = cx.style("progress-label", self.variant.as_deref(), &[]).text();
75 cx.text(area.x + i32::from(bar_width), area.y, &label, label_style, text::width(&label));
76 }
77 }
78}
79
80impl ProgressBar {
81 fn paint_sweep(&self, cx: &mut PaintCx<'_>, area: Rect, track: Rgb, fill: Rgb) {
82 let reduced = cx.reduced_motion();
83 let t = cx.cycle(cx.env().theme().motion().shimmer);
84 for column in 0..area.width {
85 let intensity = if reduced { 0.25 } else { sweep_light(t, f32::from(area.width), BAND, f32::from(column)) };
87 cx.clear(Rect::new(area.x + i32::from(column), area.y, 1, 1), track.mix(fill, intensity));
88 }
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use std::time::Duration;
95
96 use super::*;
97 use crate::runtime::{App, Command, Harness};
98 use crate::widget::View;
99
100 struct Demo(ProgressBar);
101
102 impl App for Demo {
103 type Msg = ();
104 fn update(&mut self, _: ()) -> Command<()> {
105 Command::none()
106 }
107 fn view(&self, ui: &mut View<'_, ()>) {
108 ui.add(self.0.clone()).fill_width();
109 }
110 }
111
112 #[test]
113 fn fills_with_eighth_cells_and_shows_percent() {
114 let h = Harness::new(Demo(ProgressBar::new(0.53)), 15, 1);
115 assert_eq!(h.screen(), " ▎ 53%\n");
116 let theme = h.env().theme();
117 assert_eq!(h.bg(0, 0), theme.color("accent"));
118 assert_eq!(h.bg(8, 0), theme.color("raised"));
119 }
120
121 #[test]
122 fn ascii_rounds_to_the_nearest_whole_cell_like_the_charts() {
123 let theme_fill = |h: &Harness<Demo>| h.env().theme().color("accent");
124 let mut h = Harness::new(Demo(ProgressBar::new(0.55).percent(false)), 10, 1);
125 h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
126 assert_eq!(h.screen(), "\n", "no partial glyphs in ASCII mode");
127 let filled = (0..10).filter(|x| h.bg(*x, 0) == theme_fill(&h)).count();
128 assert_eq!(filled, 6, "5.5 cells round up to 6");
129 let mut h = Harness::new(Demo(ProgressBar::new(0.54).percent(false)), 10, 1);
130 h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
131 let filled = (0..10).filter(|x| h.bg(*x, 0) == theme_fill(&h)).count();
132 assert_eq!(filled, 5, "5.4 cells round down to 5");
133 assert_eq!(h.bg(5, 0), h.env().theme().color("raised"));
134 }
135
136 #[test]
137 fn indeterminate_band_moves() {
138 let mut h = Harness::new(Demo(ProgressBar::indeterminate()), 20, 1);
139 h.advance(Duration::from_millis(600));
140 let first: Vec<_> = (0..20).map(|x| h.bg(x, 0)).collect();
141 h.advance(Duration::from_millis(300));
142 let second: Vec<_> = (0..20).map(|x| h.bg(x, 0)).collect();
143 assert_ne!(first, second);
144 assert!(h.screen().trim().is_empty());
145 }
146}