Skip to main content

elegance/
progress_bar.rs

1//! Determinate progress bar with an in-bar percent label.
2//!
3//! The bar is a rounded pill: a dim track plus an accent-coloured fill
4//! that grows from the left. Text inside the bar renders in two colours
5//! — bright over the filled region, muted over the empty region — so the
6//! label stays legible regardless of the fill level.
7
8use egui::{
9    Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, Widget,
10    WidgetInfo, WidgetType,
11};
12
13use crate::theme::{Accent, Theme};
14
15/// A horizontal determinate progress bar.
16///
17/// ```no_run
18/// # use elegance::{Accent, ProgressBar};
19/// # egui::__run_test_ui(|ui| {
20/// ui.add(ProgressBar::new(0.42));
21/// ui.add(ProgressBar::new(0.8).accent(Accent::Green).text("Uploading…"));
22/// # });
23/// ```
24#[derive(Debug, Clone)]
25#[must_use = "Add with `ui.add(...)`."]
26pub struct ProgressBar {
27    fraction: f32,
28    height: f32,
29    desired_width: Option<f32>,
30    color: Option<Color32>,
31    accent: Option<Accent>,
32    text: Option<String>,
33}
34
35impl ProgressBar {
36    /// Create a progress bar at `fraction` (0..=1). NaN and out-of-range
37    /// values are clamped.
38    pub fn new(fraction: f32) -> Self {
39        Self {
40            fraction: if fraction.is_nan() {
41                0.0
42            } else {
43                fraction.clamp(0.0, 1.0)
44            },
45            height: 22.0,
46            desired_width: None,
47            color: None,
48            accent: None,
49            text: None,
50        }
51    }
52
53    /// Bar height in points. Default: 22.
54    pub fn height(mut self, height: f32) -> Self {
55        self.height = height;
56        self
57    }
58
59    /// Override the bar width. Defaults to `ui.available_width()`.
60    pub fn desired_width(mut self, width: f32) -> Self {
61        self.desired_width = Some(width);
62        self
63    }
64
65    /// Paint the fill with an explicit colour. Clears any previously set accent.
66    pub fn color(mut self, color: Color32) -> Self {
67        self.color = Some(color);
68        self.accent = None;
69        self
70    }
71
72    /// Pick the fill colour from one of the theme's accents. Clears any
73    /// previously set explicit colour.
74    pub fn accent(mut self, accent: Accent) -> Self {
75        self.accent = Some(accent);
76        self.color = None;
77        self
78    }
79
80    /// Override the in-bar text. By default the bar shows the rounded
81    /// percent (e.g. "42%"); passing `""` hides the text entirely.
82    pub fn text(mut self, text: impl Into<String>) -> Self {
83        self.text = Some(text.into());
84        self
85    }
86}
87
88impl Widget for ProgressBar {
89    fn ui(self, ui: &mut Ui) -> Response {
90        let theme = Theme::current(ui.ctx());
91        let p = &theme.palette;
92        let fill_color = match (self.color, self.accent) {
93            (Some(c), _) => c,
94            (_, Some(a)) => p.accent_fill(a),
95            _ => p.sky,
96        };
97
98        let width = self
99            .desired_width
100            .unwrap_or_else(|| ui.available_width())
101            .max(self.height * 2.0);
102        let (rect, response) =
103            ui.allocate_exact_size(Vec2::new(width, self.height), Sense::hover());
104
105        if ui.is_rect_visible(rect) {
106            let painter = ui.painter();
107            let radius = CornerRadius::same((self.height * 0.5).round() as u8);
108
109            // Track: input-bg with a subtle border.
110            painter.rect(
111                rect,
112                radius,
113                p.input_bg,
114                Stroke::new(1.0, p.border),
115                StrokeKind::Inside,
116            );
117
118            // Fill clipped from the left.
119            let fill_w = rect.width() * self.fraction;
120            let fill_rect = Rect::from_min_size(rect.min, Vec2::new(fill_w, rect.height()));
121            if fill_w > 0.5 {
122                painter
123                    .with_clip_rect(fill_rect)
124                    .rect_filled(rect, radius, fill_color);
125            }
126
127            // Label.
128            let label = match self.text.as_deref() {
129                Some(s) => s.to_owned(),
130                None => format!("{}%", (self.fraction * 100.0).round() as u32),
131            };
132            if !label.is_empty() {
133                let font_size = (self.height * 0.55).max(11.0);
134                let galley =
135                    crate::theme::placeholder_galley(ui, &label, font_size, true, f32::INFINITY);
136                let text_pos = Pos2::new(
137                    rect.center().x - galley.size().x * 0.5,
138                    rect.center().y - galley.size().y * 0.5,
139                );
140
141                // Muted pass over the empty region.
142                let empty_rect =
143                    Rect::from_min_max(Pos2::new(rect.min.x + fill_w, rect.min.y), rect.max);
144                painter
145                    .with_clip_rect(empty_rect)
146                    .galley(text_pos, galley.clone(), p.text_muted);
147
148                // Bright pass over the filled region.
149                painter
150                    .with_clip_rect(fill_rect)
151                    .galley(text_pos, galley, Color32::WHITE);
152            }
153        }
154
155        response
156            .widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, "progress"));
157        response
158    }
159}