T12d Data Display: QrCode, Barcode, Meter, Gauge, UsageBar, Stars, Comparison (review pending: codex 401)
This commit is contained in:
18 files changed
+1161
-46
No files matched your search
@@ -0,0 +1,395 @@
|
||||
use std::f32::consts::{FRAC_PI_4, TAU};
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, App, ElementId, FontWeight, Hsla, IntoElement, ParentElement,
|
||||
RenderOnce, SharedString, Styled, Window, canvas, div, prelude::*, relative,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
motion,
|
||||
theme::{ActiveTheme, Palette, TextSize},
|
||||
typography::{AnimatedNumber, 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]
|
||||
}
|
||||
|
||||
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().text_color(colors.fg).child(self.label))
|
||||
.child(tabular(div()).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 {
|
||||
/// `value` lies within `min..=max`.
|
||||
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}"
|
||||
);
|
||||
assert!(
|
||||
(min..=max).contains(&value),
|
||||
"{value} is outside {min}..={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)) 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// How a whole divides: one bar of colored parts with the free rest as track, and a legend of each part's share.
|
||||
#[derive(IntoElement)]
|
||||
pub struct UsageBar {
|
||||
total: f64,
|
||||
parts: Vec<(SharedString, f64)>,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
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 RenderOnce for UsageBar {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let used: f64 = self.parts.iter().map(|(_, amount)| amount).sum();
|
||||
assert!(
|
||||
used <= self.total,
|
||||
"the parts ({used}) pass the total ({})",
|
||||
self.total
|
||||
);
|
||||
assert!(
|
||||
self.parts.len() <= colors.chart.len(),
|
||||
"a usage bar has colors for {} parts",
|
||||
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(colors.chart[ix])
|
||||
.when(ix == 0, |part| part.rounded_l_full())
|
||||
.when(ix == last && used == total, |part| part.rounded_r_full())
|
||||
}))
|
||||
.when(used < total, |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(colors.chart[ix]),
|
||||
)
|
||||
.child(div().text_color(colors.fg).child(name.clone()))
|
||||
.child(
|
||||
tabular(div())
|
||||
.text_color(colors.fg_muted)
|
||||
.child(format::percent(amount / total, 0, false)),
|
||||
)
|
||||
});
|
||||
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;
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user