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

+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);
}
}