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