125 lines
3.7 KiB
Rust
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);
|
|
}
|
|
}
|