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,251 @@
|
||||
use barcoders::sym::{code128::Code128, ean13::EAN13};
|
||||
use gpui::{
|
||||
App, Bounds, Div, IntoElement, ParentElement, Pixels, Refineable, RenderOnce, SharedString,
|
||||
StyleRefinement, Styled, Window, canvas, div, fill, point, size,
|
||||
};
|
||||
|
||||
pub use barcoders::error::Error as BarcodeError;
|
||||
pub use qrcode::types::QrError;
|
||||
|
||||
use crate::theme::{ActiveTheme, Radius, TextSize};
|
||||
|
||||
/// The quiet margin around a QR code, in modules, as the standard asks.
|
||||
const QUIET: usize = 4;
|
||||
|
||||
/// The dark runs in a row of modules, as start and length.
|
||||
fn runs(row: &[bool]) -> Vec<(usize, usize)> {
|
||||
let mut runs = Vec::new();
|
||||
let mut start = None;
|
||||
for (ix, dark) in row.iter().chain([&false]).enumerate() {
|
||||
match (start, *dark) {
|
||||
(None, true) => start = Some(ix),
|
||||
(Some(from), false) => {
|
||||
runs.push((from, ix - from));
|
||||
start = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
/// Paints each row's dark runs as bars `module` wide, from `origin`.
|
||||
fn paint_runs(
|
||||
rows: &[Vec<(usize, usize)>],
|
||||
origin: gpui::Point<Pixels>,
|
||||
module: Pixels,
|
||||
height: Pixels,
|
||||
color: gpui::Hsla,
|
||||
window: &mut Window,
|
||||
) {
|
||||
for (y, row) in rows.iter().enumerate() {
|
||||
for (x, length) in row {
|
||||
let corner = point(origin.x + module * *x as f32, origin.y + height * y as f32);
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(corner, size(module * *length as f32, height)),
|
||||
color,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text as a QR code: dark modules on a light tile in both themes, so any camera reads it, inside the standard quiet margin.
|
||||
#[derive(IntoElement)]
|
||||
pub struct QrCode {
|
||||
base: Div,
|
||||
rows: Vec<Vec<(usize, usize)>>,
|
||||
}
|
||||
|
||||
impl QrCode {
|
||||
pub fn new(data: impl AsRef<[u8]>) -> Result<Self, QrError> {
|
||||
let code = qrcode::QrCode::new(data)?;
|
||||
let dark: Vec<bool> = code
|
||||
.to_colors()
|
||||
.into_iter()
|
||||
.map(|color| color == qrcode::Color::Dark)
|
||||
.collect();
|
||||
Ok(Self {
|
||||
base: div(),
|
||||
rows: dark.chunks(code.width()).map(runs).collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for QrCode {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for QrCode {
|
||||
fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let (paper, ink) = (theme.colors.paper, theme.colors.ink);
|
||||
let rows = self.rows;
|
||||
let mut tile = div()
|
||||
.flex_none()
|
||||
.size(theme.qr_code())
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.bg(paper);
|
||||
tile.style().refine(self.base.style());
|
||||
tile.child(
|
||||
canvas(
|
||||
|_, _, _| {},
|
||||
move |bounds, _, window, _| {
|
||||
let span = (rows.len() + QUIET * 2) as f32;
|
||||
let module = (bounds.size.width.min(bounds.size.height) / span).floor();
|
||||
let inset = (rows.len() as f32 * module) / 2.0;
|
||||
let origin = bounds.center() - point(inset, inset);
|
||||
paint_runs(&rows, origin, module, module, ink, window);
|
||||
},
|
||||
)
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// EAN's check digit: weights of one and three from the left, then what lifts the sum to a multiple of ten.
|
||||
fn check_digit(digits: &str) -> u32 {
|
||||
let sum: u32 = digits
|
||||
.chars()
|
||||
.enumerate()
|
||||
.map(|(ix, digit)| {
|
||||
digit.to_digit(10).expect("barcoders checked the digits")
|
||||
* if ix % 2 == 0 { 1 } else { 3 }
|
||||
})
|
||||
.sum();
|
||||
(10 - sum % 10) % 10
|
||||
}
|
||||
|
||||
/// Text as a barcode, bars on a light tile in both themes with the text under them: Code 128 for printable ASCII, EAN-13 for retail numbers.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Barcode {
|
||||
base: Div,
|
||||
bars: Vec<(usize, usize)>,
|
||||
modules: usize,
|
||||
text: SharedString,
|
||||
}
|
||||
|
||||
impl Barcode {
|
||||
fn new(modules: Vec<u8>, text: String) -> Self {
|
||||
let dark: Vec<bool> = modules.iter().map(|module| *module == 1).collect();
|
||||
Self {
|
||||
base: div(),
|
||||
bars: runs(&dark),
|
||||
modules: dark.len(),
|
||||
text: text.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Code 128 in its set B: letters, digits and printable ASCII marks.
|
||||
pub fn code128(text: &str) -> Result<Self, BarcodeError> {
|
||||
let modules = Code128::new(format!("\u{0181}{text}"))?.encode();
|
||||
Ok(Self::new(modules, text.to_string()))
|
||||
}
|
||||
|
||||
/// EAN-13 from twelve digits, or thirteen whose check digit must match.
|
||||
pub fn ean13(digits: &str) -> Result<Self, BarcodeError> {
|
||||
let modules = EAN13::new(digits)?.encode();
|
||||
let text = match digits.len() {
|
||||
12 => format!("{digits}{}", check_digit(digits)),
|
||||
_ => digits.to_string(),
|
||||
};
|
||||
Ok(Self::new(modules, text))
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Barcode {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Barcode {
|
||||
fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let (paper, ink) = (theme.colors.paper, theme.colors.ink);
|
||||
let bars = vec![self.bars];
|
||||
let mut tile = div()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.px_4()
|
||||
.pt_3()
|
||||
.pb_2()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.bg(paper)
|
||||
.text_color(ink);
|
||||
tile.style().refine(self.base.style());
|
||||
tile.child(
|
||||
canvas(
|
||||
|_, _, _| {},
|
||||
move |bounds, _, window, _| {
|
||||
let module = bounds.size.width / self.modules as f32;
|
||||
paint_runs(
|
||||
&bars,
|
||||
bounds.origin,
|
||||
module,
|
||||
bounds.size.height,
|
||||
ink,
|
||||
window,
|
||||
);
|
||||
},
|
||||
)
|
||||
.w(theme.barcode_module() * self.modules as f32)
|
||||
.h(theme.barcode_height()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.child(self.text),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Barcode, BarcodeError, check_digit, runs};
|
||||
|
||||
#[test]
|
||||
fn dark_modules_merge_into_runs() {
|
||||
assert_eq!(runs(&[true, true, false, true]), [(0, 2), (3, 1)]);
|
||||
assert_eq!(runs(&[false, false]), []);
|
||||
assert_eq!(runs(&[true]), [(0, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ean_gains_its_check_digit_and_refuses_a_wrong_one() {
|
||||
assert_eq!(check_digit("400638133393"), 1);
|
||||
assert_eq!(check_digit("590123412345"), 7);
|
||||
assert_eq!(
|
||||
Barcode::ean13("400638133393").map(|code| code.text).ok(),
|
||||
Some("4006381333931".into())
|
||||
);
|
||||
assert!(matches!(
|
||||
Barcode::ean13("4006381333932"),
|
||||
Err(BarcodeError::Checksum)
|
||||
));
|
||||
assert!(matches!(
|
||||
Barcode::code128("caf\u{e9}"),
|
||||
Err(BarcodeError::Character)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_128_spends_eleven_modules_a_character() {
|
||||
let code = Barcode::code128("ELY-2419").expect("printable ASCII");
|
||||
assert_eq!(
|
||||
code.modules,
|
||||
11 + 8 * 11 + 11 + 13,
|
||||
"start, eight characters, check, stop"
|
||||
);
|
||||
assert_eq!(
|
||||
code.bars.first(),
|
||||
Some(&(0, 2)),
|
||||
"set B's start opens with two dark modules"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
use gpui::{
|
||||
App, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// What a cell holds: a value, or whether the option has the feature.
|
||||
enum Cell {
|
||||
Text(SharedString),
|
||||
Mark(bool),
|
||||
}
|
||||
|
||||
/// Options side by side: a column each, a row per feature, a value or a mark in each cell. One column can stand out.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Comparison {
|
||||
columns: Vec<SharedString>,
|
||||
featured: Option<usize>,
|
||||
rows: Vec<(SharedString, Vec<Cell>)>,
|
||||
}
|
||||
|
||||
impl Comparison {
|
||||
pub fn new(columns: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
|
||||
let columns: Vec<SharedString> = columns.into_iter().map(Into::into).collect();
|
||||
assert!(!columns.is_empty(), "a comparison needs an option");
|
||||
Self {
|
||||
columns,
|
||||
featured: None,
|
||||
rows: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tints column `ix`, such as the plan you suggest.
|
||||
pub fn featured(mut self, ix: usize) -> Self {
|
||||
assert!(
|
||||
ix < self.columns.len(),
|
||||
"column {ix} is past the last of {}",
|
||||
self.columns.len()
|
||||
);
|
||||
self.featured = Some(ix);
|
||||
self
|
||||
}
|
||||
|
||||
/// A feature and its value under each option.
|
||||
pub fn row(
|
||||
self,
|
||||
name: impl Into<SharedString>,
|
||||
values: impl IntoIterator<Item = impl Into<SharedString>>,
|
||||
) -> Self {
|
||||
let cells = values
|
||||
.into_iter()
|
||||
.map(|value| Cell::Text(value.into()))
|
||||
.collect();
|
||||
self.push(name.into(), cells)
|
||||
}
|
||||
|
||||
/// A feature, and whether each option has it.
|
||||
pub fn marks(
|
||||
self,
|
||||
name: impl Into<SharedString>,
|
||||
values: impl IntoIterator<Item = bool>,
|
||||
) -> Self {
|
||||
let cells = values.into_iter().map(Cell::Mark).collect();
|
||||
self.push(name.into(), cells)
|
||||
}
|
||||
|
||||
fn push(mut self, name: SharedString, cells: Vec<Cell>) -> Self {
|
||||
assert_eq!(
|
||||
cells.len(),
|
||||
self.columns.len(),
|
||||
"row {name} needs a cell per option"
|
||||
);
|
||||
self.rows.push((name, cells));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Comparison {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let (featured, last) = (self.featured, self.rows.len());
|
||||
let columns = self.columns.len() as u16 + 1;
|
||||
let radius = theme.radius(Radius::Md);
|
||||
let cell = move |column: Option<usize>, row: usize| {
|
||||
let lit = column.is_some() && column == featured;
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.min_h(theme.control_height(ControlSize::Lg))
|
||||
.px_3()
|
||||
.py_2()
|
||||
.when(row > 0, |cell| {
|
||||
cell.border_t_1().border_color(colors.border)
|
||||
})
|
||||
.when(column.is_some(), |cell| cell.justify_center())
|
||||
.when(lit, |cell| {
|
||||
cell.bg(colors.hover)
|
||||
.when(row == 0, |cell| cell.rounded_t(radius))
|
||||
.when(row == last, |cell| cell.rounded_b(radius))
|
||||
})
|
||||
};
|
||||
let head = std::iter::once(cell(None, 0).into_any_element()).chain(
|
||||
self.columns.into_iter().enumerate().map(|(ix, title)| {
|
||||
cell(Some(ix), 0)
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(colors.fg)
|
||||
.child(title)
|
||||
.into_any_element()
|
||||
}),
|
||||
);
|
||||
let body = self
|
||||
.rows
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.flat_map(|(ix, (name, cells))| {
|
||||
let row = ix + 1;
|
||||
std::iter::once(
|
||||
cell(None, row)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(name)
|
||||
.into_any_element(),
|
||||
)
|
||||
.chain(cells.into_iter().enumerate().map(
|
||||
move |(column, value)| {
|
||||
let body = match value {
|
||||
Cell::Text(text) => {
|
||||
div().text_color(colors.fg).child(text).into_any_element()
|
||||
}
|
||||
Cell::Mark(true) => Icon::new(IconName::Check)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg)
|
||||
.into_any_element(),
|
||||
Cell::Mark(false) => Icon::new(IconName::Minus)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_subtle)
|
||||
.into_any_element(),
|
||||
};
|
||||
cell(Some(column), row).child(body).into_any_element()
|
||||
},
|
||||
))
|
||||
});
|
||||
div()
|
||||
.grid()
|
||||
.grid_cols(columns)
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.children(head)
|
||||
.children(body)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Comparison;
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "row Storage needs a cell per option")]
|
||||
fn a_row_needs_a_cell_per_option() {
|
||||
let _ = Comparison::new(["Free", "Pro"]).row("Storage", ["5 GB"]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,13 @@ mod badge;
|
||||
mod before_after;
|
||||
mod carousel;
|
||||
mod changelog;
|
||||
mod codes;
|
||||
mod comparison;
|
||||
mod feed;
|
||||
mod gallery;
|
||||
mod measures;
|
||||
mod records;
|
||||
mod stars;
|
||||
mod stat;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -17,9 +21,14 @@ pub use badge::{Badge, CountBadge, DotBadge, Tag, Tone};
|
||||
pub use before_after::BeforeAfter;
|
||||
pub use carousel::Carousel;
|
||||
pub use changelog::{Change, Changelog, Release};
|
||||
pub use codes::{Barcode, BarcodeError, QrCode, QrError};
|
||||
pub use comparison::Comparison;
|
||||
pub use feed::{Activity, ActivityFeed};
|
||||
pub use gallery::Gallery;
|
||||
pub use measures::{Gauge, Meter, UsageBar};
|
||||
pub use records::{DescriptionList, PropertyGrid, PropertyGroup};
|
||||
pub use stars::Stars;
|
||||
pub(crate) use stars::star;
|
||||
pub use stat::{KpiCard, Statistic, TrendIndicator};
|
||||
pub use timeline::{Timeline, TimelineItem};
|
||||
pub use watermark::Watermark;
|
||||
@@ -0,0 +1,124 @@
|
||||
use std::f32::consts::{FRAC_PI_2, PI};
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, ContentMask, IntoElement, ParentElement, Path, PathBuilder, Pixels, RenderOnce,
|
||||
Styled, Window, canvas, div, point, size,
|
||||
};
|
||||
|
||||
use crate::theme::{ActiveTheme, IconSize};
|
||||
|
||||
/// A five-point star inside `bounds`, filled or outlined with Lucide's stroke ratio.
|
||||
pub(crate) fn star(bounds: Bounds<Pixels>, fill: bool) -> Path<Pixels> {
|
||||
let side = bounds.size.width.min(bounds.size.height);
|
||||
let stroke = side / 12.0;
|
||||
let center = bounds.center();
|
||||
let outer = side / 2.0 - stroke;
|
||||
let mut path = if fill {
|
||||
PathBuilder::fill()
|
||||
} else {
|
||||
PathBuilder::stroke(stroke)
|
||||
};
|
||||
for k in 0..10 {
|
||||
let radius = if k % 2 == 0 { outer } else { outer * 0.5 };
|
||||
let angle = -FRAC_PI_2 + k as f32 * PI / 5.0;
|
||||
let at = point(
|
||||
center.x + radius * angle.cos(),
|
||||
center.y + radius * angle.sin(),
|
||||
);
|
||||
if k == 0 {
|
||||
path.move_to(at);
|
||||
} else {
|
||||
path.line_to(at);
|
||||
}
|
||||
}
|
||||
path.close();
|
||||
path.build().expect("a star is a simple closed path")
|
||||
}
|
||||
|
||||
/// How much of star `ix` a score fills, from none to all.
|
||||
fn share(score: f32, ix: usize) -> f32 {
|
||||
(score - ix as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// A score in stars, read only. A score of 4.6 fills four stars and three fifths of the fifth.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Stars {
|
||||
score: f32,
|
||||
max: u8,
|
||||
size: IconSize,
|
||||
}
|
||||
|
||||
impl Stars {
|
||||
pub fn new(score: f32) -> Self {
|
||||
assert!(
|
||||
score.is_finite() && score >= 0.0,
|
||||
"a score of {score} cannot be drawn"
|
||||
);
|
||||
Self {
|
||||
score,
|
||||
max: 5,
|
||||
size: IconSize::Md,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max(mut self, max: u8) -> Self {
|
||||
assert!(max > 0, "stars need at least one");
|
||||
self.max = max;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn size(mut self, size: IconSize) -> Self {
|
||||
self.size = size;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Stars {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let (score, max) = (self.score, self.max);
|
||||
assert!(score <= max as f32, "a score of {score} is above {max}");
|
||||
let theme = cx.theme();
|
||||
let (on, off) = (theme.colors.accent, theme.colors.border_strong);
|
||||
let side = theme.icon_size(self.size);
|
||||
div()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.gap_0p5()
|
||||
.children((0..max as usize).map(|ix| {
|
||||
let filled = share(score, ix);
|
||||
canvas(
|
||||
|_, _, _| {},
|
||||
move |bounds, _, window, _| {
|
||||
window.paint_path(star(bounds, false), off);
|
||||
if filled > 0.0 {
|
||||
let part = Bounds::new(
|
||||
bounds.origin,
|
||||
size(bounds.size.width * filled, bounds.size.height),
|
||||
);
|
||||
window.with_content_mask(
|
||||
Some(ContentMask { bounds: part }),
|
||||
|window| {
|
||||
window.paint_path(star(bounds, true), on);
|
||||
window.paint_path(star(bounds, false), on);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
.size(side)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::share;
|
||||
|
||||
#[test]
|
||||
fn a_score_fills_whole_stars_then_part_of_one() {
|
||||
let fills: Vec<f32> = (0..5).map(|ix| share(3.5, ix)).collect();
|
||||
assert_eq!(fills, [1.0, 1.0, 1.0, 0.5, 0.0]);
|
||||
assert_eq!(share(0.0, 0), 0.0);
|
||||
assert_eq!(share(5.0, 4), 1.0);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user