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(Duration::from_millis(16));
255        }
256
257        // Percentage text centered over bar
258        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            // Text color: always use theme text contrasted against the bar.
263            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        // Track hover so `animate_on_hover` can start/stop the loading loop.
280        // Only matters when hover actually gates the animation.
281        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                    // Hover edge changes the bar's content (animation on/off),
287                    // so invalidate; the paint pass re-arms the frame timer.
288                    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    /// Counts stroked paths and filled rounded rects so a test can assert the
312    /// bar draws its track + fill but NO stroked spinner arc (the old handle
313    /// that read as interactive). The arc was the widget's only `stroke()`, so
314    /// `strokes == 0` pins its removal.
315    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        // egui gates the animation on `progress < 1.0`.
448        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    /// Regression: while animating, the bar must draw NO stroked spinner arc /
463    /// handle at the fill head — only the filled track and fill. A circle there
464    /// reads as an interactive drag handle, but the bar is read-only.
465    #[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    /// The static (non-animating) bar likewise draws just track + fill and no
482    /// stroked element — the pulse is purely a fill-brightness effect.
483    #[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}