use std::sync::Arc;
use rosace_core::types::{Point, Rect, Size};
use rosace_render::Color;
use super::{LayoutCtx, PaintCtx, Widget};
const DEFAULT_COUNT: u8 = 5;
const DEFAULT_SIZE: f32 = 20.0;
const DEFAULT_SPACING: f32 = 4.0;
pub(crate) fn rating_at(local_x: f32, count: u8, star_size: f32, spacing: f32) -> f32 {
if count == 0 {
return 0.0;
}
let slot = (star_size + spacing).max(1.0);
let idx = (local_x / slot).floor().clamp(0.0, count as f32 - 1.0);
idx + 1.0
}
pub struct RatingBar {
value: f32,
count: u8,
size: f32,
spacing: f32,
disabled: bool,
color: Option<Color>,
empty_color: Option<Color>,
on_change: Option<Arc<dyn Fn(f32) + Send + Sync>>,
}
impl RatingBar {
pub fn new(value: f32) -> Self {
Self {
value: value.max(0.0),
count: DEFAULT_COUNT,
size: DEFAULT_SIZE,
spacing: DEFAULT_SPACING,
disabled: false,
color: None,
empty_color: None,
on_change: None,
}
}
pub fn disabled(mut self) -> Self { self.disabled = true; self }
pub fn count(mut self, n: u8) -> Self { self.count = n; self }
pub fn size(mut self, s: f32) -> Self { self.size = s.max(1.0); self }
pub fn spacing(mut self, s: f32) -> Self { self.spacing = s.max(0.0); self }
pub fn color(mut self, c: Color) -> Self { self.color = Some(c); self }
pub fn empty_color(mut self, c: Color) -> Self { self.empty_color = Some(c); self }
pub fn on_change(mut self, f: impl Fn(f32) + Send + Sync + 'static) -> Self {
self.on_change = Some(Arc::new(f));
self
}
}
impl Widget for RatingBar {
fn layout(&self, ctx: &LayoutCtx) -> Size {
let n = self.count as f32;
let w = n * self.size + (n - 1.0).max(0.0) * self.spacing;
ctx.constraints.constrain(Size { width: w, height: self.size })
}
fn paint(&self, ctx: &mut PaintCtx) {
let (filled, empty) = {
let t = &ctx.theme.colors;
let on_surface = ctx.tc(t.on_surface);
(
self.color.unwrap_or_else(|| ctx.tc(t.primary)),
self.empty_color.unwrap_or(Color::rgba(
on_surface.r, on_surface.g, on_surface.b, 70,
)),
)
};
let r = ctx.rect;
ctx.semantics(
super::Semantics::new(rosace_core::Role::Slider)
.label("rating")
.value(format!("{:.0} of {}", self.value.round(), self.count)),
);
match (&self.on_change, self.disabled) {
(Some(cb), false) => {
let cb = Arc::clone(cb);
let (left, count, size, spacing) =
(r.origin.x, self.count, self.size, self.spacing);
ctx.on_press_at(move |x, _y| cb(rating_at(x - left, count, size, spacing)));
}
_ => ctx.on_press_at(|_, _| {}),
}
let dim = if self.disabled { 0.4 } else { 1.0 };
let with_alpha = |c: Color, a: f32| Color::rgba(c.r, c.g, c.b, ((c.a as f32 / 255.0) * a.clamp(0.0, 1.0) * 255.0).round() as u8);
let lit = self.value.round().clamp(0.0, self.count as f32) as u8;
for i in 0..self.count {
let slot_x = r.origin.x + i as f32 * (self.size + self.spacing);
let star_rect = Rect {
origin: Point { x: slot_x, y: r.origin.y },
size: Size { width: self.size, height: self.size },
};
let mut child = ctx.child(star_rect);
let active = !self.disabled && (child.hovered() || child.pressed());
let base = if i < lit { filled } else { empty };
let tint = if active { super::lerp_color(base, filled, 0.6) } else { base };
let star_size = if !self.disabled && child.pressed() { self.size * 0.9 }
else if active { self.size * 1.08 } else { self.size };
let inset = (self.size - star_size) / 2.0;
let draw_rect = Rect {
origin: Point { x: slot_x + inset, y: r.origin.y + inset },
size: Size { width: star_size, height: star_size },
};
child.rect = draw_rect;
super::Icon::new(super::IconKind::Star)
.size(star_size)
.color(with_alpha(tint, dim))
.paint(&mut child);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rosace_layout::Constraints;
fn test_env() -> (rosace_render::FontCache, rosace_theme::ThemeData) {
(rosace_render::FontCache::embedded(), rosace_theme::built_in::dark_theme())
}
#[test]
fn width_is_count_times_size_plus_gaps() {
let bar = RatingBar::new(3.0).count(5).size(20.0).spacing(4.0);
let (font, theme) = test_env();
let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
let size = bar.layout(&ctx);
assert_eq!((size.width, size.height), (116.0, 20.0));
}
#[test]
fn single_star_bar_has_no_gap() {
let bar = RatingBar::new(1.0).count(1).size(24.0);
let (font, theme) = test_env();
let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
assert_eq!(bar.layout(&ctx).width, 24.0);
}
#[test]
fn tap_position_maps_to_the_star_under_it() {
assert_eq!(rating_at(0.0, 5, 20.0, 4.0), 1.0);
assert_eq!(rating_at(10.0, 5, 20.0, 4.0), 1.0);
assert_eq!(rating_at(25.0, 5, 20.0, 4.0), 2.0);
assert_eq!(rating_at(100.0, 5, 20.0, 4.0), 5.0);
}
#[test]
fn tap_mapping_clamps_outside_the_bar() {
assert_eq!(rating_at(-30.0, 5, 20.0, 4.0), 1.0);
assert_eq!(rating_at(10_000.0, 5, 20.0, 4.0), 5.0);
assert_eq!(rating_at(50.0, 0, 20.0, 4.0), 0.0);
}
}