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)]
53pub struct Gauge {
54 value: f32,
55 min: f32,
56 max: f32,
57 label: Option<String>,
58 label_width: Option<u16>,
59 value_text: Option<String>,
60 thresholds: Option<(f32, f32)>,
61}
62
63impl Gauge {
64 #[must_use]
66 pub fn new(value: f32) -> Self {
67 Self { value, min: 0.0, max: 100.0, label: None, label_width: None, value_text: None, thresholds: None }
68 }
69
70 #[must_use]
72 pub fn range(mut self, min: f32, max: f32) -> Self {
73 self.min = min;
74 self.max = max;
75 self
76 }
77
78 #[must_use]
80 pub fn label(mut self, label: impl Into<String>) -> Self {
81 self.label = Some(label.into());
82 self
83 }
84
85 #[must_use]
88 pub fn label_width(mut self, cells: u16) -> Self {
89 self.label_width = Some(cells);
90 self
91 }
92
93 #[must_use]
95 pub fn value_text(mut self, text: impl Into<String>) -> Self {
96 self.value_text = Some(text.into());
97 self
98 }
99
100 #[must_use]
103 pub fn thresholds(mut self, warning: f32, danger: f32) -> Self {
104 self.thresholds = Some((warning, danger));
105 self
106 }
107
108 fn fraction(&self, value: f32) -> f32 {
109 let span = self.max - self.min;
110 if span > 0.0 { ((value - self.min) / span).clamp(0.0, 1.0) } else { 0.0 }
111 }
112
113 fn level(&self) -> Level {
114 match self.thresholds {
115 None => Level::Plain,
116 Some((_, danger)) if self.value >= danger => Level::Danger,
117 Some((warning, _)) if self.value >= warning => Level::Warning,
118 Some(_) => Level::Success,
119 }
120 }
121
122 fn shown_value(&self) -> String {
123 self.value_text.clone().unwrap_or_else(|| format!("{:.0}%", self.fraction(self.value) * 100.0))
124 }
125}
126
127impl<Msg: 'static> Widget<Msg> for Gauge {
128 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
129 Size::new(available.width, 1).min(available)
131 }
132
133 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
134 if area.is_empty() {
135 return;
136 }
137 let level = self.level();
138 let variant = level.variant();
139 let y = area.y;
140
141 let value = self.shown_value();
142 let marker = level.icon().map(|icon| cx.env().icons().glyph(icon).into_owned());
143 let marker_width = marker.as_deref().map_or(0, |glyph| text::width(glyph).saturating_add(1));
144 let value_width = (marker_width + text::width(&value)).min(area.width);
145 let label = self.label.as_deref().unwrap_or_default();
146 let label_budget = area.width.saturating_sub(value_width + 2).min(self.label_width.unwrap_or(u16::MAX));
147 let label_shown = text::truncate(label, label_budget).into_owned();
148 let label_width = match (label.is_empty(), self.label_width) {
149 (true, _) => 0,
150 (false, Some(_)) => label_budget + 2,
151 (false, None) => text::width(&label_shown).saturating_add(2),
152 };
153
154 let mut label_style = cx.style("gauge-label", None, &[]).text();
155 label_style.bg = None;
156 cx.text(area.x, y, &label_shown, label_style, label_budget);
157
158 let meter_start = area.x + i32::from(label_width);
159 let meter_width = area.width.saturating_sub(label_width + value_width + 2);
160 if meter_width >= MIN_METER {
161 self.paint_meter(cx, Rect::new(meter_start, y, meter_width, 1), variant);
162 }
163
164 let mut value_style = cx.style("gauge-value", variant, &[]).text();
165 value_style.bg = None;
166 let mut x = area.right() - i32::from(value_width);
167 if let Some(glyph) = marker {
168 x += i32::from(cx.text(x, y, &glyph, value_style, value_width)) + 1;
169 }
170 let budget = crate::geometry::clamp_u16(area.right() - x);
171 let value_shown = text::truncate(&value, budget).into_owned();
172 cx.text(x, y, &value_shown, value_style, budget);
173 }
174}
175
176impl Gauge {
177 fn paint_meter(&self, cx: &mut PaintCx<'_>, meter: Rect, variant: Option<&str>) {
178 let style = cx.style("gauge", variant, &[]);
179 let track = style.color("track").unwrap_or_else(|| cx.color("raised"));
180 let fill = style.color("fill").unwrap_or_else(|| cx.color("accent"));
181 cx.clear(meter, track);
182 if let Some((warning, danger)) = self.thresholds {
183 for (limit, zone_variant) in [(warning, "warning"), (danger, "danger")] {
185 let zone: Rgb = cx.style("gauge", Some(zone_variant), &[]).color("zone").unwrap_or(track);
186 let start = eighths::eighths(self.fraction(limit), meter.width) / 8;
187 let start = u16::try_from(start).unwrap_or(meter.width).min(meter.width);
188 cx.clear(Rect::new(meter.x + i32::from(start), meter.y, meter.width - start, 1), zone);
189 }
190 }
191 let filled = eighths::eighths(self.fraction(self.value), meter.width);
192 eighths::horizontal(cx, meter, filled, fill);
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use crate::icons::GlyphMode;
200 use crate::runtime::{App, Command, Harness};
201 use crate::widget::View;
202
203 struct Demo(Gauge);
204
205 impl App for Demo {
206 type Msg = ();
207 fn update(&mut self, _: ()) -> Command<()> {
208 Command::none()
209 }
210 fn view(&self, ui: &mut View<'_, ()>) {
211 ui.add(self.0.clone()).fill_width();
212 }
213 }
214
215 #[test]
216 fn label_meter_and_percentage() {
217 let h = Harness::new(Demo(Gauge::new(53.0).label("CPU")), 20, 1);
218 assert_eq!(h.screen(), "CPU ▎ 53%\n");
219 let theme = h.env().theme();
220 assert_eq!(h.bg(5, 0), theme.color("accent"));
221 assert_eq!(h.bg(10, 0), theme.color("raised"));
222 }
223
224 #[test]
225 fn thresholds_choose_tone_and_marker() {
226 let gauge = Gauge::new(6.9).range(0.0, 8.0).label("Memory").value_text("6.9 GiB").thresholds(6.0, 7.5);
227 let h = Harness::new(Demo(gauge), 30, 1);
228 assert_eq!(h.screen(), "Memory ▌ ▲ 6.9 GiB\n");
229 let theme = h.env().theme();
230 assert_eq!(h.bg(8, 0), theme.color("warning"));
231 assert_eq!(h.fg(21, 0), theme.color("warning"));
232 assert_ne!(h.bg(19, 0), h.bg(17, 0), "the danger zone has a tint of its own");
233 let calm = Harness::new(Demo(Gauge::new(20.0).thresholds(70.0, 90.0)), 12, 1);
234 assert_eq!(calm.screen(), " ● 20%\n");
235 assert_eq!(calm.bg(0, 0), theme.color("success"));
236 assert_eq!(calm.fg(7, 0), theme.color("success"));
237 }
238
239 #[test]
240 fn label_width_lines_meters_up() {
241 let h = Harness::new(Demo(Gauge::new(50.0).label("CPU").label_width(6)), 20, 1);
242 assert_eq!(h.screen(), "CPU ▌ 50%\n");
243 let cut = Harness::new(Demo(Gauge::new(50.0).label("Memory pressure").label_width(6)), 20, 1);
244 assert_eq!(cut.screen(), "Memor… ▌ 50%\n");
245 }
246
247 #[test]
248 fn narrow_gauges_keep_label_and_value() {
249 let mut h = Harness::new(Demo(Gauge::new(97.0).label("Disk").thresholds(80.0, 95.0)), 12, 1);
250 assert_eq!(h.screen(), "Disk ✕ 97%\n");
251 h.set_glyph_mode(GlyphMode::Ascii);
252 assert_eq!(h.screen(), "Disk x 97%\n");
253 }
254
255 #[test]
256 fn a_huge_label_width_is_bounded_by_the_area() {
257 let h = Harness::new(Demo(Gauge::new(50.0).label("CPU").label_width(u16::MAX)), 20, 1);
258 assert_eq!(h.screen(), "CPU 50%\n");
259 }
260}