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