Files
Ely-GPUI-Components/src/data_display/stars.rs
T

125 lines
3.7 KiB
Rust

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