1use std::sync::Arc;
12
13use rosace_core::types::{Point, Rect, Size};
14use rosace_render::Color;
15
16use super::{LayoutCtx, PaintCtx, Widget};
17
18const DEFAULT_COUNT: u8 = 5;
20const DEFAULT_SIZE: f32 = 20.0;
22const DEFAULT_SPACING: f32 = 4.0;
24
25pub(crate) fn rating_at(local_x: f32, count: u8, star_size: f32, spacing: f32) -> f32 {
30 if count == 0 {
31 return 0.0;
32 }
33 let slot = (star_size + spacing).max(1.0);
34 let idx = (local_x / slot).floor().clamp(0.0, count as f32 - 1.0);
35 idx + 1.0
36}
37
38pub struct RatingBar {
40 value: f32,
42 count: u8,
43 size: f32,
44 spacing: f32,
45 disabled: bool,
46 color: Option<Color>,
47 empty_color: Option<Color>,
48 on_change: Option<Arc<dyn Fn(f32) + Send + Sync>>,
49}
50
51impl RatingBar {
52 pub fn new(value: f32) -> Self {
54 Self {
55 value: value.max(0.0),
56 count: DEFAULT_COUNT,
57 size: DEFAULT_SIZE,
58 spacing: DEFAULT_SPACING,
59 disabled: false,
60 color: None,
61 empty_color: None,
62 on_change: None,
63 }
64 }
65 pub fn disabled(mut self) -> Self { self.disabled = true; self }
66 pub fn count(mut self, n: u8) -> Self { self.count = n; self }
68 pub fn size(mut self, s: f32) -> Self { self.size = s.max(1.0); self }
70 pub fn spacing(mut self, s: f32) -> Self { self.spacing = s.max(0.0); self }
72 pub fn color(mut self, c: Color) -> Self { self.color = Some(c); self }
74 pub fn empty_color(mut self, c: Color) -> Self { self.empty_color = Some(c); self }
76 pub fn on_change(mut self, f: impl Fn(f32) + Send + Sync + 'static) -> Self {
79 self.on_change = Some(Arc::new(f));
80 self
81 }
82}
83
84impl Widget for RatingBar {
85 fn layout(&self, ctx: &LayoutCtx) -> Size {
86 let n = self.count as f32;
87 let w = n * self.size + (n - 1.0).max(0.0) * self.spacing;
88 ctx.constraints.constrain(Size { width: w, height: self.size })
89 }
90
91 fn paint(&self, ctx: &mut PaintCtx) {
92 let (filled, empty) = {
94 let t = &ctx.theme.colors;
95 let on_surface = ctx.tc(t.on_surface);
96 (
97 self.color.unwrap_or_else(|| ctx.tc(t.primary)),
98 self.empty_color.unwrap_or(Color::rgba(
99 on_surface.r, on_surface.g, on_surface.b, 70,
100 )),
101 )
102 };
103
104 let r = ctx.rect;
105 ctx.semantics(
106 super::Semantics::new(rosace_core::Role::Slider)
107 .label("rating")
108 .value(format!("{:.0} of {}", self.value.round(), self.count)),
109 );
110
111 match (&self.on_change, self.disabled) {
114 (Some(cb), false) => {
115 let cb = Arc::clone(cb);
116 let (left, count, size, spacing) =
117 (r.origin.x, self.count, self.size, self.spacing);
118 ctx.on_press_at(move |x, _y| cb(rating_at(x - left, count, size, spacing)));
119 }
120 _ => ctx.on_press_at(|_, _| {}),
121 }
122
123 let dim = if self.disabled { 0.4 } else { 1.0 };
124 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);
125 let lit = self.value.round().clamp(0.0, self.count as f32) as u8;
126 for i in 0..self.count {
127 let slot_x = r.origin.x + i as f32 * (self.size + self.spacing);
128 let star_rect = Rect {
129 origin: Point { x: slot_x, y: r.origin.y },
130 size: Size { width: self.size, height: self.size },
131 };
132 let mut child = ctx.child(star_rect);
133 let active = !self.disabled && (child.hovered() || child.pressed());
137 let base = if i < lit { filled } else { empty };
138 let tint = if active { super::lerp_color(base, filled, 0.6) } else { base };
139 let star_size = if !self.disabled && child.pressed() { self.size * 0.9 }
140 else if active { self.size * 1.08 } else { self.size };
141 let inset = (self.size - star_size) / 2.0;
142 let draw_rect = Rect {
143 origin: Point { x: slot_x + inset, y: r.origin.y + inset },
144 size: Size { width: star_size, height: star_size },
145 };
146 child.rect = draw_rect;
147 super::Icon::new(super::IconKind::Star)
148 .size(star_size)
149 .color(with_alpha(tint, dim))
150 .paint(&mut child);
151 }
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use rosace_layout::Constraints;
159
160 fn test_env() -> (rosace_render::FontCache, rosace_theme::ThemeData) {
161 (rosace_render::FontCache::embedded(), rosace_theme::built_in::dark_theme())
162 }
163
164 #[test]
165 fn width_is_count_times_size_plus_gaps() {
166 let bar = RatingBar::new(3.0).count(5).size(20.0).spacing(4.0);
167 let (font, theme) = test_env();
168 let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
169 let size = bar.layout(&ctx);
170 assert_eq!((size.width, size.height), (116.0, 20.0));
172 }
173
174 #[test]
175 fn single_star_bar_has_no_gap() {
176 let bar = RatingBar::new(1.0).count(1).size(24.0);
177 let (font, theme) = test_env();
178 let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
179 assert_eq!(bar.layout(&ctx).width, 24.0);
180 }
181
182 #[test]
183 fn tap_position_maps_to_the_star_under_it() {
184 assert_eq!(rating_at(0.0, 5, 20.0, 4.0), 1.0);
186 assert_eq!(rating_at(10.0, 5, 20.0, 4.0), 1.0);
187 assert_eq!(rating_at(25.0, 5, 20.0, 4.0), 2.0);
188 assert_eq!(rating_at(100.0, 5, 20.0, 4.0), 5.0);
189 }
190
191 #[test]
192 fn tap_mapping_clamps_outside_the_bar() {
193 assert_eq!(rating_at(-30.0, 5, 20.0, 4.0), 1.0);
194 assert_eq!(rating_at(10_000.0, 5, 20.0, 4.0), 5.0);
195 assert_eq!(rating_at(50.0, 0, 20.0, 4.0), 0.0);
196 }
197}