use super::eighths;
use crate::color::Rgb;
use crate::geometry::{Rect, Size};
use crate::text;
use crate::widget::{MeasureCx, PaintCx, Widget};
const MIN_METER: u16 = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Level {
Plain,
Success,
Warning,
Danger,
}
impl Level {
fn variant(self) -> Option<&'static str> {
match self {
Self::Plain => None,
Self::Success => Some("success"),
Self::Warning => Some("warning"),
Self::Danger => Some("danger"),
}
}
fn icon(self) -> Option<&'static str> {
match self {
Self::Plain => None,
Self::Success => Some("dot"),
Self::Warning => Some("warning"),
Self::Danger => Some("error"),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Gauge {
value: f32,
min: f32,
max: f32,
label: Option<String>,
label_width: Option<u16>,
value_text: Option<String>,
thresholds: Option<(f32, f32)>,
}
impl Gauge {
#[must_use]
pub fn new(value: f32) -> Self {
Self { value, min: 0.0, max: 100.0, label: None, label_width: None, value_text: None, thresholds: None }
}
#[must_use]
pub fn range(mut self, min: f32, max: f32) -> Self {
self.min = min;
self.max = max;
self
}
#[must_use]
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
#[must_use]
pub fn label_width(mut self, cells: u16) -> Self {
self.label_width = Some(cells);
self
}
#[must_use]
pub fn value_text(mut self, text: impl Into<String>) -> Self {
self.value_text = Some(text.into());
self
}
#[must_use]
pub fn thresholds(mut self, warning: f32, danger: f32) -> Self {
self.thresholds = Some((warning, danger));
self
}
fn fraction(&self, value: f32) -> f32 {
let span = self.max - self.min;
if span > 0.0 { ((value - self.min) / span).clamp(0.0, 1.0) } else { 0.0 }
}
fn level(&self) -> Level {
match self.thresholds {
None => Level::Plain,
Some((_, danger)) if self.value >= danger => Level::Danger,
Some((warning, _)) if self.value >= warning => Level::Warning,
Some(_) => Level::Success,
}
}
fn shown_value(&self) -> String {
self.value_text.clone().unwrap_or_else(|| format!("{:.0}%", self.fraction(self.value) * 100.0))
}
}
impl<Msg: 'static> Widget<Msg> for Gauge {
fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
Size::new(available.width, 1).min(available)
}
fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
if area.is_empty() {
return;
}
let level = self.level();
let variant = level.variant();
let y = area.y;
let value = self.shown_value();
let marker = level.icon().map(|icon| cx.env().icons().glyph(icon).into_owned());
let marker_width = marker.as_deref().map_or(0, |glyph| text::width(glyph).saturating_add(1));
let value_width = (marker_width + text::width(&value)).min(area.width);
let label = self.label.as_deref().unwrap_or_default();
let label_budget = area.width.saturating_sub(value_width + 2).min(self.label_width.unwrap_or(u16::MAX));
let label_shown = text::truncate(label, label_budget).into_owned();
let label_width = match (label.is_empty(), self.label_width) {
(true, _) => 0,
(false, Some(_)) => label_budget + 2,
(false, None) => text::width(&label_shown).saturating_add(2),
};
let mut label_style = cx.style("gauge-label", None, &[]).text();
label_style.bg = None;
cx.text(area.x, y, &label_shown, label_style, label_budget);
let meter_start = area.x + i32::from(label_width);
let meter_width = area.width.saturating_sub(label_width + value_width + 2);
if meter_width >= MIN_METER {
self.paint_meter(cx, Rect::new(meter_start, y, meter_width, 1), variant);
}
let mut value_style = cx.style("gauge-value", variant, &[]).text();
value_style.bg = None;
let mut x = area.right() - i32::from(value_width);
if let Some(glyph) = marker {
x += i32::from(cx.text(x, y, &glyph, value_style, value_width)) + 1;
}
let budget = crate::geometry::clamp_u16(area.right() - x);
let value_shown = text::truncate(&value, budget).into_owned();
cx.text(x, y, &value_shown, value_style, budget);
}
}
impl Gauge {
fn paint_meter(&self, cx: &mut PaintCx<'_>, meter: Rect, variant: Option<&str>) {
let style = cx.style("gauge", variant, &[]);
let track = style.color("track").unwrap_or_else(|| cx.color("raised"));
let fill = style.color("fill").unwrap_or_else(|| cx.color("accent"));
cx.clear(meter, track);
if let Some((warning, danger)) = self.thresholds {
for (limit, zone_variant) in [(warning, "warning"), (danger, "danger")] {
let zone: Rgb = cx.style("gauge", Some(zone_variant), &[]).color("zone").unwrap_or(track);
let start = eighths::eighths(self.fraction(limit), meter.width) / 8;
let start = u16::try_from(start).unwrap_or(meter.width).min(meter.width);
cx.clear(Rect::new(meter.x + i32::from(start), meter.y, meter.width - start, 1), zone);
}
}
let filled = eighths::eighths(self.fraction(self.value), meter.width);
eighths::horizontal(cx, meter, filled, fill);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::icons::GlyphMode;
use crate::runtime::{App, Command, Harness};
use crate::widget::View;
struct Demo(Gauge);
impl App for Demo {
type Msg = ();
fn update(&mut self, _: ()) -> Command<()> {
Command::none()
}
fn view(&self, ui: &mut View<'_, ()>) {
ui.add(self.0.clone()).fill_width();
}
}
#[test]
fn label_meter_and_percentage() {
let h = Harness::new(Demo(Gauge::new(53.0).label("CPU")), 20, 1);
assert_eq!(h.screen(), "CPU ▎ 53%\n");
let theme = h.env().theme();
assert_eq!(h.bg(5, 0), theme.color("accent"));
assert_eq!(h.bg(10, 0), theme.color("raised"));
}
#[test]
fn thresholds_choose_tone_and_marker() {
let gauge = Gauge::new(6.9).range(0.0, 8.0).label("Memory").value_text("6.9 GiB").thresholds(6.0, 7.5);
let h = Harness::new(Demo(gauge), 30, 1);
assert_eq!(h.screen(), "Memory ▌ ▲ 6.9 GiB\n");
let theme = h.env().theme();
assert_eq!(h.bg(8, 0), theme.color("warning"));
assert_eq!(h.fg(21, 0), theme.color("warning"));
assert_ne!(h.bg(19, 0), h.bg(17, 0), "the danger zone has a tint of its own");
let calm = Harness::new(Demo(Gauge::new(20.0).thresholds(70.0, 90.0)), 12, 1);
assert_eq!(calm.screen(), " ● 20%\n");
assert_eq!(calm.bg(0, 0), theme.color("success"));
assert_eq!(calm.fg(7, 0), theme.color("success"));
}
#[test]
fn label_width_lines_meters_up() {
let h = Harness::new(Demo(Gauge::new(50.0).label("CPU").label_width(6)), 20, 1);
assert_eq!(h.screen(), "CPU ▌ 50%\n");
let cut = Harness::new(Demo(Gauge::new(50.0).label("Memory pressure").label_width(6)), 20, 1);
assert_eq!(cut.screen(), "Memor… ▌ 50%\n");
}
#[test]
fn narrow_gauges_keep_label_and_value() {
let mut h = Harness::new(Demo(Gauge::new(97.0).label("Disk").thresholds(80.0, 95.0)), 12, 1);
assert_eq!(h.screen(), "Disk ✕ 97%\n");
h.set_glyph_mode(GlyphMode::Ascii);
assert_eq!(h.screen(), "Disk x 97%\n");
}
struct Row(f32);
impl App for Row {
type Msg = f32;
fn update(&mut self, value: f32) -> Command<f32> {
self.0 = value;
Command::none()
}
fn view(&self, ui: &mut View<'_, f32>) {
ui.add(Gauge::new(self.0).label("CPU")).fill_width();
ui.add(super::super::Button::new("Refresh").on_press(50.0)).id("refresh");
}
}
#[test]
fn a_gauge_is_a_readout_and_answers_no_input() {
let mut h = Harness::new(Row(53.0), 20, 2);
let meter = h.screen().lines().next().unwrap_or_default().to_owned();
h.click(5, 0).drag((3, 0), (15, 0)).hover(7, 0);
h.press("right").press("left").press("home").press("enter").press("space");
assert_eq!(h.app().0, 53.0, "nothing the gauge saw changed the value");
assert_eq!(h.screen().lines().next(), Some(meter.as_str()), "and the gauge is drawn as before");
h.press("tab").press("enter");
assert_eq!(h.app().0, 50.0, "Tab went past the gauge to the button");
}
#[test]
fn a_huge_label_width_is_bounded_by_the_area() {
let h = Harness::new(Demo(Gauge::new(50.0).label("CPU").label_width(u16::MAX)), 20, 1);
assert_eq!(h.screen(), "CPU 50%\n");
}
}