1use 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
10const HEIGHT: f32 = 4.0;
13const TRACK_GAP: f32 = 4.0;
16const STOP_RADIUS: f32 = 2.0;
19
20pub 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 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 pub fn width(mut self, width: f32) -> Self {
59 self.width = Dimension::Length(width);
60 self
61 }
62
63 #[must_use]
65 pub fn color(mut self, color: Color) -> Self {
66 self.color = Some(color);
67 self
68 }
69
70 #[must_use]
72 pub fn track_color(mut self, color: Color) -> Self {
73 self.track_color = Some(color);
74 self
75 }
76
77 #[must_use]
79 pub fn min_height(mut self, height: f32) -> Self {
80 self.min_height = Some(height);
81 self
82 }
83
84 #[must_use]
87 pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
88 self.radius = Some(radius.into());
89 self
90 }
91
92 #[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 #[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 #[must_use]
109 pub fn track_gap(mut self, gap: f32) -> Self {
110 self.track_gap = Some(gap);
111 self
112 }
113
114 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 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 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 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 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 (widths[0], *widths.get(1).unwrap_or(&0.0))
268 }
269
270 #[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 #[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 #[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 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 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 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 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}