1use frus_core::{Point, Rect, ResolvedTextStyle, Scene};
4use frus_layout::{Dimension, FlexDirection, Style};
5
6use crate::disabled::{disabled_container, disabled_content};
7use crate::interaction::Status;
8use crate::theme::Theme;
9use crate::widget::Widget;
10
11const STAR: f32 = 24.0;
12
13struct Star<Msg> {
15 filled: bool,
16 enabled: bool,
19 message: Msg,
20}
21
22impl<Msg: Clone> Widget<Msg> for Star<Msg> {
23 fn style(&self) -> Style {
24 Style {
25 width: Dimension::Length(STAR),
26 height: Dimension::Length(STAR),
27 ..Default::default()
28 }
29 }
30
31 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
32 &[]
33 }
34
35 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
36 let o = status.opacity;
37 let base = if !self.enabled {
43 if self.filled {
44 disabled_content(theme)
45 } else {
46 disabled_container(theme)
47 }
48 } else if self.filled {
49 theme.primary
50 } else {
51 theme.muted.fade(0.45)
52 };
53 let hover = if self.enabled {
55 status.hover_progress
56 } else {
57 0.0
58 };
59 let color = base.lerp(theme.on_surface, 0.2 * hover);
60 scene.text(
61 Point::new(bounds.x, bounds.y - 2.0),
62 "★".to_string(),
63 &ResolvedTextStyle::exact(STAR),
65 color.fade(o),
66 );
67 }
68
69 fn on_click(&self) -> Option<Msg> {
70 if !self.enabled {
71 return None;
72 }
73 Some(self.message.clone())
74 }
75
76 fn focusable(&self) -> bool {
77 self.enabled
78 }
79}
80
81pub struct Rating<Msg = crate::callback::Callback> {
83 value: u32,
84 max: u32,
85 enabled: bool,
86 on_rate: Box<dyn Fn(u32) -> Msg>,
87 children: Vec<Box<dyn Widget<Msg>>>,
88}
89
90impl<Msg: Clone + 'static> Rating<Msg> {
91 pub fn new(value: u32, max: u32, on_rate: impl Fn(u32) -> Msg + 'static) -> Self {
94 let mut rating = Self {
95 value,
96 max,
97 enabled: true,
98 on_rate: Box::new(on_rate),
99 children: Vec::new(),
100 };
101 rating.rebuild();
102 rating
103 }
104
105 pub fn enabled(mut self, enabled: bool) -> Self {
111 self.enabled = enabled;
112 self.rebuild();
113 self
114 }
115
116 fn rebuild(&mut self) {
119 self.children = (0..self.max)
120 .map(|i| {
121 Box::new(Star {
122 filled: i < self.value,
123 enabled: self.enabled,
124 message: (self.on_rate)(i + 1),
125 }) as Box<dyn Widget<Msg>>
126 })
127 .collect();
128 }
129}
130
131impl<Msg: Clone> Widget<Msg> for Rating<Msg> {
132 fn style(&self) -> Style {
133 Style {
134 flex_direction: FlexDirection::Row,
135 gap: 4.0,
136 ..Default::default()
137 }
138 }
139
140 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
141 &self.children
142 }
143
144 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
145
146 fn on_click(&self) -> Option<Msg> {
147 None
148 }
149
150 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
151 let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Slider)
154 .value(format!("{} / {}", self.value, self.max))
155 .range(0.0, self.value as f32, self.max as f32);
156 Some(if self.enabled {
157 semantics
158 } else {
159 semantics.disabled(true)
160 })
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[derive(Clone, Debug, PartialEq)]
169 enum Msg {
170 Rate(u32),
171 }
172
173 #[test]
174 fn has_max_stars_and_click_emits_rank() {
175 let rating = Rating::new(2, 5, Msg::Rate);
176 let stars = Widget::<Msg>::children(&rating);
177 assert_eq!(stars.len(), 5);
178 assert_eq!(stars[3].on_click(), Some(Msg::Rate(4)));
180 }
181
182 #[test]
183 fn filled_stars_match_value() {
184 let rating = Rating::new(3, 5, Msg::Rate);
186 let count_color = |filled_expected: bool| {
187 let theme = Theme::default();
188 let target = if filled_expected {
189 theme.primary
190 } else {
191 theme.muted.fade(0.45)
192 };
193 (0..5)
194 .filter(|&i| {
195 let mut scene = Scene::new();
196 Widget::<Msg>::children(&rating)[i].paint(
197 Rect::new(0.0, 0.0, STAR, STAR),
198 Status::default(),
199 &theme,
200 &mut scene,
201 );
202 matches!(scene.primitives()[0], frus_core::Primitive::Text { color, .. } if color == target)
203 })
204 .count()
205 };
206 assert_eq!(count_color(true), 3); assert_eq!(count_color(false), 2); }
209
210 #[test]
211 fn a_disabled_rating_is_inert_but_still_shows_the_score() {
212 let dead = Rating::new(3, 5, Msg::Rate).enabled(false);
213 for (i, star) in Widget::<Msg>::children(&dead).iter().enumerate() {
214 assert_eq!(star.on_click(), None, "star {i} still answers");
215 assert!(!star.focusable(), "star {i} is still in the tab order");
216 }
217 let semantics = Widget::<Msg>::semantics(&dead).expect("still announced");
218 assert!(semantics.disabled, "announced as unavailable");
219 assert_eq!(semantics.value.as_deref(), Some("3 / 5"), "score survives");
220 }
221
222 #[test]
223 fn a_disabled_rating_flattens_rather_than_fading_the_accent() {
224 let theme = Theme::default();
227 let dead = Rating::new(3, 5, Msg::Rate).enabled(false);
228 let lit = |i: usize| {
229 let mut scene = Scene::new();
230 Widget::<Msg>::children(&dead)[i].paint(
231 Rect::new(0.0, 0.0, STAR, STAR),
232 Status::default(),
233 &theme,
234 &mut scene,
235 );
236 match scene.primitives()[0] {
237 frus_core::Primitive::Text { color, .. } => color,
238 _ => panic!("a star is text"),
239 }
240 };
241 for i in 0..5 {
242 assert_ne!(lit(i), theme.primary, "star {i} keeps the accent");
243 }
244 for i in 0..3 {
246 assert_eq!(lit(i), disabled_content(&theme), "star {i} should be lit");
247 }
248 for i in 3..5 {
249 assert_eq!(
250 lit(i),
251 disabled_container(&theme),
252 "star {i} should be dark"
253 );
254 }
255 let from_surface = |c: frus_core::Color| {
258 (c.r - theme.scheme.surface.r).abs()
259 + (c.g - theme.scheme.surface.g).abs()
260 + (c.b - theme.scheme.surface.b).abs()
261 };
262 assert!(
263 from_surface(lit(0)) > from_surface(lit(4)),
264 "and a lit star reads louder than an unlit one"
265 );
266 }
267}