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}