1use 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#[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 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 pub fn readonly(mut self, readonly: bool) -> Self {
80 self.readonly = readonly;
81 self
82 }
83
84 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
107fn 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}