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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 |
|
||||
@@ -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"]),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+4
-34
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
|
||||
Reference in new issue
Block a user