Skip to main content

retroglyph_ui/widget/
stat_bar.rs

1//! [`StatBar`]: a labeled `current`/`max` stat 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 stat bar: `label`, a bar filling `current / max` of the
11/// remaining width colored by [`super::Meter`], and a trailing
12/// `"current/max"` readout.
13///
14/// Only the first row of `area` is used. Same layout and coloring as
15/// [`super::Gauge`], but for integer `current`/`max` pairs (health, mana,
16/// stamina) with a literal readout instead of a percentage: `"45/100"`
17/// reads as a stat, not a load. `max == 0` renders as an empty, unfilled bar
18/// with a `"0/0"` readout rather than a special-cased blank output. If
19/// `current` exceeds `max` (e.g. a temporarily buffed stat), the bar fill
20/// still caps at 100%, but the readout shows the true, uncapped numbers
21/// (`"120/100"`) so the overflow stays visible in text. `label_style` defaults to
22/// [`Theme::DARK`]'s `dim` role (as if [`StatBar::theme`] had been called); set it with
23/// [`StatBar::label_style`]. The fill (and readout) color defaults to [`super::Meter`]'s
24/// green→yellow→red load ramp, which reads backwards for a health/mana/stamina stat (full
25/// renders as danger, not safe); override it with [`StatBar::fill_color`].
26///
27/// # Examples
28///
29/// ```
30/// use retroglyph_core::grid::{Grid, Rect};
31/// use retroglyph_ui::widget::{StatBar, Widget};
32/// use retroglyph_ui::Surface;
33///
34/// let area = Rect::new(0, 0, 20, 1);
35/// let mut grid = Grid::new(20, 1);
36/// StatBar::new("HP", 45, 100).render(&mut Surface::new(&mut grid, area, 0));
37/// ```
38#[derive(Clone, Copy, Debug)]
39pub struct StatBar<'a> {
40    label: &'a str,
41    current: u32,
42    max: u32,
43    label_style: Style,
44    fill_color: fn(f32) -> Color,
45}
46
47impl<'a> StatBar<'a> {
48    /// A stat bar for `label`, reading `current` out of `max`, with `label_style` styled from
49    /// [`Theme::DARK`] (as if [`StatBar::theme`] had been called).
50    #[must_use]
51    pub fn new(label: &'a str, current: u32, max: u32) -> Self {
52        Self {
53            label,
54            current,
55            max,
56            label_style: Style::new(),
57            fill_color: bar::meter_fill_color,
58        }
59        .theme(Theme::DARK)
60    }
61
62    /// Set the label's style.
63    #[must_use]
64    pub const fn label_style(mut self, style: Style) -> Self {
65        self.label_style = style;
66        self
67    }
68
69    /// Overrides the bar's fill (and readout text) color from [`super::Meter`]'s default
70    /// green→yellow→red load ramp to `fill_color`, called with `current / max` clamped to
71    /// `0.0..=1.0`.
72    ///
73    /// The default ramp reads backwards for a descending stat like health: full renders as
74    /// danger (red), not safe. Supply a ramp that fits instead, e.g.
75    /// `|r| Color::lerp(Color::RED, Color::GREEN, r)`. A plain `fn` pointer rather than a
76    /// boxed closure, so `StatBar` stays `Copy`; a non-capturing closure like the one above
77    /// coerces to it automatically.
78    #[must_use]
79    pub const fn fill_color(mut self, fill_color: fn(f32) -> Color) -> Self {
80        self.fill_color = fill_color;
81        self
82    }
83
84    /// Sets `label_style` to `theme.dim` on `theme.panel_bg`, the same mapping (and the same
85    /// "assumes it's drawn on `theme.panel_bg`" caveat) as [`super::Gauge::theme`]; see its
86    /// doc comment for the full explanation, including why the bar's own load-colored fill stays
87    /// outside `theme`'s role palette, unless overridden separately with [`StatBar::fill_color`].
88    ///
89    /// Call before any manual [`StatBar::label_style`] override you want to keep.
90    #[must_use]
91    pub fn theme(self, theme: Theme) -> Self {
92        self.theme_on(theme, theme.panel_bg)
93    }
94
95    /// Same as [`StatBar::theme`], but `label_style` is drawn on `bg` instead of
96    /// `theme.panel_bg`: for a stat bar drawn directly on a backdrop other than a themed
97    /// [`super::Panel`]/[`super::Modal`]'s fill. [`StatBar::theme`] is exactly
98    /// `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 StatBar<'_> {
107    fn render(&self, surface: &mut Surface<'_>) {
108        let ratio = if self.max == 0 {
109            0.0
110        } else {
111            // `current`/`max` are gauge readouts (health, ammo, ...) for on-screen display; values
112            // near `u32::MAX` losing mantissa bits below f32's 2^23 threshold has no visible
113            // effect on the rendered ratio.
114            #[allow(clippy::cast_precision_loss)]
115            {
116                self.current as f32 / self.max as f32
117            }
118        };
119        // `"4294967295/4294967295"` (two `u32::MAX`s) is the longest possible output: 21 bytes.
120        let mut readout = bar::ReadoutBuf::<24>::new();
121        let _ = write!(readout, "{}/{}", self.current, self.max);
122        bar::render(
123            surface,
124            self.label,
125            self.label_style,
126            ratio,
127            readout.as_str(),
128            self.fill_color,
129        );
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use retroglyph_core::grid::{Grid, Pos, Rect};
136
137    use super::*;
138
139    #[test]
140    fn zero_max_renders_an_empty_bar_and_zero_zero_readout() {
141        // 1-char label "H" makes the bar's starting column predictable: it
142        // begins right after "H" plus a one-column gap, i.e. at column 2.
143        let area = Rect::new(0, 0, 20, 1);
144        let mut grid = Grid::new(20, 1);
145        StatBar::new("H", 0, 0).render(&mut Surface::new(&mut grid, area, 0));
146
147        assert_eq!(grid[Pos::new(2, 0)].glyph(), '░'); // empty bar cell
148        assert_eq!(grid[Pos::new(19, 0)].glyph(), '0'); // last char of "0/0"
149    }
150
151    #[test]
152    fn normal_case_fills_proportionally_and_shows_current_over_max() {
153        let area = Rect::new(0, 0, 20, 1);
154        let mut grid = Grid::new(20, 1);
155        StatBar::new("H", 45, 100).render(&mut Surface::new(&mut grid, area, 0));
156
157        assert_eq!(grid[Pos::new(2, 0)].glyph(), '█'); // bar starts filled
158        assert_eq!(grid[Pos::new(19, 0)].glyph(), '0'); // last char of "45/100"
159    }
160
161    #[test]
162    fn over_max_caps_the_bar_but_shows_true_numbers_in_the_readout() {
163        let area = Rect::new(0, 0, 20, 1);
164        let mut grid = Grid::new(20, 1);
165        StatBar::new("H", 150, 100).render(&mut Surface::new(&mut grid, area, 0));
166
167        // Bar's last cell before the gap+readout is fully filled (clamped
168        // to 100%), but the readout still reads the true "150/100".
169        assert_eq!(grid[Pos::new(11, 0)].glyph(), '█');
170        assert_eq!(grid[Pos::new(19, 0)].glyph(), '0'); // last char of "150/100"
171    }
172
173    #[test]
174    fn label_style_is_configurable() {
175        use retroglyph_core::color::Color;
176
177        let area = Rect::new(0, 0, 20, 1);
178        let mut grid = Grid::new(20, 1);
179        StatBar::new("H", 45, 100)
180            .label_style(Style::new().fg(Color::WHITE))
181            .render(&mut Surface::new(&mut grid, area, 0));
182
183        assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Color::WHITE);
184    }
185
186    #[test]
187    fn fill_color_overrides_the_default_meter_ramp() {
188        fn white_to_red(ratio: f32) -> Color {
189            Color::lerp(Color::WHITE, Color::RED, ratio)
190        }
191
192        let area = Rect::new(0, 0, 20, 1);
193        let mut grid = Grid::new(20, 1);
194        StatBar::new("H", 100, 100)
195            .fill_color(white_to_red)
196            .render(&mut Surface::new(&mut grid, area, 0));
197
198        // Full ratio: the default ramp would also be red-ish here, so assert against the
199        // custom ramp's own output rather than a color literal that might coincide.
200        assert_eq!(grid[Pos::new(2, 0)].style().foreground(), white_to_red(1.0));
201    }
202
203    #[test]
204    fn theme_maps_dim_role_onto_label_style() {
205        let area = Rect::new(0, 0, 20, 1);
206        let mut grid = Grid::new(20, 1);
207        StatBar::new("H", 45, 100)
208            .theme(Theme::DARK)
209            .render(&mut Surface::new(&mut grid, area, 0));
210
211        assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.dim);
212        assert_eq!(
213            grid[Pos::new(0, 0)].style().background(),
214            Theme::DARK.panel_bg
215        );
216    }
217
218    #[test]
219    fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
220        let area = Rect::new(0, 0, 20, 1);
221        let mut grid = Grid::new(20, 1);
222        StatBar::new("H", 45, 100)
223            .theme_on(Theme::DARK, Color::Default)
224            .render(&mut Surface::new(&mut grid, area, 0));
225
226        assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.dim);
227        assert_eq!(grid[Pos::new(0, 0)].style().background(), Color::Default);
228    }
229}