Skip to main content

frus_widgets/
rating.rs

1//! [`Rating`]: a **controlled** rating in **clickable stars**.
2
3use 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
13/// One star, filled or empty, and clickable.
14struct Star<Msg> {
15    filled: bool,
16    /// The rating's availability, handed down. A star that still answered under a
17    /// disabled rating would be the whole control, a rating being only its stars.
18    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        // A disabled rating must still show its score. Flattening both to the same grey
38        // was the first attempt and the golden refused it: five identical stars say
39        // nothing, where the whole of what a rating carries is how many are lit. So the
40        // two halves of the rule do the work - a lit star is the **mark** at 38 %, an
41        // unlit one the **container** it sits in at 12 %.
42        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        // A slight lightening on hover, and none at all when there is nothing to press.
54        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            // A star is an icon drawn as a glyph: its size is the box beside it, not type.
64            &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
81/// A star rating: `value` out of `max`.
82pub 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    /// Creates a rating: `value` filled stars out of `max`. Clicking the i-th star
92    /// emits `on_rate(i + 1)`.
93    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    /// Whether the rating can be changed. Disabled it is **inert** - no star takes a
106    /// press or the focus - and it still shows the score, because read-only is not
107    /// invisible.
108    ///
109    /// See [`crate::disabled`] for the whole contract.
110    pub fn enabled(mut self, enabled: bool) -> Self {
111        self.enabled = enabled;
112        self.rebuild();
113        self
114    }
115
116    /// Rebuilds the stars, so that the order of the builders does not change what comes
117    /// out - the trap `RadioGroup` fell into in milestone 322.
118    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        // A rating said nothing to a reader before this: the score is the whole of what it
152        // carries, and it is still owed to someone who cannot change it.
153        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        // Clicking the fourth star, index 3, gives a rating of 4.
179        assert_eq!(stars[3].on_click(), Some(Msg::Rate(4)));
180    }
181
182    #[test]
183    fn filled_stars_match_value() {
184        // Full and empty stars are told apart by the painted color.
185        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); // three filled
207        assert_eq!(count_color(false), 2); // two empty
208    }
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        // Filled and empty alike go to the content grey: a star is a mark, and a pale
225        // accent would read as a quieter score rather than as an unavailable one.
226        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        // And the score is still legible: three lit stars, two not.
245        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        // Louder means further from the surface. Since milestone 329 both tokens are
256        // opaque — the flattening this rule is named for — so there is no alpha left.
257        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}