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}