Skip to main content

retroglyph_ui/widget/
gauge.rs

1//! [`Gauge`]: a labeled, load-colored progress bar.
2use core::fmt::Write as _;
3
4use retroglyph_core::color::{Color, Style};
5
6use super::{Widget, bar};
7use crate::Surface;
8use crate::theme::Theme;
9
10/// A labeled gauge: a `label`, then a bar filling `ratio` (0.0-1.0) of the
11/// remaining width, colored by [`super::Meter`], with a trailing percentage.
12///
13/// Only the first row of `area` is used. Generalizes
14/// [`ProgressBar`](super::ProgressBar) with a load-colored fill and inline
15/// label/readout. For a `current`/`max` integer stat (health, mana) rather
16/// than a `0.0..=1.0` load ratio, see [`super::StatBar`]. `label_style` defaults to
17/// [`Theme::DARK`]'s `dim` role (as if [`Gauge::theme`] had been called); set it with
18/// [`Gauge::label_style`]. The fill (and readout) color defaults to [`super::Meter`]'s
19/// green→yellow→red load ramp; override it with [`Gauge::fill_color`] for a gauge that isn't
20/// load-shaped (e.g. a health bar, where full should read as safe, not danger).
21///
22/// # Examples
23///
24/// ```
25/// use retroglyph_core::grid::{Grid, Rect};
26/// use retroglyph_ui::widget::{Gauge, Widget};
27/// use retroglyph_ui::Surface;
28///
29/// let area = Rect::new(0, 0, 20, 1);
30/// let mut grid = Grid::new(20, 1);
31/// Gauge::new("CPU", 0.75).render(&mut Surface::new(&mut grid, area, 0));
32/// ```
33#[derive(Clone, Copy, Debug)]
34pub struct Gauge<'a> {
35    label: &'a str,
36    ratio: f32,
37    label_style: Style,
38    fill_color: fn(f32) -> Color,
39}
40
41impl<'a> Gauge<'a> {
42    /// A gauge for `label`, filled to `ratio` (0.0-1.0), with `label_style` styled from
43    /// [`Theme::DARK`] (as if [`Gauge::theme`] had been called).
44    #[must_use]
45    pub fn new(label: &'a str, ratio: f32) -> Self {
46        Self {
47            label,
48            ratio,
49            label_style: Style::new(),
50            fill_color: bar::meter_fill_color,
51        }
52        .theme(Theme::DARK)
53    }
54
55    /// Set the label's style.
56    #[must_use]
57    pub const fn label_style(mut self, style: Style) -> Self {
58        self.label_style = style;
59        self
60    }
61
62    /// Overrides the bar's fill (and readout text) color from [`super::Meter`]'s default
63    /// green→yellow→red load ramp to `fill_color`, called with the clamped `0.0..=1.0` ratio.
64    ///
65    /// For gauges that aren't load-shaped, where the default ramp would read backwards (a full
66    /// health bar reading as danger rather than safe): supply a ramp that fits, e.g.
67    /// `|r| Color::lerp(Color::RED, Color::GREEN, r)`. A plain `fn` pointer rather than a
68    /// boxed closure, so `Gauge` stays `Copy`; a non-capturing closure like the one above
69    /// coerces to it automatically.
70    #[must_use]
71    pub const fn fill_color(mut self, fill_color: fn(f32) -> Color) -> Self {
72        self.fill_color = fill_color;
73        self
74    }
75
76    /// Sets `label_style` to `theme.dim` on `theme.panel_bg`: the same de-emphasized role
77    /// `09_widgets_dashboard` already uses for the plain-text label next to this gauge's
78    /// sparkline. The bar's own fill stays load-colored via [`super::Meter`] regardless of
79    /// `theme` (matching every other gauge/meter-backed widget here; see [`super::Sparkline`]'s
80    /// doc comment for why that coloring is not part of the [`Theme`] role palette), unless
81    /// overridden separately with [`Gauge::fill_color`].
82    ///
83    /// `label_style` sets an explicit background rather than leaving it at [`Style::new()`]'s
84    /// default: an unset background isn't "transparent" once a real backend draws it (a bare
85    /// `Color::Default` cell paints as solid black behind the glyph; see
86    /// `retroglyph-software`'s `DEFAULT_BG`), so this widget assumes it's drawn on
87    /// `theme.panel_bg`, true when composed with a themed [`super::Panel`]/[`super::Modal`].
88    /// Drawing this gauge directly on the raw screen background instead needs a manual
89    /// `.label_style(...)` override afterwards.
90    ///
91    /// Call before any manual [`Gauge::label_style`] override you want to keep.
92    #[must_use]
93    pub fn theme(self, theme: Theme) -> Self {
94        self.theme_on(theme, theme.panel_bg)
95    }
96
97    /// Same as [`Gauge::theme`], but `label_style` is drawn on `bg` instead of `theme.panel_bg`,
98    /// for a gauge drawn directly on a backdrop other than a themed [`super::Panel`]/
99    /// [`super::Modal`]'s fill. [`Gauge::theme`] is exactly `theme_on(theme, theme.panel_bg)`.
100    #[must_use]
101    pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
102        self.label_style = Style::new().fg(theme.dim).bg(bg);
103        self
104    }
105}
106
107impl Widget for Gauge<'_> {
108    fn render(&self, surface: &mut Surface<'_>) {
109        let ratio = self.ratio.clamp(0.0, 1.0);
110        // "100%" is the longest possible output: 4 bytes.
111        let mut pct = bar::ReadoutBuf::<4>::new();
112        // `ratio` is clamped to `0.0..=1.0` above, so the rounded percentage always lands in
113        // `0..=100`, well within `i32`'s range.
114        #[allow(clippy::cast_possible_truncation)]
115        let pct_value = retroglyph_core::math::round(ratio * 100.0) as i32;
116        let _ = write!(pct, "{pct_value:>3}%");
117        bar::render(
118            surface,
119            self.label,
120            self.label_style,
121            ratio,
122            pct.as_str(),
123            self.fill_color,
124        );
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use retroglyph_core::grid::{Grid, Pos, Rect};
131
132    use super::*;
133
134    #[test]
135    fn label_bar_and_percentage_readout() {
136        let area = Rect::new(0, 0, 20, 1);
137        let mut grid = Grid::new(20, 1);
138        Gauge::new("H", 0.5).render(&mut Surface::new(&mut grid, area, 0));
139
140        assert_eq!(grid[Pos::new(2, 0)].glyph(), '█'); // bar starts filled
141        assert_eq!(grid[Pos::new(19, 0)].glyph(), '%'); // "XX%"-style readout
142    }
143
144    #[test]
145    fn label_style_is_configurable() {
146        use retroglyph_core::color::Color;
147
148        let area = Rect::new(0, 0, 20, 1);
149        let mut grid = Grid::new(20, 1);
150        Gauge::new("H", 0.5)
151            .label_style(Style::new().fg(Color::WHITE))
152            .render(&mut Surface::new(&mut grid, area, 0));
153
154        assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Color::WHITE);
155    }
156
157    #[test]
158    fn fill_color_overrides_the_default_meter_ramp() {
159        fn white_to_red(ratio: f32) -> Color {
160            Color::lerp(Color::WHITE, Color::RED, ratio)
161        }
162
163        let area = Rect::new(0, 0, 20, 1);
164        let mut grid = Grid::new(20, 1);
165        Gauge::new("H", 1.0)
166            .fill_color(white_to_red)
167            .render(&mut Surface::new(&mut grid, area, 0));
168
169        // Full ratio: the default ramp would also be red-ish here, so assert against the
170        // custom ramp's own output rather than a color literal that might coincide.
171        assert_eq!(grid[Pos::new(2, 0)].style().foreground(), white_to_red(1.0));
172    }
173
174    #[test]
175    fn theme_maps_dim_role_onto_label_style() {
176        let area = Rect::new(0, 0, 20, 1);
177        let mut grid = Grid::new(20, 1);
178        Gauge::new("H", 0.5)
179            .theme(Theme::DARK)
180            .render(&mut Surface::new(&mut grid, area, 0));
181
182        assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.dim);
183        assert_eq!(
184            grid[Pos::new(0, 0)].style().background(),
185            Theme::DARK.panel_bg
186        );
187    }
188
189    #[test]
190    fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
191        use retroglyph_core::color::Color;
192
193        let area = Rect::new(0, 0, 20, 1);
194        let mut grid = Grid::new(20, 1);
195        Gauge::new("H", 0.5)
196            .theme_on(Theme::DARK, Color::Default)
197            .render(&mut Surface::new(&mut grid, area, 0));
198
199        assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.dim);
200        assert_eq!(grid[Pos::new(0, 0)].style().background(), Color::Default);
201    }
202}