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