From f41faf568461f702323ab027cb52f94b13dd7c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 19:18:10 -0400 Subject: [PATCH] T12d Data Display: QrCode, Barcode, Meter, Gauge, UsageBar, Stars, Comparison (review pending: codex 401) --- AGENTS.md | 2 + Cargo.lock | 14 + Cargo.toml | 2 + TASKS.md | 1 + examples/gallery/pages/data/measures.rs | 138 +++++++++ examples/gallery/pages/data/mod.rs | 8 + src/data_display/codes.rs | 251 +++++++++++++++ src/data_display/comparison.rs | 164 ++++++++++ src/data_display/measures.rs | 395 ++++++++++++++++++++++++ src/data_display/mod.rs | 9 + src/data_display/stars.rs | 124 ++++++++ src/forms/rating.rs | 38 +-- src/motion/mod.rs | 2 + src/motion/progress.rs | 11 +- src/motion/spinner.rs | 2 +- src/theme/palette.rs | 6 + src/theme/tokens.rs | 24 ++ tasks/ch11-20.md | 16 +- 18 files changed, 1161 insertions(+), 46 deletions(-) create mode 100644 examples/gallery/pages/data/measures.rs create mode 100644 src/data_display/codes.rs create mode 100644 src/data_display/comparison.rs create mode 100644 src/data_display/measures.rs create mode 100644 src/data_display/stars.rs diff --git a/AGENTS.md b/AGENTS.md index 7086279..cadda96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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()`. diff --git a/Cargo.lock b/Cargo.lock index 39a9c9e..e58b1c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 9a7a664..1b54098 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/TASKS.md b/TASKS.md index 0ac6d00..b121312 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 | diff --git a/examples/gallery/pages/data/measures.rs b/examples/gallery/pages/data/measures.rs new file mode 100644 index 0000000..d208ddb --- /dev/null +++ b/examples/gallery/pages/data/measures.rs @@ -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"]), + ), + ) +} diff --git a/examples/gallery/pages/data/mod.rs b/examples/gallery/pages/data/mod.rs index 63b913c..8f0ab85 100644 --- a/examples/gallery/pages/data/mod.rs +++ b/examples/gallery/pages/data/mod.rs @@ -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() } diff --git a/src/data_display/codes.rs b/src/data_display/codes.rs new file mode 100644 index 0000000..5f9171b --- /dev/null +++ b/src/data_display/codes.rs @@ -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, + 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>, +} + +impl QrCode { + pub fn new(data: impl AsRef<[u8]>) -> Result { + let code = qrcode::QrCode::new(data)?; + let dark: Vec = 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, text: String) -> Self { + let dark: Vec = 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 { + 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 { + 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" + ); + } +} diff --git a/src/data_display/comparison.rs b/src/data_display/comparison.rs new file mode 100644 index 0000000..f1877c3 --- /dev/null +++ b/src/data_display/comparison.rs @@ -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, + featured: Option, + rows: Vec<(SharedString, Vec)>, +} + +impl Comparison { + pub fn new(columns: impl IntoIterator>) -> Self { + let columns: Vec = 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, + values: impl IntoIterator>, + ) -> 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, + values: impl IntoIterator, + ) -> Self { + let cells = values.into_iter().map(Cell::Mark).collect(); + self.push(name.into(), cells) + } + + fn push(mut self, name: SharedString, cells: Vec) -> 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, 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"]); + } +} diff --git a/src/data_display/measures.rs b/src/data_display/measures.rs new file mode 100644 index 0000000..7473d05 --- /dev/null +++ b/src/data_display/measures.rs @@ -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( + id: ElementId, + share: f32, + window: &mut Window, + cx: &mut App, + draw: D, +) -> impl IntoElement + use +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, + thresholds: (f32, f32), +} + +impl Meter { + pub fn new(id: impl Into, label: impl Into, 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) -> 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, + thresholds: (f32, f32), +} + +impl Gauge { + /// `value` lies within `min..=max`. + pub fn new( + id: impl Into, + label: impl Into, + 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) -> 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, 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); + } +} diff --git a/src/data_display/mod.rs b/src/data_display/mod.rs index eb14e83..507cc14 100644 --- a/src/data_display/mod.rs +++ b/src/data_display/mod.rs @@ -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; diff --git a/src/data_display/stars.rs b/src/data_display/stars.rs new file mode 100644 index 0000000..14a71de --- /dev/null +++ b/src/data_display/stars.rs @@ -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, fill: bool) -> Path { + 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 = (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); + } +} diff --git a/src/forms/rating.rs b/src/forms/rating.rs index 67982f7..9a8196e 100644 --- a/src/forms/rating.rs +++ b/src/forms/rating.rs @@ -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, fill: bool) -> Path { - 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, filled: bool, color: Hsla, window: &mut Window) { if filled { window.paint_path(star(bounds, true), color); diff --git a/src/motion/mod.rs b/src/motion/mod.rs index 341b082..c736fd3 100644 --- a/src/motion/mod.rs +++ b/src/motion/mod.rs @@ -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}; diff --git a/src/motion/progress.rs b/src/motion/progress.rs index 1f1d792..18f6d3b 100644 --- a/src/motion/progress.rs +++ b/src/motion/progress.rs @@ -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); diff --git a/src/motion/spinner.rs b/src/motion/spinner.rs index 5ad7b72..669563c 100644 --- a/src/motion/spinner.rs +++ b/src/motion/spinner.rs @@ -92,7 +92,7 @@ fn dot(center: Point, 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, radius: Pixels, from: f32, diff --git a/src/theme/palette.rs b/src/theme/palette.rs index c2c5279..eed482f 100644 --- a/src/theme/palette.rs +++ b/src/theme/palette.rs @@ -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, ]), diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index 1355940..bbdc6a5 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -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 { size(px_to_rems(200.0), px_to_rems(96.0)) diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index 12ebcf1..e655fc0 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -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`