Skip to main content

guise/input/
rating.rs

1//! `Rating` — a row of clickable stars (controlled).
2//!
3//! The parent owns the value (an `f32` rendered as whole stars) and passes a
4//! change handler, or two-way binds it with [`Rating::bind`]. Clicking star
5//! `i` sets the value to `i`; hovering an unfilled star previews it in the
6//! accent color. `readonly` renders a static display.
7//!
8//! ```ignore
9//! Rating::new("stars")
10//!     .value(self.stars)
11//!     .color(ColorName::Yellow)
12//!     .on_change(cx.listener(|this, value: &f32, _w, cx| {
13//!         this.stars = *value;
14//!         cx.notify();
15//!     }))
16//! ```
17
18use std::rc::Rc;
19
20use gpui::prelude::*;
21use gpui::{div, px, App, ElementId, IntoElement, SharedString, Window};
22
23use crate::reactive::Binding;
24use crate::style::ColorValue;
25use crate::theme::{theme, ColorName, Size};
26
27type ChangeHandler = Rc<dyn Fn(&f32, &mut Window, &mut App) + 'static>;
28
29/// A star rating. The Mantine `Rating`. Controlled: pass `value` and an
30/// `on_change`, or two-way bind with [`Rating::bind`].
31#[derive(IntoElement)]
32pub struct Rating {
33    id: ElementId,
34    value: f32,
35    count: usize,
36    color: ColorValue,
37    size: Size,
38    readonly: bool,
39    binding: Option<Binding<f32>>,
40    on_change: Option<ChangeHandler>,
41}
42
43impl Rating {
44    pub fn new(id: impl Into<ElementId>) -> Self {
45        Rating {
46            id: id.into(),
47            value: 0.0,
48            count: 5,
49            color: ColorValue::Named(ColorName::Yellow),
50            size: Size::Md,
51            readonly: false,
52            binding: None,
53            on_change: None,
54        }
55    }
56
57    pub fn value(mut self, value: f32) -> Self {
58        self.value = value;
59        self
60    }
61
62    /// How many stars to draw (default 5).
63    pub fn count(mut self, count: usize) -> Self {
64        self.count = count.max(1);
65        self
66    }
67
68    pub fn color(mut self, color: impl Into<ColorValue>) -> Self {
69        self.color = color.into();
70        self
71    }
72
73    pub fn size(mut self, size: Size) -> Self {
74        self.size = size;
75        self
76    }
77
78    /// Display-only: no hover preview, no clicks.
79    pub fn readonly(mut self, readonly: bool) -> Self {
80        self.readonly = readonly;
81        self
82    }
83
84    /// Two-way bind the value. Overrides `value`; clicks write the new rating
85    /// back through the binding, then run any `on_change`.
86    pub fn bind(mut self, binding: Binding<f32>) -> Self {
87        self.binding = Some(binding);
88        self
89    }
90
91    pub fn on_change(mut self, handler: impl Fn(&f32, &mut Window, &mut App) + 'static) -> Self {
92        self.on_change = Some(Rc::new(handler));
93        self
94    }
95
96    fn glyph_px(&self) -> f32 {
97        match self.size {
98            Size::Xs => 14.0,
99            Size::Sm => 18.0,
100            Size::Md => 22.0,
101            Size::Lg => 28.0,
102            Size::Xl => 36.0,
103        }
104    }
105}
106
107/// How many leading stars read as filled for `value` (rounded, clamped).
108fn filled_stars(value: f32, count: usize) -> usize {
109    if value <= 0.0 {
110        0
111    } else {
112        (value.round() as usize).min(count)
113    }
114}
115
116impl RenderOnce for Rating {
117    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
118        let t = theme(cx);
119        let accent = self.color.accent(t);
120        let empty = if t.scheme.is_dark() {
121            t.color(ColorName::Dark, 3)
122        } else {
123            t.color(ColorName::Gray, 4)
124        }
125        .hsla();
126        let glyph = self.glyph_px();
127
128        let value = self.binding.as_ref().map_or(self.value, |b| b.get(cx));
129        let filled = filled_stars(value, self.count);
130
131        let mut row = div()
132            .id(self.id)
133            .flex()
134            .flex_row()
135            .items_center()
136            .gap(px(2.0));
137
138        for i in 1..=self.count {
139            let is_filled = i <= filled;
140            let mut star = div()
141                .id(("guise-rating-star", i))
142                .text_size(px(glyph))
143                .text_color(if is_filled { accent } else { empty })
144                .child(SharedString::new_static(if is_filled {
145                    "\u{2605}"
146                } else {
147                    "\u{2606}"
148                }));
149            if !self.readonly {
150                star = star.cursor_pointer().hover(move |s| s.text_color(accent));
151                let binding = self.binding.clone();
152                let handler = self.on_change.clone();
153                let next = i as f32;
154                star = star.on_click(move |_ev, window, cx| {
155                    if let Some(binding) = &binding {
156                        binding.set(cx, next);
157                    }
158                    if let Some(handler) = &handler {
159                        handler(&next, window, cx);
160                    }
161                });
162            }
163            row = row.child(star);
164        }
165        row
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::filled_stars;
172
173    #[test]
174    fn rounds_to_the_nearest_star() {
175        assert_eq!(filled_stars(2.4, 5), 2);
176        assert_eq!(filled_stars(2.5, 5), 3);
177        assert_eq!(filled_stars(3.0, 5), 3);
178    }
179
180    #[test]
181    fn clamps_into_the_star_range() {
182        assert_eq!(filled_stars(0.0, 5), 0);
183        assert_eq!(filled_stars(-1.0, 5), 0);
184        assert_eq!(filled_stars(9.0, 5), 5);
185    }
186}