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}