Skip to main content

rosace_widgets/tree/
rating_bar.rs

1//! `RatingBar` (D115/Phase 32 Step 1) — a row of stars showing a rating,
2//! tappable to set one (whole stars only; a tap rounds to the star under
3//! the pointer — half-star display is named follow-up work, not silently
4//! attempted).
5//!
6//! Stars render through [`super::Icon`]'s `Star` (same vector-icon pipeline
7//! as everything else): filled stars in `.color()`, the rest dimmed in
8//! `.empty_color()`. Read-only without `.on_change` — the tap region still
9//! absorbs (interactive-by-identity), it just changes nothing.
10
11use std::sync::Arc;
12
13use rosace_core::types::{Point, Rect, Size};
14use rosace_render::Color;
15
16use super::{LayoutCtx, PaintCtx, Widget};
17
18/// Default number of stars.
19const DEFAULT_COUNT: u8 = 5;
20/// Default star box size (logical px).
21const DEFAULT_SIZE: f32 = 20.0;
22/// Default gap between stars (logical px).
23const DEFAULT_SPACING: f32 = 4.0;
24
25/// Pure tap-position → rating mapping: which whole-star rating a press at
26/// `local_x` px (from the widget's left edge) selects. Each star owns its
27/// box plus the trailing gap; the result is always `1..=count` (`0.0` only
28/// for an empty bar).
29pub(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
38/// A star-rating display/input row.
39pub struct RatingBar {
40    /// Current rating in `0.0..=count` (rendered rounded to whole stars).
41    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    /// A rating bar showing `value` stars (of [`RatingBar::count`], default 5).
53    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    /// Number of stars (default `5`).
67    pub fn count(mut self, n: u8) -> Self { self.count = n; self }
68    /// Star box size in logical px (default `20.0`).
69    pub fn size(mut self, s: f32) -> Self { self.size = s.max(1.0); self }
70    /// Gap between stars in logical px (default `4.0`).
71    pub fn spacing(mut self, s: f32) -> Self { self.spacing = s.max(0.0); self }
72    /// Filled-star tint — defaults to the theme's `primary`.
73    pub fn color(mut self, c: Color) -> Self { self.color = Some(c); self }
74    /// Empty-star tint — defaults to the theme's `on_surface`, dimmed.
75    pub fn empty_color(mut self, c: Color) -> Self { self.empty_color = Some(c); self }
76    /// Called with the new rating (`1.0..=count`, whole stars) on tap/drag.
77    /// Without it the bar is read-only (taps absorb, nothing changes).
78    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        // Hoisted theme reads (the borrow must end before mutable painting).
93        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        // Tap/drag sets the rating — always registered
112        // (interactive-by-identity): read-only bars absorb the press.
113        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            // Per-star hover/press micro-interaction: the star under the
134            // pointer brightens and grows slightly (preview-up-to-hovered is a
135            // named follow-up — the paint pass has no pointer coordinate).
136            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        // 5 × 20 + 4 × 4 gaps = 116.
171        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        // count 5, size 20, spacing 4 → 24px slots.
185        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}