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