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(Duration::from_millis(16));
255 }
256
257 if self.props.show_text {
259 let label = format!("{:.0}%", self.props.value * 100.0);
260 ctx.set_font(Arc::clone(&self.font));
261 ctx.set_font_size(self.props.font_size);
262 let mid = w * 0.5;
264 let text_color = if fill_w > mid {
265 Color::rgba(1.0, 1.0, 1.0, 0.9)
266 } else {
267 v.text_dim
268 };
269 ctx.set_fill_color(text_color);
270 if let Some(m) = ctx.measure_text(&label) {
271 let tx = (w - m.width) * 0.5;
272 let ty = bar_y + BAR_H * 0.5 - (m.ascent - m.descent) * 0.5;
273 ctx.fill_text(&label, tx, ty);
274 }
275 }
276 }
277
278 fn on_event(&mut self, event: &Event) -> EventResult {
279 if let Event::MouseMove { pos } = event {
282 if self.animate_on_hover {
283 let was = self.hovered;
284 self.hovered = self.hit_test(*pos);
285 if was != self.hovered {
286 crate::animation::request_draw();
289 }
290 }
291 }
292 EventResult::Ignored
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use crate::draw_ctx::{FillRule, GlPaint, LinearGradientPaint, RadialGradientPaint};
300 use crate::text::TextMetrics;
301 use agg_rust::comp_op::CompOp;
302 use agg_rust::math_stroke::{LineCap, LineJoin};
303 use agg_rust::trans_affine::TransAffine;
304
305 const FONT_BYTES: &[u8] = include_bytes!("../../../demo/assets/CascadiaCode.ttf");
306
307 fn test_font() -> Arc<Font> {
308 Arc::new(Font::from_slice(FONT_BYTES).expect("font"))
309 }
310
311 struct PaintRecorder {
316 transform: TransAffine,
317 stack: Vec<TransAffine>,
318 strokes: usize,
319 filled_rounded_rects: usize,
320 last_was_rounded_rect: bool,
321 }
322
323 impl PaintRecorder {
324 fn new() -> Self {
325 Self {
326 transform: TransAffine::new(),
327 stack: Vec::new(),
328 strokes: 0,
329 filled_rounded_rects: 0,
330 last_was_rounded_rect: false,
331 }
332 }
333 }
334
335 impl DrawCtx for PaintRecorder {
336 fn set_fill_color(&mut self, _color: Color) {}
337 fn set_stroke_color(&mut self, _color: Color) {}
338 fn set_fill_linear_gradient(&mut self, _gradient: LinearGradientPaint) {}
339 fn set_fill_radial_gradient(&mut self, _gradient: RadialGradientPaint) {}
340 fn set_line_width(&mut self, _w: f64) {}
341 fn set_line_join(&mut self, _join: LineJoin) {}
342 fn set_line_cap(&mut self, _cap: LineCap) {}
343 fn set_miter_limit(&mut self, _limit: f64) {}
344 fn set_line_dash(&mut self, _dashes: &[f64], _offset: f64) {}
345 fn set_blend_mode(&mut self, _mode: CompOp) {}
346 fn set_global_alpha(&mut self, _alpha: f64) {}
347 fn set_fill_rule(&mut self, _rule: FillRule) {}
348 fn set_font(&mut self, _font: Arc<Font>) {}
349 fn set_font_size(&mut self, _size: f64) {}
350 fn clip_rect(&mut self, _x: f64, _y: f64, _w: f64, _h: f64) {}
351 fn reset_clip(&mut self) {}
352 fn clear(&mut self, _color: Color) {}
353 fn begin_path(&mut self) {
354 self.last_was_rounded_rect = false;
355 }
356 fn move_to(&mut self, _x: f64, _y: f64) {
357 self.last_was_rounded_rect = false;
358 }
359 fn line_to(&mut self, _x: f64, _y: f64) {
360 self.last_was_rounded_rect = false;
361 }
362 fn cubic_to(&mut self, _cx1: f64, _cy1: f64, _cx2: f64, _cy2: f64, _x: f64, _y: f64) {}
363 fn quad_to(&mut self, _cx: f64, _cy: f64, _x: f64, _y: f64) {}
364 fn arc_to(&mut self, _cx: f64, _cy: f64, _r: f64, _s: f64, _e: f64, _ccw: bool) {}
365 fn circle(&mut self, _cx: f64, _cy: f64, _r: f64) {
366 self.last_was_rounded_rect = false;
367 }
368 fn rect(&mut self, _x: f64, _y: f64, _w: f64, _h: f64) {
369 self.last_was_rounded_rect = false;
370 }
371 fn rounded_rect(&mut self, _x: f64, _y: f64, _w: f64, _h: f64, _r: f64) {
372 self.last_was_rounded_rect = true;
373 }
374 fn close_path(&mut self) {}
375 fn fill(&mut self) {
376 if self.last_was_rounded_rect {
377 self.filled_rounded_rects += 1;
378 }
379 }
380 fn stroke(&mut self) {
381 self.strokes += 1;
382 }
383 fn fill_and_stroke(&mut self) {}
384 fn draw_triangles_aa(&mut self, _vertices: &[[f32; 3]], _indices: &[u32], _color: Color) {}
385 fn fill_text(&mut self, _text: &str, _x: f64, _y: f64) {}
386 fn fill_text_gsv(&mut self, _text: &str, _x: f64, _y: f64, _size: f64) {}
387 fn measure_text(&self, _text: &str) -> Option<TextMetrics> {
388 Some(TextMetrics {
389 width: 30.0,
390 ascent: 8.0,
391 descent: 2.0,
392 line_height: 12.0,
393 })
394 }
395 fn transform(&self) -> TransAffine {
396 self.transform
397 }
398 fn save(&mut self) {
399 self.stack.push(self.transform);
400 }
401 fn restore(&mut self) {
402 if let Some(t) = self.stack.pop() {
403 self.transform = t;
404 }
405 }
406 fn translate(&mut self, tx: f64, ty: f64) {
407 self.transform
408 .premultiply(&TransAffine::new_translation(tx, ty));
409 }
410 fn rotate(&mut self, radians: f64) {
411 self.transform
412 .premultiply(&TransAffine::new_rotation(radians));
413 }
414 fn scale(&mut self, sx: f64, sy: f64) {
415 self.transform.premultiply(&TransAffine::new_scaling(sx, sy));
416 }
417 fn set_transform(&mut self, m: TransAffine) {
418 self.transform = m;
419 }
420 fn reset_transform(&mut self) {
421 self.transform = TransAffine::new();
422 }
423 fn gl_paint(&mut self, _screen_rect: Rect, _painter: &mut dyn GlPaint) {}
424 }
425
426 fn paint_recorded(pb: &mut ProgressBar) -> PaintRecorder {
427 pb.set_bounds(Rect::new(0.0, 0.0, 200.0, WIDGET_H));
428 let mut ctx = PaintRecorder::new();
429 pb.paint(&mut ctx);
430 ctx
431 }
432
433 #[test]
434 fn not_animating_by_default() {
435 let pb = ProgressBar::new(0.5, test_font());
436 assert!(!pb.animating());
437 }
438
439 #[test]
440 fn explicit_animate_runs_below_full() {
441 let pb = ProgressBar::new(0.5, test_font()).with_animate(true);
442 assert!(pb.animating());
443 }
444
445 #[test]
446 fn animation_stops_when_complete() {
447 let pb = ProgressBar::new(1.0, test_font()).with_animate(true);
449 assert!(!pb.animating());
450 }
451
452 #[test]
453 fn hover_mode_only_animates_while_hovered() {
454 let mut pb = ProgressBar::new(0.5, test_font()).with_animate_on_hover(true);
455 assert!(!pb.animating(), "idle until hovered");
456 pb.hovered = true;
457 assert!(pb.animating(), "animates while hovered");
458 pb.hovered = false;
459 assert!(!pb.animating(), "stops when hover leaves");
460 }
461
462 #[test]
466 fn animating_paint_has_no_spinner_arc() {
467 let mut pb = ProgressBar::new(0.5, test_font())
468 .with_animate(true)
469 .with_show_text(false);
470 let rec = paint_recorded(&mut pb);
471 assert_eq!(
472 rec.strokes, 0,
473 "animating bar must not stroke a spinner arc / handle"
474 );
475 assert_eq!(
476 rec.filled_rounded_rects, 2,
477 "track + fill are the only filled shapes"
478 );
479 }
480
481 #[test]
484 fn static_paint_has_no_stroked_element() {
485 let mut pb = ProgressBar::new(0.5, test_font()).with_show_text(false);
486 let rec = paint_recorded(&mut pb);
487 assert_eq!(rec.strokes, 0);
488 assert_eq!(rec.filled_rounded_rects, 2);
489 }
490}