Skip to main content

agg_gui/widgets/
progress_bar.rs

1//! `ProgressBar` — a read-only horizontal progress indicator.
2//!
3//! Supports an optional loading animation (`animate`) that pulses the fill
4//! brightness, mirroring egui's `ProgressBar::animate`. There is deliberately
5//! **no** spinner arc or dot at the fill head — a circle there reads as an
6//! interactive handle, but the bar is read-only. The animation only runs while
7//! `value < 1.0` and the bar is actually painted (i.e. visible), re-arming a
8//! ~60 fps wake via [`request_draw_after`](crate::animation::request_draw_after)
9//! each frame so the loop idles the moment the bar is culled or finishes.
10
11use 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/// Linear interpolation between `a` and `b` by `t` (unclamped).
28#[inline]
29fn lerp(a: f64, b: f64, t: f64) -> f64 {
30    a + (b - a) * t
31}
32
33/// Inspector-visible properties of a [`ProgressBar`].
34#[cfg_attr(feature = "reflect", derive(bevy_reflect::Reflect))]
35#[derive(Clone, Debug)]
36pub struct ProgressBarProps {
37    /// Progress in `[0.0, 1.0]`.
38    pub value: f64,
39    pub show_text: bool,
40    pub font_size: f64,
41    pub fill_color: Option<Color>,
42    /// When `true`, play the loading animation while `value < 1.0`.
43    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
58/// A horizontal progress bar. `value` is in `[0.0, 1.0]`.
59pub struct ProgressBar {
60    bounds: Rect,
61    children: Vec<Box<dyn Widget>>, // always empty
62    base: WidgetBase,
63    pub props: ProgressBarProps,
64    font: Arc<Font>,
65    /// When set, the animation runs while the bar is hovered (matches the egui
66    /// gallery, which passes `response.hovered()` to `.animate`).
67    animate_on_hover: bool,
68    /// Live hover state, tracked so `animate_on_hover` can start/stop the loop.
69    hovered: bool,
70    /// Time origin for the pulse/spinner phase.
71    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    /// Enable the loading animation. While set (and `value < 1.0`), the fill
101    /// brightness pulses gently. Mirrors egui's `ProgressBar::animate`; there is
102    /// no spinner arc or handle at the fill head.
103    pub fn with_animate(mut self, animate: bool) -> Self {
104        self.props.animate = animate;
105        self
106    }
107    /// Runtime setter for the animation flag (e.g. driven by app state).
108    pub fn set_animate(&mut self, animate: bool) {
109        self.props.animate = animate;
110    }
111    /// Animate only while the bar is hovered — the egui Widget Gallery
112    /// behavior ("The progress bar can be animated!").
113    pub fn with_animate_on_hover(mut self, on: bool) -> Self {
114        self.animate_on_hover = on;
115        self
116    }
117
118    /// Whether the animation should currently play: enabled (explicitly or via
119    /// hover) and not yet complete.
120    #[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        // Track
215        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        // Fill — use explicit fill_color if set, otherwise fall back to accent.
223        // While animating, gently pulse the whole fill's brightness (egui-like,
224        // a smooth 0.78..1.0 sine). This is the ONLY animated element: no arc,
225        // dot, or moving handle at the head that could read as interactive.
226        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            // sin maps to 0..1 via (sin+1)/2, then into the 0.78..1.0 range.
230            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        // Keep the pulse alive: re-arm ~60 fps without invalidating cached
250        // widgets. The bar is uncached, so its next paint re-reads the phase
251        // and redraws. Gated on `animating` AND actually painting, so the loop
252        // idles the instant the bar is culled or reaches 100%.
253        if animating {
254            crate::animation::request_draw_after_tagged(
255                Duration::from_millis(16),
256                "progress_bar.pulse",
257            );
258        }
259
260        // Percentage text centered over bar
261        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            // Text color: always use theme text contrasted against the bar.
266            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        // Track hover so `animate_on_hover` can start/stop the loading loop.
283        // Only matters when hover actually gates the animation.
284        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                    // Hover edge changes the bar's content (animation on/off),
290                    // so invalidate; the paint pass re-arms the frame timer.
291                    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    /// Counts stroked paths and filled rounded rects so a test can assert the
315    /// bar draws its track + fill but NO stroked spinner arc (the old handle
316    /// that read as interactive). The arc was the widget's only `stroke()`, so
317    /// `strokes == 0` pins its removal.
318    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        // egui gates the animation on `progress < 1.0`.
451        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    /// Regression: while animating, the bar must draw NO stroked spinner arc /
466    /// handle at the fill head — only the filled track and fill. A circle there
467    /// reads as an interactive drag handle, but the bar is read-only.
468    #[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    /// The static (non-animating) bar likewise draws just track + fill and no
485    /// stroked element — the pulse is purely a fill-brightness effect.
486    #[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}