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}