Skip to main content

elegance/
gauge.rs

1//! Gauges: radial half-circle and linear meter.
2//!
3//! Two widgets for displaying a current value (as a 0..1 fraction)
4//! against optional threshold zones:
5//!
6//! - [`RadialGauge`] — half-circle speedometer with an optional needle and
7//!   value readout in the bowl. The classic dashboard gauge.
8//! - [`LinearGauge`] — horizontal bar with optional faded threshold bands
9//!   behind the fill, optional ticks and labels above.
10//!
11//! For the donut form (a circular gauge with no needle), use
12//! [`ProgressRing`](crate::ProgressRing) with [`ProgressRing::zones`]:
13//! it shares the same shape as a determinate progress indicator, plus
14//! [`ProgressRing::unit`] for a baseline-aligned suffix and
15//! [`ProgressRing::caption_below`] to anchor a caption outside the ring.
16//!
17//! Both gauges derive their fill colour from [`GaugeZones`] when supplied
18//! (`success` / `warning` / `danger` based on which band the value falls
19//! in). Without zones they fall back to the theme's sky accent. Override
20//! either with the per-widget `color` builder.
21
22use std::f32::consts::PI;
23
24use egui::{
25    epaint::{PathShape, PathStroke},
26    pos2, Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, Widget,
27    WidgetInfo, WidgetType,
28};
29
30use crate::theme::{placeholder_galley, with_alpha, Palette, Theme, BASELINE_FRAC};
31
32/// Threshold breakpoints driving automatic colouring on a gauge.
33///
34/// Values up to `warn` paint in the theme's `success` colour, values
35/// from `warn..crit` paint in `warning`, and values `>= crit` paint in
36/// `danger`. Both fields are clamped to `0..=1` and `crit` is forced to
37/// be at least `warn`.
38///
39/// ```
40/// # use elegance::GaugeZones;
41/// let z = GaugeZones::new(0.6, 0.85);
42/// assert_eq!(z.warn(), 0.6);
43/// assert_eq!(z.crit(), 0.85);
44/// ```
45#[derive(Clone, Copy, Debug, PartialEq)]
46pub struct GaugeZones {
47    warn: f32,
48    crit: f32,
49}
50
51impl GaugeZones {
52    /// Create a new zone breakdown. `warn` is the green→amber boundary,
53    /// `crit` the amber→red boundary. Both are clamped to `0..=1`; `crit`
54    /// is raised to `warn` if it would otherwise be smaller.
55    pub fn new(warn: f32, crit: f32) -> Self {
56        let warn = warn.clamp(0.0, 1.0);
57        let crit = crit.clamp(0.0, 1.0).max(warn);
58        Self { warn, crit }
59    }
60
61    /// The green→amber boundary as a fraction in `0..=1`.
62    pub const fn warn(self) -> f32 {
63        self.warn
64    }
65
66    /// The amber→red boundary as a fraction in `0..=1`.
67    pub const fn crit(self) -> f32 {
68        self.crit
69    }
70
71    pub(crate) fn color(&self, fraction: f32, palette: &Palette) -> Color32 {
72        if fraction >= self.crit {
73            palette.danger
74        } else if fraction >= self.warn {
75            palette.warning
76        } else {
77            palette.success
78        }
79    }
80}
81
82fn clamp_fraction(f: f32) -> f32 {
83    if f.is_nan() {
84        0.0
85    } else {
86        f.clamp(0.0, 1.0)
87    }
88}
89
90fn track_color(palette: &Palette) -> Color32 {
91    if palette.is_dark {
92        palette.bg
93    } else {
94        palette.depth_tint(palette.input_bg, 0.04)
95    }
96}
97
98// --- Radial -----------------------------------------------------------------
99
100/// Half-circle dashboard gauge with an optional needle and value readout.
101///
102/// ```no_run
103/// # use elegance::{RadialGauge, GaugeZones};
104/// # egui::__run_test_ui(|ui| {
105/// ui.add(
106///     RadialGauge::new(0.42)
107///         .zones(GaugeZones::new(0.6, 0.85)),
108/// );
109/// # });
110/// ```
111#[derive(Clone, Debug)]
112#[must_use = "Add with `ui.add(...)`."]
113pub struct RadialGauge {
114    fraction: f32,
115    size: f32,
116    color: Option<Color32>,
117    zones: Option<GaugeZones>,
118    needle: bool,
119    text: Option<String>,
120    unit: Option<String>,
121    show_scale: bool,
122}
123
124impl RadialGauge {
125    /// Create a gauge displaying `fraction` (0..=1). NaN and out-of-range
126    /// values are clamped.
127    pub fn new(fraction: f32) -> Self {
128        Self {
129            fraction: clamp_fraction(fraction),
130            size: 200.0,
131            color: None,
132            zones: None,
133            needle: true,
134            text: None,
135            unit: None,
136            show_scale: true,
137        }
138    }
139
140    /// Outer width in points. The gauge's height is roughly `0.74 * size`
141    /// for the arc plus a small reserve for the scale labels. Default: 200.
142    /// Clamped to at least 80.
143    #[inline]
144    pub fn size(mut self, size: f32) -> Self {
145        self.size = size.max(80.0);
146        self
147    }
148
149    /// Override the fill colour. Clears any previously set zones-based
150    /// colouring (zones still drive the threshold bands if configured).
151    #[inline]
152    pub fn color(mut self, color: Color32) -> Self {
153        self.color = Some(color);
154        self
155    }
156
157    /// Configure threshold zones. The fill colour is auto-derived from the
158    /// zone the current fraction falls in (success/warning/danger), and
159    /// faint bands are painted behind the active fill at the boundaries.
160    #[inline]
161    pub fn zones(mut self, zones: GaugeZones) -> Self {
162        self.zones = Some(zones);
163        self
164    }
165
166    /// Whether to draw the needle. Default: on.
167    #[inline]
168    pub fn needle(mut self, on: bool) -> Self {
169        self.needle = on;
170        self
171    }
172
173    /// Override the value readout. Default: rounded percent (e.g. "42").
174    /// Pass `""` to hide the readout entirely.
175    #[inline]
176    pub fn text(mut self, text: impl Into<String>) -> Self {
177        self.text = Some(text.into());
178        self
179    }
180
181    /// Override the unit suffix shown after the value. Default: `"%"` when
182    /// the readout uses the auto percent; no unit otherwise. Pass `""` to
183    /// hide the unit.
184    #[inline]
185    pub fn unit(mut self, unit: impl Into<String>) -> Self {
186        self.unit = Some(unit.into());
187        self
188    }
189
190    /// Whether to draw the `0`/`100` scale labels under the arc. Default: on.
191    #[inline]
192    pub fn show_scale(mut self, on: bool) -> Self {
193        self.show_scale = on;
194        self
195    }
196}
197
198impl Widget for RadialGauge {
199    fn ui(self, ui: &mut Ui) -> Response {
200        let theme = Theme::current(ui.ctx());
201        let p = &theme.palette;
202
203        let scale_size = (self.size * 0.052).clamp(9.0, 12.0);
204        let scale_h = if self.show_scale {
205            scale_size + 4.0
206        } else {
207            0.0
208        };
209        let arc_h = self.size * 0.74;
210        let total_h = arc_h + scale_h;
211        let (rect, response) =
212            ui.allocate_exact_size(Vec2::new(self.size, total_h), Sense::hover());
213
214        if ui.is_rect_visible(rect) {
215            let painter = ui.painter();
216            let arc_rect = Rect::from_min_size(rect.min, Vec2::new(self.size, arc_h));
217            let cx = arc_rect.center().x;
218            let cy = arc_rect.top() + self.size * 0.5;
219            let r = self.size * 0.4;
220            let stroke_w = self.size * 0.07;
221
222            // Sample the half-arc; arc_point(t) maps t in 0..1 from the
223            // left endpoint (fraction 0) to the right endpoint (fraction 1)
224            // sweeping over the top.
225            let n_segments: usize = 96;
226            let arc_point = |t: f32| -> Pos2 {
227                let a = PI - PI * t;
228                pos2(cx + r * a.cos(), cy - r * a.sin())
229            };
230            let arc_points = |start: f32, end: f32| -> Vec<Pos2> {
231                let span = (end - start).max(0.0);
232                let n = ((n_segments as f32 * span).ceil() as usize).max(2);
233                (0..=n)
234                    .map(|i| arc_point(start + span * (i as f32 / n as f32)))
235                    .collect()
236            };
237
238            // Track.
239            painter.add(PathShape::line(
240                arc_points(0.0, 1.0),
241                PathStroke::new(stroke_w, track_color(p)),
242            ));
243
244            // Threshold bands.
245            if let Some(z) = &self.zones {
246                painter.add(PathShape::line(
247                    arc_points(0.0, z.warn),
248                    PathStroke::new(stroke_w, with_alpha(p.success, 56)),
249                ));
250                painter.add(PathShape::line(
251                    arc_points(z.warn, z.crit),
252                    PathStroke::new(stroke_w, with_alpha(p.warning, 60)),
253                ));
254                painter.add(PathShape::line(
255                    arc_points(z.crit, 1.0),
256                    PathStroke::new(stroke_w, with_alpha(p.danger, 66)),
257                ));
258            }
259
260            // Active fill.
261            let fill_color = self.color.unwrap_or_else(|| {
262                self.zones
263                    .as_ref()
264                    .map(|z| z.color(self.fraction, p))
265                    .unwrap_or(p.sky)
266            });
267            if self.fraction > 0.0 {
268                painter.add(PathShape::line(
269                    arc_points(0.0, self.fraction),
270                    PathStroke::new(stroke_w, fill_color),
271                ));
272            }
273
274            // Tick marks at zone boundaries.
275            if let Some(z) = &self.zones {
276                for &boundary in &[z.warn, z.crit] {
277                    let a = PI - PI * boundary;
278                    let inner_r = r + stroke_w * 0.5 + 1.0;
279                    let outer_r = inner_r + stroke_w * 0.55;
280                    let inner = pos2(cx + inner_r * a.cos(), cy - inner_r * a.sin());
281                    let outer = pos2(cx + outer_r * a.cos(), cy - outer_r * a.sin());
282                    painter.line_segment([inner, outer], Stroke::new(1.0, p.text_muted));
283                }
284            }
285
286            // Needle.
287            if self.needle {
288                let a = PI - PI * self.fraction;
289                let needle_len = r * 0.9;
290                let half_w = (self.size * 0.013).max(1.5);
291                let perp = a + PI * 0.5;
292                let tip = pos2(cx + needle_len * a.cos(), cy - needle_len * a.sin());
293                let base_l = pos2(cx + half_w * perp.cos(), cy - half_w * perp.sin());
294                let base_r = pos2(cx - half_w * perp.cos(), cy + half_w * perp.sin());
295                painter.add(PathShape::convex_polygon(
296                    vec![tip, base_l, base_r],
297                    p.text,
298                    Stroke::NONE,
299                ));
300
301                let pivot_r = (self.size * 0.03).max(4.0);
302                painter.circle_filled(pos2(cx, cy), pivot_r, p.card);
303                painter.circle_stroke(pos2(cx, cy), pivot_r, Stroke::new(1.5, p.text));
304                painter.circle_filled(pos2(cx, cy), pivot_r * 0.28, p.bg);
305            }
306
307            // Value readout (in the bowl, below the pivot).
308            let primary_size = (self.size * 0.15).clamp(14.0, 36.0);
309            let unit_size = (self.size * 0.085).clamp(12.0, 22.0);
310            let primary = self
311                .text
312                .clone()
313                .unwrap_or_else(|| format!("{}", (self.fraction * 100.0).round() as u32));
314            let unit = self.unit.clone().unwrap_or_else(|| {
315                if self.text.is_none() {
316                    "%".into()
317                } else {
318                    String::new()
319                }
320            });
321
322            if !primary.is_empty() {
323                let g_num = placeholder_galley(ui, &primary, primary_size, true, f32::INFINITY);
324                let g_unit = (!unit.is_empty())
325                    .then(|| placeholder_galley(ui, &unit, unit_size, false, f32::INFINITY));
326                let num_w = g_num.size().x;
327                let num_h = g_num.size().y;
328                let unit_w = g_unit.as_ref().map_or(0.0, |g| g.size().x);
329                let gap = if g_unit.is_some() { 3.0 } else { 0.0 };
330                let total_w = num_w + gap + unit_w;
331
332                let bottom_y = arc_rect.bottom() - 6.0;
333                let num_top = bottom_y - num_h;
334                let start_x = cx - total_w * 0.5;
335                painter.galley(pos2(start_x, num_top), g_num, p.text);
336                if let Some(g) = g_unit {
337                    let baseline = num_top + num_h * BASELINE_FRAC;
338                    let unit_y = baseline - g.size().y * BASELINE_FRAC;
339                    painter.galley(pos2(start_x + num_w + gap, unit_y), g, p.text_muted);
340                }
341            }
342
343            // Scale labels.
344            if self.show_scale {
345                let label_y = arc_rect.bottom() + 2.0;
346                let g_left = placeholder_galley(ui, "0", scale_size, false, f32::INFINITY);
347                let g_right = placeholder_galley(ui, "100", scale_size, false, f32::INFINITY);
348                painter.galley(
349                    pos2(cx - r - g_left.size().x * 0.5, label_y),
350                    g_left,
351                    p.text_faint,
352                );
353                painter.galley(
354                    pos2(cx + r - g_right.size().x * 0.5, label_y),
355                    g_right,
356                    p.text_faint,
357                );
358            }
359        }
360
361        response.widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, "gauge"));
362        response
363    }
364}
365
366// --- Linear -----------------------------------------------------------------
367
368/// Horizontal meter with optional threshold bands and labels.
369///
370/// Differs from [`ProgressBar`](crate::ProgressBar) in three ways:
371/// threshold zones drive the fill colour (success / warning / danger),
372/// faint bands paint behind the fill at zone boundaries, and a thumb
373/// marker pins the current position over the bar. Threshold ticks and
374/// labels above the bar are opt-in via [`LinearGauge::threshold_label`]
375/// or [`LinearGauge::show_zone_labels`].
376///
377/// ```no_run
378/// # use elegance::{LinearGauge, GaugeZones};
379/// # egui::__run_test_ui(|ui| {
380/// ui.add(
381///     LinearGauge::new(0.42)
382///         .zones(GaugeZones::new(0.6, 0.85))
383///         .show_zone_labels(),
384/// );
385/// # });
386/// ```
387#[derive(Clone, Debug)]
388#[must_use = "Add with `ui.add(...)`."]
389pub struct LinearGauge {
390    fraction: f32,
391    height: f32,
392    desired_width: Option<f32>,
393    color: Option<Color32>,
394    zones: Option<GaugeZones>,
395    threshold_labels: Vec<(f32, String)>,
396    thumb: bool,
397}
398
399impl LinearGauge {
400    /// Create a meter at `fraction` (0..=1). NaN and out-of-range values
401    /// are clamped.
402    pub fn new(fraction: f32) -> Self {
403        Self {
404            fraction: clamp_fraction(fraction),
405            height: 14.0,
406            desired_width: None,
407            color: None,
408            zones: None,
409            threshold_labels: Vec::new(),
410            thumb: true,
411        }
412    }
413
414    /// Bar height in points. Default: 14.
415    #[inline]
416    pub fn height(mut self, height: f32) -> Self {
417        self.height = height.max(6.0);
418        self
419    }
420
421    /// Override the bar width. Defaults to `ui.available_width()`.
422    #[inline]
423    pub fn desired_width(mut self, width: f32) -> Self {
424        self.desired_width = Some(width);
425        self
426    }
427
428    /// Override the fill colour.
429    #[inline]
430    pub fn color(mut self, color: Color32) -> Self {
431        self.color = Some(color);
432        self
433    }
434
435    /// Configure threshold zones. Faint bands paint behind the fill at
436    /// the boundaries and the fill colour auto-derives from the active
437    /// zone (success / warning / danger).
438    #[inline]
439    pub fn zones(mut self, zones: GaugeZones) -> Self {
440        self.zones = Some(zones);
441        self
442    }
443
444    /// Add a tick + label above the bar at `position` (0..=1). Stack
445    /// multiple calls to register more.
446    pub fn threshold_label(mut self, position: f32, label: impl Into<String>) -> Self {
447        self.threshold_labels
448            .push((position.clamp(0.0, 1.0), label.into()));
449        self
450    }
451
452    /// Convenience: add ticks + labels at the configured zone boundaries,
453    /// formatting each as a percent. Has no effect unless [`zones`] is
454    /// also set.
455    ///
456    /// [`zones`]: LinearGauge::zones
457    pub fn show_zone_labels(mut self) -> Self {
458        if let Some(z) = self.zones {
459            self.threshold_labels
460                .push((z.warn, format!("{}", (z.warn * 100.0).round() as u32)));
461            self.threshold_labels
462                .push((z.crit, format!("{}", (z.crit * 100.0).round() as u32)));
463        }
464        self
465    }
466
467    /// Whether to draw the thumb marker at the current position. Default: on.
468    #[inline]
469    pub fn thumb(mut self, on: bool) -> Self {
470        self.thumb = on;
471        self
472    }
473}
474
475impl Widget for LinearGauge {
476    fn ui(self, ui: &mut Ui) -> Response {
477        let theme = Theme::current(ui.ctx());
478        let p = &theme.palette;
479
480        let label_size = 10.0;
481        let label_pad = 6.0;
482        let label_h = if self.threshold_labels.is_empty() {
483            0.0
484        } else {
485            label_size + label_pad
486        };
487        let width = self
488            .desired_width
489            .unwrap_or_else(|| ui.available_width())
490            .max(self.height * 4.0);
491        let total_h = self.height + label_h;
492        let (rect, response) = ui.allocate_exact_size(Vec2::new(width, total_h), Sense::hover());
493
494        if ui.is_rect_visible(rect) {
495            let painter = ui.painter();
496            let bar_rect = Rect::from_min_size(
497                pos2(rect.left(), rect.top() + label_h),
498                Vec2::new(width, self.height),
499            );
500            let radius = CornerRadius::same((self.height * 0.5).round() as u8);
501
502            // Track.
503            painter.rect(
504                bar_rect,
505                radius,
506                p.input_bg,
507                Stroke::new(1.0, p.border),
508                StrokeKind::Inside,
509            );
510
511            // Zone bands behind the fill, with rounded outer corners only
512            // so they meet the bar's pill shape on either end.
513            if let Some(z) = &self.zones {
514                let r = (self.height * 0.5).round() as u8;
515                let band =
516                    |start: f32, end: f32, color: Color32, left_round: bool, right_round: bool| {
517                        if end <= start {
518                            return;
519                        }
520                        let x0 = bar_rect.left() + bar_rect.width() * start;
521                        let x1 = bar_rect.left() + bar_rect.width() * end;
522                        let cr = CornerRadius {
523                            nw: if left_round { r } else { 0 },
524                            sw: if left_round { r } else { 0 },
525                            ne: if right_round { r } else { 0 },
526                            se: if right_round { r } else { 0 },
527                        };
528                        let rect = Rect::from_min_max(
529                            pos2(x0, bar_rect.top()),
530                            pos2(x1, bar_rect.bottom()),
531                        );
532                        painter.rect_filled(rect.shrink(0.5), cr, color);
533                    };
534                band(0.0, z.warn, with_alpha(p.success, 50), true, false);
535                band(z.warn, z.crit, with_alpha(p.warning, 56), false, false);
536                band(z.crit, 1.0, with_alpha(p.danger, 60), false, true);
537            }
538
539            // Active fill.
540            let fill_color = self.color.unwrap_or_else(|| {
541                self.zones
542                    .as_ref()
543                    .map(|z| z.color(self.fraction, p))
544                    .unwrap_or(p.sky)
545            });
546            let fill_w = bar_rect.width() * self.fraction;
547            if fill_w > 0.5 {
548                let fill_rect =
549                    Rect::from_min_size(bar_rect.min, Vec2::new(fill_w, bar_rect.height()));
550                painter
551                    .with_clip_rect(fill_rect)
552                    .rect_filled(bar_rect, radius, fill_color);
553            }
554
555            // Thumb.
556            if self.thumb && self.fraction > 0.0 {
557                let x = bar_rect.left() + fill_w;
558                painter.line_segment(
559                    [
560                        pos2(x, bar_rect.top() + 1.0),
561                        pos2(x, bar_rect.bottom() - 1.0),
562                    ],
563                    Stroke::new(2.0, p.text),
564                );
565            }
566
567            // Threshold ticks + labels.
568            for (pos, label) in &self.threshold_labels {
569                let x = bar_rect.left() + bar_rect.width() * pos.clamp(0.0, 1.0);
570                let g = placeholder_galley(ui, label, label_size, false, f32::INFINITY);
571                let label_y = rect.top();
572                painter.galley(pos2(x - g.size().x * 0.5, label_y), g, p.text_faint);
573                let tick_top = label_y + label_size + 1.0;
574                let tick_bot = bar_rect.top() - 1.0;
575                if tick_bot > tick_top {
576                    painter.line_segment(
577                        [pos2(x, tick_top), pos2(x, tick_bot)],
578                        Stroke::new(1.0, p.text_faint),
579                    );
580                }
581            }
582        }
583
584        response.widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, "meter"));
585        response
586    }
587}