454 lines
14 KiB
Rust
454 lines
14 KiB
Rust
use std::{
|
|
f32::consts::{FRAC_PI_4, TAU},
|
|
rc::Rc,
|
|
};
|
|
|
|
use gpui::{
|
|
Animation, AnimationExt, App, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement,
|
|
ParentElement, RenderOnce, SharedString, Styled, Window, canvas, div, prelude::*, relative,
|
|
};
|
|
|
|
use crate::{
|
|
motion,
|
|
theme::{ActiveTheme, Palette, TextSize},
|
|
typography::{AnimatedNumber, Ellipsis, format, tabular},
|
|
};
|
|
|
|
/// Where a filled share stands against a meter's thresholds; it indexes `tones`.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum Level {
|
|
Calm = 0,
|
|
Warn = 1,
|
|
Danger = 2,
|
|
}
|
|
|
|
impl Level {
|
|
fn of(share: f32, (warn, danger): (f32, f32)) -> Self {
|
|
if share >= danger {
|
|
Self::Danger
|
|
} else if share >= warn {
|
|
Self::Warn
|
|
} else {
|
|
Self::Calm
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A fill's color for each level: plain, then amber, then red.
|
|
fn tones(colors: &Palette) -> [Hsla; 3] {
|
|
[colors.fg, colors.warning, colors.danger]
|
|
}
|
|
|
|
/// A share's color against its limits: plain, amber past the first, red past the second.
|
|
pub(crate) fn tone(share: f32, limits: (f32, f32), colors: &Palette) -> Hsla {
|
|
tones(colors)[Level::of(share, thresholds(limits.0, limits.1)) as usize]
|
|
}
|
|
|
|
fn thresholds(warn: f32, danger: f32) -> (f32, f32) {
|
|
assert!(
|
|
0.0 < warn && warn <= danger && danger <= 1.0,
|
|
"thresholds {warn} and {danger} must rise within 0..=1"
|
|
);
|
|
(warn, danger)
|
|
}
|
|
|
|
fn checked(share: f32) -> f32 {
|
|
assert!(
|
|
(0.0..=1.0).contains(&share),
|
|
"a share of {share} is not 0..=1"
|
|
);
|
|
share
|
|
}
|
|
|
|
/// Glides from the last share to `share` on a change, and stays still on first paint.
|
|
fn gliding<E, D>(
|
|
id: ElementId,
|
|
share: f32,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
draw: D,
|
|
) -> impl IntoElement + use<E, D>
|
|
where
|
|
E: IntoElement + 'static,
|
|
D: Fn(f32) -> E + 'static,
|
|
{
|
|
let (from, turn) = motion::follow(&id, share, window, cx);
|
|
div().size_full().with_animation(
|
|
(id, format!("glide-{turn}")),
|
|
Animation::new(motion::duration(motion::BASE, cx)).with_easing(motion::ease_out_cubic),
|
|
move |frame, t| {
|
|
let at = if turn == 0 {
|
|
share
|
|
} else {
|
|
from + (share - from) * t
|
|
};
|
|
frame.child(draw(at))
|
|
},
|
|
)
|
|
}
|
|
|
|
/// How full something is, such as a disk or a quota: a label, how much, and a bar that turns amber, then red, as it fills.
|
|
#[derive(IntoElement)]
|
|
pub struct Meter {
|
|
id: ElementId,
|
|
label: SharedString,
|
|
share: f32,
|
|
detail: Option<SharedString>,
|
|
thresholds: (f32, f32),
|
|
}
|
|
|
|
impl Meter {
|
|
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>, share: f32) -> Self {
|
|
Self {
|
|
id: id.into(),
|
|
label: label.into(),
|
|
share: checked(share),
|
|
detail: None,
|
|
thresholds: (0.8, 0.95),
|
|
}
|
|
}
|
|
|
|
/// The share in units, such as "38 GB of 256 GB"; by default, the percent.
|
|
pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
|
|
self.detail = Some(detail.into());
|
|
self
|
|
}
|
|
|
|
/// Where the bar turns amber, then red.
|
|
pub fn thresholds(mut self, warn: f32, danger: f32) -> Self {
|
|
self.thresholds = thresholds(warn, danger);
|
|
self
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for Meter {
|
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let theme = cx.theme();
|
|
let colors = &theme.colors;
|
|
let (track, lane, tones) = (theme.meter_track(), colors.border, tones(colors));
|
|
let detail = self
|
|
.detail
|
|
.unwrap_or_else(|| format::percent(self.share as f64, 0, false).into());
|
|
let head = div()
|
|
.flex()
|
|
.items_baseline()
|
|
.justify_between()
|
|
.gap_3()
|
|
.text_size(theme.text_size(TextSize::Sm))
|
|
.child(
|
|
div()
|
|
.flex_1()
|
|
.min_w_0()
|
|
.text_color(colors.fg)
|
|
.child(Ellipsis::new(self.label)),
|
|
)
|
|
.child(
|
|
tabular(div())
|
|
.debug_selector(|| "meter-detail".into())
|
|
.flex_none()
|
|
.text_color(colors.fg_muted)
|
|
.child(detail),
|
|
);
|
|
let limits = self.thresholds;
|
|
let bar = gliding(self.id, self.share, window, cx, move |at| {
|
|
div().relative().h(track).rounded_full().bg(lane).child(
|
|
div()
|
|
.absolute()
|
|
.top_0()
|
|
.left_0()
|
|
.h_full()
|
|
.w(relative(at))
|
|
.rounded_full()
|
|
.bg(tones[Level::of(at, limits) as usize]),
|
|
)
|
|
});
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_1p5()
|
|
.child(head)
|
|
.child(div().h(track).child(bar))
|
|
}
|
|
}
|
|
|
|
/// Where a gauge's arc starts, at the lower left, and how far it sweeps, clockwise to the lower right.
|
|
const START: f32 = 3.0 * FRAC_PI_4;
|
|
const SWEEP: f32 = 0.75 * TAU;
|
|
|
|
/// A value on a dial: an arc of three quarters that fills clockwise from the lower left, the number rolling at the center, the label below.
|
|
#[derive(IntoElement)]
|
|
pub struct Gauge {
|
|
id: ElementId,
|
|
label: SharedString,
|
|
value: f64,
|
|
range: (f64, f64),
|
|
decimals: usize,
|
|
suffix: Option<SharedString>,
|
|
thresholds: (f32, f32),
|
|
}
|
|
|
|
impl Gauge {
|
|
/// A `value` past `min..=max` pegs the dial and shows as it is.
|
|
pub fn new(
|
|
id: impl Into<ElementId>,
|
|
label: impl Into<SharedString>,
|
|
value: f64,
|
|
min: f64,
|
|
max: f64,
|
|
) -> Self {
|
|
assert!(
|
|
min < max,
|
|
"a gauge needs min below max, got {min} and {max}"
|
|
);
|
|
Self {
|
|
id: id.into(),
|
|
label: label.into(),
|
|
value,
|
|
range: (min, max),
|
|
decimals: 0,
|
|
suffix: None,
|
|
thresholds: (0.8, 0.95),
|
|
}
|
|
}
|
|
|
|
pub fn decimals(mut self, decimals: usize) -> Self {
|
|
self.decimals = decimals;
|
|
self
|
|
}
|
|
|
|
/// A unit after the number, such as "°C".
|
|
pub fn suffix(mut self, suffix: impl Into<SharedString>) -> Self {
|
|
self.suffix = Some(suffix.into());
|
|
self
|
|
}
|
|
|
|
/// Where the arc turns amber, then red, as shares of the range.
|
|
pub fn thresholds(mut self, warn: f32, danger: f32) -> Self {
|
|
self.thresholds = thresholds(warn, danger);
|
|
self
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for Gauge {
|
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let (min, max) = self.range;
|
|
let share = ((self.value - min) / (max - min)).clamp(0.0, 1.0) as f32;
|
|
let theme = cx.theme();
|
|
let (limits, track, tones) = (self.thresholds, theme.colors.border, tones(&theme.colors));
|
|
let dial = gliding(self.id.clone(), share, window, cx, move |at| {
|
|
let color = tones[Level::of(at, limits) as usize];
|
|
canvas(
|
|
|_, _, _| {},
|
|
move |bounds, _, window, _| {
|
|
let side = bounds.size.width.min(bounds.size.height);
|
|
let width = side / 16.0;
|
|
let (center, radius) = (bounds.center(), side / 2.0 - width);
|
|
motion::arc(center, radius, START, SWEEP, width, track, window);
|
|
if at > 0.0 {
|
|
motion::arc(center, radius, START, SWEEP * at, width, color, window);
|
|
}
|
|
},
|
|
)
|
|
.size_full()
|
|
});
|
|
let theme = cx.theme();
|
|
let unit = |text: SharedString| {
|
|
div()
|
|
.text_size(theme.text_size(TextSize::Sm))
|
|
.text_color(theme.colors.fg_muted)
|
|
.child(text)
|
|
};
|
|
div()
|
|
.relative()
|
|
.flex_none()
|
|
.size(theme.gauge())
|
|
.child(div().absolute().inset_0().child(dial))
|
|
.child(
|
|
div()
|
|
.absolute()
|
|
.inset_0()
|
|
.flex()
|
|
.items_center()
|
|
.justify_center()
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.items_baseline()
|
|
.gap_0p5()
|
|
.child(
|
|
AnimatedNumber::new((self.id, "number"), self.value)
|
|
.decimals(self.decimals)
|
|
.size(TextSize::Xl),
|
|
)
|
|
.children(self.suffix.map(unit)),
|
|
),
|
|
)
|
|
.child(
|
|
div()
|
|
.absolute()
|
|
.bottom_3()
|
|
.left_0()
|
|
.right_0()
|
|
.flex()
|
|
.justify_center()
|
|
.text_size(theme.text_size(TextSize::Xs))
|
|
.text_color(theme.colors.fg_muted)
|
|
.child(self.label),
|
|
)
|
|
}
|
|
}
|
|
|
|
type Amounts = Rc<dyn Fn(f64) -> String>;
|
|
|
|
/// How a whole divides: one bar of colored parts with the free rest as track, and a legend of each part's share or amount.
|
|
#[derive(IntoElement)]
|
|
pub struct UsageBar {
|
|
total: f64,
|
|
parts: Vec<(SharedString, f64)>,
|
|
amounts: Option<Amounts>,
|
|
}
|
|
|
|
impl UsageBar {
|
|
/// Parts of `total`; what they leave shows as free track.
|
|
pub fn new(total: f64) -> Self {
|
|
assert!(
|
|
total > 0.0,
|
|
"a usage bar needs a positive total, got {total}"
|
|
);
|
|
Self {
|
|
total,
|
|
parts: Vec::new(),
|
|
amounts: None,
|
|
}
|
|
}
|
|
|
|
/// Shows each part's amount, as `write` puts it, in place of its share.
|
|
pub fn amounts(mut self, write: impl Fn(f64) -> String + 'static) -> Self {
|
|
self.amounts = Some(Rc::new(write));
|
|
self
|
|
}
|
|
|
|
pub fn part(mut self, name: impl Into<SharedString>, amount: f64) -> Self {
|
|
assert!(amount >= 0.0, "a part cannot be negative, got {amount}");
|
|
self.parts.push((name.into(), amount));
|
|
self
|
|
}
|
|
}
|
|
|
|
impl UsageBar {
|
|
/// What the parts use and whether they fill the total, within float error; over it they fill it.
|
|
fn used(&self) -> (f64, bool) {
|
|
let used: f64 = self.parts.iter().map(|(_, amount)| amount).sum();
|
|
(used, used >= self.total * (1.0 - 1e-9))
|
|
}
|
|
|
|
/// A part's figure in the legend: its amount as `amounts` writes it, or its share.
|
|
fn figure(&self, amount: f64) -> String {
|
|
match &self.amounts {
|
|
Some(write) => write(amount),
|
|
None => format::percent(amount / self.total, 0, false),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for UsageBar {
|
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let theme = cx.theme();
|
|
let colors = &theme.colors;
|
|
let (used, full) = self.used();
|
|
let hue = |ix: usize| colors.chart[ix % colors.chart.len()];
|
|
let total = self.total;
|
|
let grown = |share: f64| {
|
|
let mut part = div().h_full().flex_basis(relative(0.0));
|
|
part.style().flex_grow = Some(share as f32);
|
|
part
|
|
};
|
|
let last = self.parts.len().saturating_sub(1);
|
|
let bar = div()
|
|
.flex()
|
|
.gap_0p5()
|
|
.h(theme.meter_track())
|
|
.children(self.parts.iter().enumerate().map(|(ix, (_, amount))| {
|
|
grown(amount / total)
|
|
.bg(hue(ix))
|
|
.when(ix == 0, |part| part.rounded_l_full())
|
|
.when(ix == last && full, |part| part.rounded_r_full())
|
|
}))
|
|
.when(!full, |bar| {
|
|
bar.child(
|
|
grown((total - used) / total)
|
|
.bg(colors.border)
|
|
.rounded_r_full()
|
|
.when(self.parts.is_empty(), |free| free.rounded_l_full()),
|
|
)
|
|
});
|
|
let legend = self.parts.iter().enumerate().map(|(ix, (name, amount))| {
|
|
div()
|
|
.flex()
|
|
.items_center()
|
|
.gap_1p5()
|
|
.child(div().size(theme.status_dot()).rounded_full().bg(hue(ix)))
|
|
.child(div().text_color(colors.fg).child(name.clone()))
|
|
.child(
|
|
tabular(div())
|
|
.text_color(colors.fg_muted)
|
|
.child(self.figure(*amount)),
|
|
)
|
|
});
|
|
div().flex().flex_col().gap_2().child(bar).child(
|
|
div()
|
|
.flex()
|
|
.flex_wrap()
|
|
.gap_x_4()
|
|
.gap_y_1()
|
|
.text_size(theme.text_size(TextSize::Sm))
|
|
.font_weight(FontWeight::NORMAL)
|
|
.children(legend),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{Level, UsageBar, tone};
|
|
use crate::theme::Palette;
|
|
|
|
#[test]
|
|
fn a_tone_follows_the_share_past_each_limit() {
|
|
let colors = Palette::light(false);
|
|
let limits = (0.8, 0.95);
|
|
assert_eq!(tone(0.5, limits, &colors), colors.fg);
|
|
assert_eq!(tone(0.85, limits, &colors), colors.warning);
|
|
assert_eq!(tone(0.97, limits, &colors), colors.danger);
|
|
}
|
|
|
|
#[test]
|
|
fn parts_past_the_total_fill_it() {
|
|
let over = UsageBar::new(10.0).part("a", 8.0).part("b", 5.0);
|
|
assert_eq!(over.used(), (13.0, true));
|
|
}
|
|
|
|
#[test]
|
|
fn parts_that_sum_to_the_total_fill_it_despite_float_error() {
|
|
assert!(UsageBar::new(0.3).part("a", 0.1).part("b", 0.2).used().1);
|
|
assert!(!UsageBar::new(1.0).part("a", 0.5).used().1);
|
|
}
|
|
|
|
#[test]
|
|
fn a_part_reads_as_its_share_or_as_written() {
|
|
let bar = UsageBar::new(4.0).part("a", 1.0);
|
|
assert_eq!(bar.figure(1.0), "25%");
|
|
let bar = bar.amounts(|amount| format!("{amount} GB"));
|
|
assert_eq!(bar.figure(1.0), "1 GB");
|
|
}
|
|
|
|
#[test]
|
|
fn a_share_turns_amber_then_red() {
|
|
let limits = (0.8, 0.95);
|
|
assert_eq!(Level::of(0.5, limits), Level::Calm);
|
|
assert_eq!(Level::of(0.8, limits), Level::Warn);
|
|
assert_eq!(Level::of(0.94, limits), Level::Warn);
|
|
assert_eq!(Level::of(0.95, limits), Level::Danger);
|
|
}
|
|
}
|