Skip to main content

qframe/widgets/
gauge.rs

1//! Gauges: how full something is, with limits that change its tone.
2
3use super::eighths;
4use crate::color::Rgb;
5use crate::geometry::{Rect, Size};
6use crate::text;
7use crate::widget::{MeasureCx, PaintCx, Widget};
8
9/// Narrowest meter drawn, in cells; below this the gauge shows only its label and value.
10const MIN_METER: u16 = 4;
11
12/// How a value stands against the gauge's thresholds.
13#[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/// A one-row meter for a value in a range: label, meter and value.
42///
43/// The meter fills in eighths of a cell (whole cells in ASCII mode). With thresholds the fill takes
44/// the success, warning or danger tone by value, the parts of the track past each threshold are
45/// tinted faintly so the limits are visible before they are reached, and the value carries a
46/// marker so the state reads without colour. The value shows a percentage of the range unless a
47/// text is given. When the area is too narrow for a meter, only the label and value remain.
48///
49/// A gauge is a readout, not a control: its one value is always written beside the meter, so there
50/// is nothing a pointer or a key could move to. It takes no keyboard focus and answers no input.
51/// To let someone set the value, use a [`Slider`](super::Slider); to explain a label that had to be
52/// cut, wrap the gauge in a [`Tooltip`](super::Tooltip); to read a value out of a history, use a
53/// [`Sparkline`](super::Sparkline).
54///
55/// Style keys: `gauge` and `gauge.<success|warning|danger>` (`track`, `fill`, `zone` for the
56/// tint past a threshold), `gauge-label` (`fg`), `gauge-value` and `gauge-value.<level>` (`fg`,
57/// `bold`).
58#[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    /// A gauge at `value` in the range 0 to 100.
71    #[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    /// The range the value lives in, e.g. `0.0, 64.0` for gibibytes of memory.
77    #[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    /// A name drawn before the meter, e.g. "Memory".
85    #[must_use]
86    pub fn label(mut self, label: impl Into<String>) -> Self {
87        self.label = Some(label.into());
88        self
89    }
90
91    /// Reserves `cells` for the label, so gauges stacked with different labels line up. Longer
92    /// labels are cut with `…`.
93    #[must_use]
94    pub fn label_width(mut self, cells: u16) -> Self {
95        self.label_width = Some(cells);
96        self
97    }
98
99    /// Text drawn after the meter instead of the percentage, e.g. "6.2 of 8 GiB".
100    #[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    /// From `warning` the gauge turns to the warning tone, from `danger` to the danger tone;
107    /// below both it is in the success tone. Values are in the gauge's range.
108    #[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        // A gauge takes the whole width it is given; narrow ones keep only the label and value.
136        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            // Zones past each limit are tinted with the tone the gauge will take there.
190            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    /// A gauge next to a button, to see where keyboard focus goes.
262    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}