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/// Style keys: `gauge` and `gauge.<success|warning|danger>` (`track`, `fill`, `zone` for the
50/// tint past a threshold), `gauge-label` (`fg`), `gauge-value` and `gauge-value.<level>` (`fg`,
51/// `bold`).
52#[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    /// A gauge at `value` in the range 0 to 100.
65    #[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    /// The range the value lives in, e.g. `0.0, 64.0` for gibibytes of memory.
71    #[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    /// A name drawn before the meter, e.g. "Memory".
79    #[must_use]
80    pub fn label(mut self, label: impl Into<String>) -> Self {
81        self.label = Some(label.into());
82        self
83    }
84
85    /// Reserves `cells` for the label, so gauges stacked with different labels line up. Longer
86    /// labels are cut with `…`.
87    #[must_use]
88    pub fn label_width(mut self, cells: u16) -> Self {
89        self.label_width = Some(cells);
90        self
91    }
92
93    /// Text drawn after the meter instead of the percentage, e.g. "6.2 of 8 GiB".
94    #[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    /// From `warning` the gauge turns to the warning tone, from `danger` to the danger tone;
101    /// below both it is in the success tone. Values are in the gauge's range.
102    #[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        // A gauge takes the whole width it is given; narrow ones keep only the label and value.
130        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            // Zones past each limit are tinted with the tone the gauge will take there.
184            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}