T12d Data Display: QrCode, Barcode, Meter, Gauge, UsageBar, Stars, Comparison (review pending: codex 401)

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 19:18:10 -04:00
1 parent 8798d95d7a
commit f41faf5684
18 files changed
+1161 -46

No files matched your search

+2
View File
@@ -8,6 +8,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- gpui 0.2.2 from crates.io. Feature `runtime_shaders` is on by default: machines without Xcode lack the Metal compiler.
- Assets, embedded with `rust-embed`: Lucide 1.48.0 icons (ISC), Inter 4.1 and JetBrains Mono 2.304 (OFL).
- Catalog data: `emojis` (Unicode emoji), `isolang` (ISO 639, native names), `isocountry` (ISO 3166), `iso_currency` (ISO 4217, also minor units for money).
- Codes: `qrcode` (QR) and `barcoders` (Code 128, EAN-13), both MIT OR Apache-2.0, default features off.
- macOS extras (tray icon, Dock badge) call AppKit through `cocoa` 0.26 and `objc` 0.2, the crates gpui already links.
- Gallery: `examples/gallery`. Website: `frontend/` (Vite 8, pnpm), built after the components.
@@ -80,6 +81,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- gpui runs key bindings before key listeners. A container takes a child's bound key through the action with `capture_action`, as `Form` takes `Submit`.
- `ScrollHandle::scroll_to_item` runs in the container's prepaint before its bounds are stored, so a first-frame call misfires. Call it from a child's prepaint once the handle has bounds, then request one frame (`navigation::editor_tabs`). Never poll layout with `request_animation_frame`.
- gpui animations run on the wall clock; timers run on the executor's clock, which tests advance by hand. A test that needs an animation settled turns on the theme's reduced motion. Clocks read `background_executor().now()` and wake only their own view.
- Machine-read marks, QR codes and barcodes, paint `ink` on `paper`: dark on light in both themes, so cameras read them.
- Reduced motion shortens animations to 1ms on the wall clock. Tests sleep past it between refreshes to see where one ends.
- The test platform never runs next-frame callbacks, and its text metrics are simplified: each character takes a fixed advance. Tests refresh the window in place of the display link, and click where layout does not hang on text, such as padding and slots.
- An absolute element with no insets sits where it would flow: after the content in a block, inside the padding in a flex box. A canvas that measures its parent pins itself with `.top_0().left_0()`.
Generated
+14
View File
@@ -544,6 +544,12 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "barcoders"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3826fb6e98ec72c0c0db8c9a40af4932d16793021027469857e84c1b50a1e8f"
[[package]]
name = "base64"
version = "0.22.1"
@@ -1543,6 +1549,7 @@ name = "ely-gpui-component"
version = "0.1.0"
dependencies = [
"anyhow",
"barcoders",
"block",
"cocoa 0.26.0",
"core-foundation 0.10.0",
@@ -1557,6 +1564,7 @@ dependencies = [
"jiff",
"log",
"objc",
"qrcode",
"raw-window-handle",
"regex",
"rust-embed",
@@ -4331,6 +4339,12 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
[[package]]
name = "quick-error"
version = "2.0.1"
+2
View File
@@ -15,6 +15,7 @@ test-support = ["gpui/test-support"]
[dependencies]
anyhow = "1"
barcoders = { version = "2", default-features = false, features = ["std"] }
emojis = "0.9"
gpui = "0.2.2"
iso_currency = { version = "0.7", features = ["iterator"] }
@@ -22,6 +23,7 @@ isocountry = "0.3"
isolang = { version = "2.4", features = ["local_names", "list_languages"] }
jiff = "0.2"
log = "0.4"
qrcode = { version = "0.14", default-features = false }
regex = "1"
rust-embed = "8.12"
serde = { version = "1", features = ["derive"] }
+1
View File
@@ -185,3 +185,4 @@ Per-component lines live in `tasks/`. Tags there:
| T12a | 1 | — | pending: codex answered 401 on every request; committed unreviewed, reviewed from its commit once codex logs in |
| T12b | 1 | — | pending: codex still logged out |
| T12c | 1 | — | pending: codex still logged out |
| T12d | 1 | — | pending: codex still logged out |
+138
View File
@@ -0,0 +1,138 @@
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::{Barcode, Comparison, Gauge, Meter, QrCode, Stars, UsageBar},
theme::{ActiveTheme, IconSize, TextSize},
};
use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px};
use crate::{
probe::probe,
ui::{keep, row, section, set, specimen, specimens},
};
pub fn codes(cx: &mut App) -> impl IntoElement + use<> {
let qr = QrCode::new("https://github.com/ZacharyZhang-NY/Ely-GPUI-Components")
.expect("a short link fits a QR code");
let label = Barcode::code128("ELY-2419").expect("printable ASCII");
let retail = Barcode::ean13("400638133393").expect("twelve digits");
section(
"QRCode / Barcode",
"Codes for cameras and scanners: dark on a light tile in both themes, so they read anywhere. Code 128 takes printable ASCII; EAN-13 adds its own check digit.",
cx,
)
.child(
specimens()
.child(specimen("QR code", qr, cx))
.child(specimen("Code 128", label, cx))
.child(specimen("EAN-13", retail, cx)),
)
}
/// Readings the demo steps through: storage, quota, memory, CPU, temperature.
const READINGS: [(f32, f32, f32, f64, f64); 3] = [
(0.15, 0.62, 0.71, 38.0, 54.0),
(0.41, 0.86, 0.9, 64.0, 71.5),
(0.58, 0.97, 0.99, 91.0, 88.0),
];
pub fn measures(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let turn = keep("measures-turn", || 0usize, window, cx);
let (now, next) = (*turn.read(cx), turn.clone());
let (disk, quota, memory, cpu, heat) = READINGS[now % READINGS.len()];
section(
"Meter / Gauge / UsageBar",
"How full things are. A meter turns amber past 80% and red past 95%; a gauge rolls its number and sweeps its arc; a usage bar splits one whole into parts.",
cx,
)
.child(
div()
.flex()
.gap_8()
.child(
div()
.w(px(320.))
.flex()
.flex_col()
.gap_4()
.child(Meter::new("meter-disk", "Storage", disk).detail(format!("{:.0} GB of 256 GB", disk * 256.0)))
.child(Meter::new("meter-quota", "Upload quota", quota))
.child(Meter::new("meter-memory", "Memory", memory)),
)
.child(Gauge::new("gauge-cpu", "CPU", cpu, 0.0, 100.0).suffix("%"))
.child(
Gauge::new("gauge-heat", "Temperature", heat, 20.0, 100.0)
.decimals(1)
.suffix("°C")
.thresholds(0.7, 0.85),
),
)
.child(
div().w(px(560.)).child(
UsageBar::new(256.0)
.part("Apps", 42.0)
.part("Photos", 81.5)
.part("Documents", 23.0)
.part("System", 18.0),
),
)
.child(row().child(probe(
"measures-next",
Button::new("measures-next", "Next reading")
.variant(ButtonVariant::Ghost)
.on_click(move |_, _, cx| set(&next, now + 1, cx)),
)))
}
pub fn stars(cx: &mut App) -> impl IntoElement + use<> {
let theme = cx.theme();
let score = |value: f32, note: &'static str| {
div()
.flex()
.items_center()
.gap_2()
.child(Stars::new(value))
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(note),
)
};
section(
"Stars / Rating display",
"A score in stars, read only. Parts of a star fill too; to set a score, use Rating in Forms.",
cx,
)
.child(
div()
.flex()
.flex_col()
.gap_3()
.child(score(4.6, "4.6 · 1,284 reviews"))
.child(score(3.0, "3.0 · 18 reviews"))
.child(div().flex().items_center().gap_2().child(Stars::new(8.5).max(10).size(IconSize::Sm)).child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child("8.5 of 10, small"),
)),
)
}
pub fn comparison(cx: &mut App) -> impl IntoElement + use<> {
section(
"Comparison",
"Options side by side, a row per feature. One column can stand out.",
cx,
)
.child(
div().w(px(640.)).child(
Comparison::new(["Free", "Pro", "Team"])
.featured(1)
.row("Storage", ["5 GB", "1 TB", "Unlimited"])
.marks("Version history", [false, true, true])
.marks("Shared spaces", [false, false, true])
.row("Support", ["Community", "Email", "Priority"]),
),
)
}
+8
View File
@@ -1,3 +1,4 @@
mod measures;
mod media;
mod people;
mod records;
@@ -48,6 +49,9 @@ const SCRIPT: &[Step] = &[
Step::UpAt("before-after", 390.0, 160.0),
Step::Wait(200),
Step::Shot("before-after-drag"),
Step::Click("measures-next"),
Step::Wait(400),
Step::Shot("measures-next"),
Step::Rest,
];
@@ -67,5 +71,9 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(media::gallery(cx))
.child(media::before_after(cx))
.child(media::watermark(window, cx))
.child(measures::codes(cx))
.child(measures::measures(window, cx))
.child(measures::stars(cx))
.child(measures::comparison(cx))
.into_any_element()
}
+251
View File
@@ -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"
);
}
}
+164
View File
@@ -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"]);
}
}
+395
View File
@@ -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);
}
}
+9
View File
@@ -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;
+124
View File
@@ -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);
}
}
+4 -34
View File
@@ -1,47 +1,17 @@
use std::{
f32::consts::{FRAC_PI_2, PI},
rc::Rc,
};
use std::rc::Rc;
use gpui::{
App, Bounds, ElementId, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement,
Path, PathBuilder, Pixels, RenderOnce, StatefulInteractiveElement, Styled, Window, canvas, div,
point, prelude::*, transparent_black,
Pixels, RenderOnce, StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*,
transparent_black,
};
use crate::{
data_display::star,
primitives::tab_stop,
theme::{ActiveTheme, IconSize, Radius},
};
/// A five-point star inside `bounds`, filled or outlined with Lucide's stroke ratio.
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")
}
fn paint_star(bounds: Bounds<Pixels>, filled: bool, color: Hsla, window: &mut Window) {
if filled {
window.paint_path(star(bounds, true), color);
+2
View File
@@ -20,9 +20,11 @@ pub use effects::{Blink, Flash, Glow, Marquee, Pulse, Shake};
pub use list::{AnimatePresence, Flip};
pub use overlay::{LazyLoad, LoadingOverlay, Refresh, RefreshIndicator, TypingIndicator};
pub use particles::{AnimatedGradient, Confetti, ParticleBackground, Ripple};
pub(crate) use progress::follow;
pub use progress::{ProgressBar, ProgressRing};
pub use reorder::Reorder;
pub use skeleton::{Shimmer, Skeleton, SkeletonAvatar, SkeletonCard, SkeletonTable, SkeletonText};
pub(crate) use slide::{Axis, Marker, glide, measure_item, measure_origin, slide};
pub(crate) use spinner::arc;
pub use spinner::{Spinner, SpinnerStyle};
pub use transition::{Entrance, Stagger, Transition};
+8 -3
View File
@@ -19,7 +19,12 @@ struct Glide {
}
/// Keeps `value`'s last change; returns where to glide from, and the change's number.
fn glide(id: &ElementId, value: f32, window: &mut Window, cx: &mut App) -> (f32, usize) {
pub(crate) fn follow(
id: &ElementId,
value: f32,
window: &mut Window,
cx: &mut App,
) -> (f32, usize) {
let state = window.use_keyed_state((id.clone(), "glide"), cx, |_, _| Glide {
from: value,
to: value,
@@ -136,7 +141,7 @@ impl RenderOnce for ProgressBar {
)
.into_any_element();
};
let (from, turn) = glide(&self.id, value, window, cx);
let (from, turn) = follow(&self.id, value, window, cx);
let (buffer, segments) = (self.buffer, self.segments);
let lanes = move |at: f32| {
(0..segments).map(move |ix| {
@@ -196,7 +201,7 @@ impl ProgressRing {
impl RenderOnce for ProgressRing {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (value, percent) = (self.value, self.percent);
let (from, turn) = glide(&self.id, value, window, cx);
let (from, turn) = follow(&self.id, value, window, cx);
let theme = cx.theme();
let (fill, track) = (theme.colors.fg, theme.colors.border);
let text = theme.text_size(TextSize::Xs);
+1 -1
View File
@@ -92,7 +92,7 @@ fn dot(center: Point<Pixels>, radius: Pixels, color: Hsla, window: &mut Window)
window.paint_quad(fill(Bounds::new(corner, size(side, side)), color).corner_radii(radius));
}
pub(super) fn arc(
pub(crate) fn arc(
center: Point<Pixels>,
radius: Pixels,
from: f32,
+6
View File
@@ -101,6 +101,8 @@ mixable!(
shadow: Hsla,
tooltip_bg: Hsla,
tooltip_fg: Hsla,
paper: Hsla,
ink: Hsla,
chart: [Hsla; 8],
ansi: [Hsla; 16],
syntax: Syntax,
@@ -151,6 +153,8 @@ impl Palette {
shadow: c(0x181613),
tooltip_bg: c(0x181613),
tooltip_fg: c(0xfcfaf7),
paper: c(0xffffff),
ink: c(0x181613),
chart: c8([
0x5182c1, 0x009589, 0xaa732b, 0xb8636b, 0x8572bb, 0x539156, 0xb66946, 0x138db1,
]),
@@ -218,6 +222,8 @@ impl Palette {
shadow: c(0x000000),
tooltip_bg: c(0x373533),
tooltip_fg: c(0xf3f1f0),
paper: c(0xf3f1f0),
ink: c(0x181613),
chart: c8([
0x79a7e2, 0x4eb9ad, 0xcd995c, 0xdc8a90, 0xa998dd, 0x7cb57d, 0xd9906f, 0x56b2d4,
]),
+24
View File
@@ -284,6 +284,30 @@ impl Theme {
px_to_rems(128.0)
}
/// A QR code's tile.
pub fn qr_code(&self) -> Rems {
px_to_rems(160.0)
}
/// The narrowest bar of a barcode; the others are whole multiples.
pub fn barcode_module(&self) -> Rems {
px_to_rems(2.0)
}
pub fn barcode_height(&self) -> Rems {
px_to_rems(56.0)
}
/// A gauge's dial.
pub fn gauge(&self) -> Rems {
px_to_rems(144.0)
}
/// The track of a meter or a usage bar.
pub fn meter_track(&self) -> Rems {
px_to_rems(6.0)
}
/// One repeat of a watermark's text.
pub fn watermark_tile(&self) -> Size<Rems> {
size(px_to_rems(200.0), px_to_rems(96.0))
+8 -8
View File
@@ -80,14 +80,14 @@
## T12d Data Display · Codes & measures
- [ ] QRCode
- [ ] Barcode
- [ ] ColorSwatch display → `forms::ColorSwatch`
- [ ] Meter
- [ ] Gauge
- [ ] UsageBar
- [ ] Rating display → `forms::Rating`
- [ ] Comparison
- [x] QRCode → `data_display::QrCode`
- [x] Barcode → `data_display::Barcode`: Code 128 and EAN-13
- [x] ColorSwatch display → `forms::ColorSwatch`
- [x] Meter → `data_display::Meter`
- [x] Gauge → `data_display::Gauge`
- [x] UsageBar → `data_display::UsageBar`
- [x] Rating display → `data_display::Stars`, read only with parts of a star; `forms::Rating` sets a score
- [x] Comparison → `data_display::Comparison`
## T13 Lists & Trees — `src/lists`