1use super::eighths;
4use crate::color::Rgb;
5use crate::geometry::{Rect, Size};
6use crate::text;
7use crate::widget::{MeasureCx, PaintCx, Widget};
8
9const MIN_METER: u16 = 4;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14enum Level {
15 Plain,
16 Success,
17 Warning,
18 Danger,
19}
20
21impl Level {
22 fn variant(self) -> Option<&'static str> {
23 match self {
24 Self::Plain => None,
25 Self::Success => Some("success"),
26 Self::Warning => Some("warning"),
27 Self::Danger => Some("danger"),
28 }
29 }
30
31 fn icon(self) -> Option<&'static str> {
32 match self {
33 Self::Plain => None,
34 Self::Success => Some("dot"),
35 Self::Warning => Some("warning"),
36 Self::Danger => Some("error"),
37 }
38 }
39}
40
41#[derive(Debug, Clone, PartialEq)]
59pub struct Gauge {
60 value: f32,
61 min: f32,
62 max: f32,
63 label: Option<String>,
64 label_width: Option<u16>,
65 value_text: Option<String>,
66 thresholds: Option<(f32, f32)>,
67}
68
69impl Gauge {
70 #[must_use]
72 pub fn new(value: f32) -> Self {
73 Self { value, min: 0.0, max: 100.0, label: None, label_width: None, value_text: None, thresholds: None }
74 }
75
76 #[must_use]
78 pub fn range(mut self, min: f32, max: f32) -> Self {
79 self.min = min;
80 self.max = max;
81 self
82 }
83
84 #[must_use]
86 pub fn label(mut self, label: impl Into<String>) -> Self {
87 self.label = Some(label.into());
88 self
89 }
90
91 #[must_use]
94 pub fn label_width(mut self, cells: u16) -> Self {
95 self.label_width = Some(cells);
96 self
97 }
98
99 #[must_use]
101 pub fn value_text(mut self, text: impl Into<String>) -> Self {
102 self.value_text = Some(text.into());
103 self
104 }
105
106 #[must_use]
109 pub fn thresholds(mut self, warning: f32, danger: f32) -> Self {
110 self.thresholds = Some((warning, danger));
111 self
112 }
113
114 fn fraction(&self, value: f32) -> f32 {
115 let span = self.max - self.min;
116 if span > 0.0 { ((value - self.min) / span).clamp(0.0, 1.0) } else { 0.0 }
117 }
118
119 fn level(&self) -> Level {
120 match self.thresholds {
121 None => Level::Plain,
122 Some((_, danger)) if self.value >= danger => Level::Danger,
123 Some((warning, _)) if self.value >= warning => Level::Warning,
124 Some(_) => Level::Success,
125 }
126 }
127
128 fn shown_value(&self) -> String {
129 self.value_text.clone().unwrap_or_else(|| format!("{:.0}%", self.fraction(self.value) * 100.0))
130 }
131}
132
133impl<Msg: 'static> Widget<Msg> for Gauge {
134 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
135 Size::new(available.width, 1).min(available)
137 }
138
139 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
140 if area.is_empty() {
141 return;
142 }
143 let level = self.level();
144 let variant = level.variant();
145 let y = area.y;
146
147 let value = self.shown_value();
148 let marker = level.icon().map(|icon| cx.env().icons().glyph(icon).into_owned());
149 let marker_width = marker.as_deref().map_or(0, |glyph| text::width(glyph).saturating_add(1));
150 let value_width = (marker_width + text::width(&value)).min(area.width);
151 let label = self.label.as_deref().unwrap_or_default();
152 let label_budget = area.width.saturating_sub(value_width + 2).min(self.label_width.unwrap_or(u16::MAX));
153 let label_shown = text::truncate(label, label_budget).into_owned();
154 let label_width = match (label.is_empty(), self.label_width) {
155 (true, _) => 0,
156 (false, Some(_)) => label_budget + 2,
157 (false, None) => text::width(&label_shown).saturating_add(2),
158 };
159
160 let mut label_style = cx.style("gauge-label", None, &[]).text();
161 label_style.bg = None;
162 cx.text(area.x, y, &label_shown, label_style, label_budget);
163
164 let meter_start = area.x + i32::from(label_width);
165 let meter_width = area.width.saturating_sub(label_width + value_width + 2);
166 if meter_width >= MIN_METER {
167 self.paint_meter(cx, Rect::new(meter_start, y, meter_width, 1), variant);
168 }
169
170 let mut value_style = cx.style("gauge-value", variant, &[]).text();
171 value_style.bg = None;
172 let mut x = area.right() - i32::from(value_width);
173 if let Some(glyph) = marker {
174 x += i32::from(cx.text(x, y, &glyph, value_style, value_width)) + 1;
175 }
176 let budget = crate::geometry::clamp_u16(area.right() - x);
177 let value_shown = text::truncate(&value, budget).into_owned();
178 cx.text(x, y, &value_shown, value_style, budget);
179 }
180}
181
182impl Gauge {
183 fn paint_meter(&self, cx: &mut PaintCx<'_>, meter: Rect, variant: Option<&str>) {
184 let style = cx.style("gauge", variant, &[]);
185 let track = style.color("track").unwrap_or_else(|| cx.color("raised"));
186 let fill = style.color("fill").unwrap_or_else(|| cx.color("accent"));
187 cx.clear(meter, track);
188 if let Some((warning, danger)) = self.thresholds {
189 for (limit, zone_variant) in [(warning, "warning"), (danger, "danger")] {
191 let zone: Rgb = cx.style("gauge", Some(zone_variant), &[]).color("zone").unwrap_or(track);
192 let start = eighths::eighths(self.fraction(limit), meter.width) / 8;
193 let start = u16::try_from(start).unwrap_or(meter.width).min(meter.width);
194 cx.clear(Rect::new(meter.x + i32::from(start), meter.y, meter.width - start, 1), zone);
195 }
196 }
197 let filled = eighths::eighths(self.fraction(self.value), meter.width);
198 eighths::horizontal(cx, meter, filled, fill);
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use crate::icons::GlyphMode;
206 use crate::runtime::{App, Command, Harness};
207 use crate::widget::View;
208
209 struct Demo(Gauge);
210
211 impl App for Demo {
212 type Msg = ();
213 fn update(&mut self, _: ()) -> Command<()> {
214 Command::none()
215 }
216 fn view(&self, ui: &mut View<'_, ()>) {
217 ui.add(self.0.clone()).fill_width();
218 }
219 }
220
221 #[test]
222 fn label_meter_and_percentage() {
223 let h = Harness::new(Demo(Gauge::new(53.0).label("CPU")), 20, 1);
224 assert_eq!(h.screen(), "CPU ▎ 53%\n");
225 let theme = h.env().theme();
226 assert_eq!(h.bg(5, 0), theme.color("accent"));
227 assert_eq!(h.bg(10, 0), theme.color("raised"));
228 }
229
230 #[test]
231 fn thresholds_choose_tone_and_marker() {
232 let gauge = Gauge::new(6.9).range(0.0, 8.0).label("Memory").value_text("6.9 GiB").thresholds(6.0, 7.5);
233 let h = Harness::new(Demo(gauge), 30, 1);
234 assert_eq!(h.screen(), "Memory ▌ ▲ 6.9 GiB\n");
235 let theme = h.env().theme();
236 assert_eq!(h.bg(8, 0), theme.color("warning"));
237 assert_eq!(h.fg(21, 0), theme.color("warning"));
238 assert_ne!(h.bg(19, 0), h.bg(17, 0), "the danger zone has a tint of its own");
239 let calm = Harness::new(Demo(Gauge::new(20.0).thresholds(70.0, 90.0)), 12, 1);
240 assert_eq!(calm.screen(), " ● 20%\n");
241 assert_eq!(calm.bg(0, 0), theme.color("success"));
242 assert_eq!(calm.fg(7, 0), theme.color("success"));
243 }
244
245 #[test]
246 fn label_width_lines_meters_up() {
247 let h = Harness::new(Demo(Gauge::new(50.0).label("CPU").label_width(6)), 20, 1);
248 assert_eq!(h.screen(), "CPU ▌ 50%\n");
249 let cut = Harness::new(Demo(Gauge::new(50.0).label("Memory pressure").label_width(6)), 20, 1);
250 assert_eq!(cut.screen(), "Memor… ▌ 50%\n");
251 }
252
253 #[test]
254 fn narrow_gauges_keep_label_and_value() {
255 let mut h = Harness::new(Demo(Gauge::new(97.0).label("Disk").thresholds(80.0, 95.0)), 12, 1);
256 assert_eq!(h.screen(), "Disk ✕ 97%\n");
257 h.set_glyph_mode(GlyphMode::Ascii);
258 assert_eq!(h.screen(), "Disk x 97%\n");
259 }
260
261 struct Row(f32);
263
264 impl App for Row {
265 type Msg = f32;
266 fn update(&mut self, value: f32) -> Command<f32> {
267 self.0 = value;
268 Command::none()
269 }
270 fn view(&self, ui: &mut View<'_, f32>) {
271 ui.add(Gauge::new(self.0).label("CPU")).fill_width();
272 ui.add(super::super::Button::new("Refresh").on_press(50.0)).id("refresh");
273 }
274 }
275
276 #[test]
277 fn a_gauge_is_a_readout_and_answers_no_input() {
278 let mut h = Harness::new(Row(53.0), 20, 2);
279 let meter = h.screen().lines().next().unwrap_or_default().to_owned();
280 h.click(5, 0).drag((3, 0), (15, 0)).hover(7, 0);
281 h.press("right").press("left").press("home").press("enter").press("space");
282 assert_eq!(h.app().0, 53.0, "nothing the gauge saw changed the value");
283 assert_eq!(h.screen().lines().next(), Some(meter.as_str()), "and the gauge is drawn as before");
284 h.press("tab").press("enter");
285 assert_eq!(h.app().0, 50.0, "Tab went past the gauge to the button");
286 }
287
288 #[test]
289 fn a_huge_label_width_is_bounded_by_the_area() {
290 let h = Harness::new(Demo(Gauge::new(50.0).label("CPU").label_width(u16::MAX)), 20, 1);
291 assert_eq!(h.screen(), "CPU 50%\n");
292 }
293}