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}