1use std::sync::Arc;
12use std::time::Duration;
13
14use web_time::Instant;
15
16use crate::color::Color;
17use crate::draw_ctx::DrawCtx;
18use crate::event::{Event, EventResult};
19use crate::geometry::{Rect, Size};
20use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
21use crate::text::Font;
22use crate::widget::Widget;
23
24const BAR_H: f64 = 18.0;
25const WIDGET_H: f64 = 24.0;
26
27#[inline]
29fn lerp(a: f64, b: f64, t: f64) -> f64 {
30 a + (b - a) * t
31}
32
33#[cfg_attr(feature = "reflect", derive(bevy_reflect::Reflect))]
35#[derive(Clone, Debug)]
36pub struct ProgressBarProps {
37 pub value: f64,
39 pub show_text: bool,
40 pub font_size: f64,
41 pub fill_color: Option<Color>,
42 pub animate: bool,
44}
45
46impl Default for ProgressBarProps {
47 fn default() -> Self {
48 Self {
49 value: 0.0,
50 show_text: true,
51 font_size: 11.0,
52 fill_color: None,
53 animate: false,
54 }
55 }
56}
57
58pub struct ProgressBar {
60 bounds: Rect,
61 children: Vec<Box<dyn Widget>>, base: WidgetBase,
63 pub props: ProgressBarProps,
64 font: Arc<Font>,
65 animate_on_hover: bool,
68 hovered: bool,
70 anim_start: Instant,
72}
73
74impl ProgressBar {
75 pub fn new(value: f64, font: Arc<Font>) -> Self {
76 Self {
77 bounds: Rect::default(),
78 children: Vec::new(),
79 base: WidgetBase::new(),
80 props: ProgressBarProps {
81 value: value.clamp(0.0, 1.0),
82 ..ProgressBarProps::default()
83 },
84 font,
85 animate_on_hover: false,
86 hovered: false,
87 anim_start: Instant::now(),
88 }
89 }
90
91 pub fn with_show_text(mut self, show: bool) -> Self {
92 self.props.show_text = show;
93 self
94 }
95 pub fn with_fill_color(mut self, color: Color) -> Self {
96 self.props.fill_color = Some(color);
97 self
98 }
99
100 pub fn with_animate(mut self, animate: bool) -> Self {
104 self.props.animate = animate;
105 self
106 }
107 pub fn set_animate(&mut self, animate: bool) {
109 self.props.animate = animate;
110 }
111 pub fn with_animate_on_hover(mut self, on: bool) -> Self {
114 self.animate_on_hover = on;
115 self
116 }
117
118 #[inline]
121 fn animating(&self) -> bool {
122 self.props.value < 1.0 && (self.props.animate || (self.animate_on_hover && self.hovered))
123 }
124
125 pub fn with_margin(mut self, m: Insets) -> Self {
126 self.base.margin = m;
127 self
128 }
129 pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
130 self.base.h_anchor = h;
131 self
132 }
133 pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
134 self.base.v_anchor = v;
135 self
136 }
137 pub fn with_min_size(mut self, s: Size) -> Self {
138 self.base.min_size = s;
139 self
140 }
141 pub fn with_max_size(mut self, s: Size) -> Self {
142 self.base.max_size = s;
143 self
144 }
145
146 pub fn set_value(&mut self, v: f64) {
147 self.props.value = v.clamp(0.0, 1.0);
148 }
149
150 pub fn value(&self) -> f64 {
151 self.props.value
152 }
153}
154
155impl Widget for ProgressBar {
156 fn type_name(&self) -> &'static str {
157 "ProgressBar"
158 }
159 fn bounds(&self) -> Rect {
160 self.bounds
161 }
162 fn set_bounds(&mut self, b: Rect) {
163 self.bounds = b;
164 }
165 fn children(&self) -> &[Box<dyn Widget>] {
166 &self.children
167 }
168 fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
169 &mut self.children
170 }
171
172 #[cfg(feature = "reflect")]
173 fn as_reflect(&self) -> Option<&dyn bevy_reflect::Reflect> {
174 Some(&self.props)
175 }
176 #[cfg(feature = "reflect")]
177 fn as_reflect_mut(&mut self) -> Option<&mut dyn bevy_reflect::Reflect> {
178 Some(&mut self.props)
179 }
180
181 fn margin(&self) -> Insets {
182 self.base.margin
183 }
184 fn widget_base(&self) -> Option<&WidgetBase> {
185 Some(&self.base)
186 }
187 fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
188 Some(&mut self.base)
189 }
190 fn h_anchor(&self) -> HAnchor {
191 self.base.h_anchor
192 }
193 fn v_anchor(&self) -> VAnchor {
194 self.base.v_anchor
195 }
196 fn min_size(&self) -> Size {
197 self.base.min_size
198 }
199 fn max_size(&self) -> Size {
200 self.base.max_size
201 }
202
203 fn layout(&mut self, available: Size) -> Size {
204 Size::new(available.width, WIDGET_H)
205 }
206
207 fn paint(&mut self, ctx: &mut dyn DrawCtx) {
208 let v = ctx.visuals();
209 let w = self.bounds.width;
210 let h = self.bounds.height;
211 let bar_y = (h - BAR_H) * 0.5;
212 let r = BAR_H * 0.5;
213
214 ctx.set_fill_color(v.track_bg);
216 ctx.begin_path();
217 ctx.rounded_rect(0.0, bar_y, w, BAR_H, r);
218 ctx.fill();
219
220 let animating = self.animating();
221
222 let base_fill = self.props.fill_color.unwrap_or(v.accent);
227 let time = self.anim_start.elapsed().as_secs_f64();
228 let fill_color = if animating {
229 let pulse = (time * std::f64::consts::TAU * 0.6).sin() * 0.5 + 0.5;
231 let factor = lerp(0.78, 1.0, pulse) as f32;
232 Color::rgba(
233 base_fill.r * factor,
234 base_fill.g * factor,
235 base_fill.b * factor,
236 base_fill.a,
237 )
238 } else {
239 base_fill
240 };
241 let fill_w = (w * self.props.value).max(0.0);
242 if fill_w >= 1.0 {
243 ctx.set_fill_color(fill_color);
244 ctx.begin_path();
245 ctx.rounded_rect(0.0, bar_y, fill_w, BAR_H, r);
246 ctx.fill();
247 }
248
249 if animating {
254 crate::animation::request_draw_after_tagged(
255 Duration::from_millis(16),
256 "progress_bar.pulse",
257 );
258 }
259
260 if self.props.show_text {
262 let label = format!("{:.0}%", self.props.value * 100.0);
263 ctx.set_font(Arc::clone(&self.font));
264 ctx.set_font_size(self.props.font_size);
265 let mid = w * 0.5;
267 let text_color = if fill_w > mid {
268 Color::rgba(1.0, 1.0, 1.0, 0.9)
269 } else {
270 v.text_dim
271 };
272 ctx.set_fill_color(text_color);
273 if let Some(m) = ctx.measure_text(&label) {
274 let tx = (w - m.width) * 0.5;
275 let ty = bar_y + BAR_H * 0.5 - (m.ascent - m.descent) * 0.5;
276 ctx.fill_text(&label, tx, ty);
277 }
278 }
279 }
280
281 fn on_event(&mut self, event: &Event) -> EventResult {
282 if let Event::MouseMove { pos } = event {
285 if self.animate_on_hover {
286 let was = self.hovered;
287 self.hovered = self.hit_test(*pos);
288 if was != self.hovered {
289 crate::animation::request_draw();
292 }
293 }
294 }
295 EventResult::Ignored
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use crate::draw_ctx::{FillRule, GlPaint, LinearGradientPaint, RadialGradientPaint};
303 use crate::text::TextMetrics;
304 use agg_rust::comp_op::CompOp;
305 use agg_rust::math_stroke::{LineCap, LineJoin};
306 use agg_rust::trans_affine::TransAffine;
307
308 const FONT_BYTES: &[u8] = include_bytes!("../../../demo/assets/CascadiaCode.ttf");
309
310 fn test_font() -> Arc<Font> {
311 Arc::new(Font::from_slice(FONT_BYTES).expect("font"))
312 }
313
314 struct PaintRecorder {
319 transform: TransAffine,
320 stack: Vec<TransAffine>,
321 strokes: usize,
322 filled_rounded_rects: usize,
323 last_was_rounded_rect: bool,
324 }
325
326 impl PaintRecorder {
327 fn new() -> Self {
328 Self {
329 transform: TransAffine::new(),
330 stack: Vec::new(),
331 strokes: 0,
332 filled_rounded_rects: 0,
333 last_was_rounded_rect: false,
334 }
335 }
336 }
337
338 impl DrawCtx for PaintRecorder {
339 fn set_fill_color(&mut self, _color: Color) {}
340 fn set_stroke_color(&mut self, _color: Color) {}
341 fn set_fill_linear_gradient(&mut self, _gradient: LinearGradientPaint) {}
342 fn set_fill_radial_gradient(&mut self, _gradient: RadialGradientPaint) {}
343 fn set_line_width(&mut self, _w: f64) {}
344 fn set_line_join(&mut self, _join: LineJoin) {}
345 fn set_line_cap(&mut self, _cap: LineCap) {}
346 fn set_miter_limit(&mut self, _limit: f64) {}
347 fn set_line_dash(&mut self, _dashes: &[f64], _offset: f64) {}
348 fn set_blend_mode(&mut self, _mode: CompOp) {}
349 fn set_global_alpha(&mut self, _alpha: f64) {}
350 fn set_fill_rule(&mut self, _rule: FillRule) {}
351 fn set_font(&mut self, _font: Arc<Font>) {}
352 fn set_font_size(&mut self, _size: f64) {}
353 fn clip_rect(&mut self, _x: f64, _y: f64, _w: f64, _h: f64) {}
354 fn reset_clip(&mut self) {}
355 fn clear(&mut self, _color: Color) {}
356 fn begin_path(&mut self) {
357 self.last_was_rounded_rect = false;
358 }
359 fn move_to(&mut self, _x: f64, _y: f64) {
360 self.last_was_rounded_rect = false;
361 }
362 fn line_to(&mut self, _x: f64, _y: f64) {
363 self.last_was_rounded_rect = false;
364 }
365 fn cubic_to(&mut self, _cx1: f64, _cy1: f64, _cx2: f64, _cy2: f64, _x: f64, _y: f64) {}
366 fn quad_to(&mut self, _cx: f64, _cy: f64, _x: f64, _y: f64) {}
367 fn arc_to(&mut self, _cx: f64, _cy: f64, _r: f64, _s: f64, _e: f64, _ccw: bool) {}
368 fn circle(&mut self, _cx: f64, _cy: f64, _r: f64) {
369 self.last_was_rounded_rect = false;
370 }
371 fn rect(&mut self, _x: f64, _y: f64, _w: f64, _h: f64) {
372 self.last_was_rounded_rect = false;
373 }
374 fn rounded_rect(&mut self, _x: f64, _y: f64, _w: f64, _h: f64, _r: f64) {
375 self.last_was_rounded_rect = true;
376 }
377 fn close_path(&mut self) {}
378 fn fill(&mut self) {
379 if self.last_was_rounded_rect {
380 self.filled_rounded_rects += 1;
381 }
382 }
383 fn stroke(&mut self) {
384 self.strokes += 1;
385 }
386 fn fill_and_stroke(&mut self) {}
387 fn draw_triangles_aa(&mut self, _vertices: &[[f32; 3]], _indices: &[u32], _color: Color) {}
388 fn fill_text(&mut self, _text: &str, _x: f64, _y: f64) {}
389 fn fill_text_gsv(&mut self, _text: &str, _x: f64, _y: f64, _size: f64) {}
390 fn measure_text(&self, _text: &str) -> Option<TextMetrics> {
391 Some(TextMetrics {
392 width: 30.0,
393 ascent: 8.0,
394 descent: 2.0,
395 line_height: 12.0,
396 })
397 }
398 fn transform(&self) -> TransAffine {
399 self.transform
400 }
401 fn save(&mut self) {
402 self.stack.push(self.transform);
403 }
404 fn restore(&mut self) {
405 if let Some(t) = self.stack.pop() {
406 self.transform = t;
407 }
408 }
409 fn translate(&mut self, tx: f64, ty: f64) {
410 self.transform
411 .premultiply(&TransAffine::new_translation(tx, ty));
412 }
413 fn rotate(&mut self, radians: f64) {
414 self.transform
415 .premultiply(&TransAffine::new_rotation(radians));
416 }
417 fn scale(&mut self, sx: f64, sy: f64) {
418 self.transform.premultiply(&TransAffine::new_scaling(sx, sy));
419 }
420 fn set_transform(&mut self, m: TransAffine) {
421 self.transform = m;
422 }
423 fn reset_transform(&mut self) {
424 self.transform = TransAffine::new();
425 }
426 fn gl_paint(&mut self, _screen_rect: Rect, _painter: &mut dyn GlPaint) {}
427 }
428
429 fn paint_recorded(pb: &mut ProgressBar) -> PaintRecorder {
430 pb.set_bounds(Rect::new(0.0, 0.0, 200.0, WIDGET_H));
431 let mut ctx = PaintRecorder::new();
432 pb.paint(&mut ctx);
433 ctx
434 }
435
436 #[test]
437 fn not_animating_by_default() {
438 let pb = ProgressBar::new(0.5, test_font());
439 assert!(!pb.animating());
440 }
441
442 #[test]
443 fn explicit_animate_runs_below_full() {
444 let pb = ProgressBar::new(0.5, test_font()).with_animate(true);
445 assert!(pb.animating());
446 }
447
448 #[test]
449 fn animation_stops_when_complete() {
450 let pb = ProgressBar::new(1.0, test_font()).with_animate(true);
452 assert!(!pb.animating());
453 }
454
455 #[test]
456 fn hover_mode_only_animates_while_hovered() {
457 let mut pb = ProgressBar::new(0.5, test_font()).with_animate_on_hover(true);
458 assert!(!pb.animating(), "idle until hovered");
459 pb.hovered = true;
460 assert!(pb.animating(), "animates while hovered");
461 pb.hovered = false;
462 assert!(!pb.animating(), "stops when hover leaves");
463 }
464
465 #[test]
469 fn animating_paint_has_no_spinner_arc() {
470 let mut pb = ProgressBar::new(0.5, test_font())
471 .with_animate(true)
472 .with_show_text(false);
473 let rec = paint_recorded(&mut pb);
474 assert_eq!(
475 rec.strokes, 0,
476 "animating bar must not stroke a spinner arc / handle"
477 );
478 assert_eq!(
479 rec.filled_rounded_rects, 2,
480 "track + fill are the only filled shapes"
481 );
482 }
483
484 #[test]
487 fn static_paint_has_no_stroked_element() {
488 let mut pb = ProgressBar::new(0.5, test_font()).with_show_text(false);
489 let rec = paint_recorded(&mut pb);
490 assert_eq!(rec.strokes, 0);
491 assert_eq!(rec.filled_rounded_rects, 2);
492 }
493}