Skip to main content

frus_widgets/
icon.rs

1//! [`Icon`]: displays a vector icon from the bundled set ([`Icons`]), scaled and
2//! coloured to the theme. It is the first consumer of the vector paths
3//! ([`frus_core::Path`]) on the widget side.
4
5use frus_core::{Color, Rect, Scene};
6use frus_layout::{Dimension, Style};
7
8use crate::icons::{AnimatedIconData, IconData, GRID};
9use crate::interaction::Status;
10use crate::theme::Theme;
11use crate::widget::Widget;
12
13/// A vector icon. Size and colour can both be customised; they default to `24` px
14/// and the theme's foreground colour (`on_surface`).
15pub struct Icon {
16    icon: IconData,
17    /// A pair being crossed, and the end it is heading for. `None` for the ordinary
18    /// case: an icon that is one mark and stays it.
19    morph: Option<(AnimatedIconData, f32)>,
20    /// `None` = whatever the theme says, else the 24 px grid the paths are drawn on.
21    size: Option<f32>,
22    color: Option<Color>,
23}
24
25impl Icon {
26    /// An icon in the theme's colour and at the theme's size — 24 px unless a theme
27    /// says otherwise.
28    pub fn new(icon: IconData) -> Self {
29        Self {
30            icon,
31            morph: None,
32            size: None,
33            color: None,
34        }
35    }
36
37    /// **An icon that crosses between two marks**, at the end `on` names: `false` is the
38    /// pair's `0.0` and `true` its `1.0`.
39    ///
40    /// The way across is not this widget's to time. It declares the end it wants and the
41    /// runtime drives the value there — the same machinery a switch's knob and a drawer's
42    /// slide are on, with the same duration and the same curve — so an icon and the panel
43    /// it opens move together because they are being driven by the same rule, not because
44    /// two timers were set to the same number.
45    ///
46    /// ```
47    /// use frus_widgets::{AnimatedIcons, Icon};
48    ///
49    /// let open = true;
50    /// let _button_mark = Icon::animated(AnimatedIcons::MENU_CLOSE, open);
51    /// ```
52    pub fn animated(icon: AnimatedIconData, on: bool) -> Self {
53        let target = if on { 1.0 } else { 0.0 };
54        Self {
55            // The end of the animation, which is what an isolated frame — a test, a
56            // golden, the first frame after a mount — should draw.
57            icon: icon.at(target),
58            morph: Some((icon, target)),
59            size: None,
60            color: None,
61        }
62    }
63
64    /// Sets the size, that is, the square's side, in logical pixels. Outranks the theme.
65    pub fn size(mut self, size: f32) -> Self {
66        self.size = Some(size);
67        self
68    }
69
70    /// Forces the colour; the theme's otherwise, and `on_surface` if it says nothing.
71    pub fn color(mut self, color: Color) -> Self {
72        self.color = Some(color);
73        self
74    }
75
76    /// The box a given side asks for.
77    fn sized(&self, side: f32) -> Style {
78        Style {
79            width: Dimension::Length(side),
80            height: Dimension::Length(side),
81            ..Default::default()
82        }
83    }
84
85    /// The side actually drawn: `caller ?? theme ?? the grid`.
86    fn resolved_size(&self, theme: Option<&Theme>) -> f32 {
87        self.size
88            .or_else(|| theme.and_then(|t| t.widgets.icon.size))
89            .unwrap_or(GRID)
90    }
91}
92
93impl<Msg> Widget<Msg> for Icon {
94    fn style(&self) -> Style {
95        self.sized(self.resolved_size(None))
96    }
97
98    /// The theme has a say in the **size**, not only the colour, so an app bar can make
99    /// its glyphs smaller and have them take less room rather than the same room with a
100    /// smaller drawing in it.
101    fn style_themed(&self, theme: &Theme) -> Style {
102        self.sized(self.resolved_size(Some(theme)))
103    }
104
105    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
106        &[]
107    }
108
109    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
110        let color = self
111            .color
112            .or(theme.widgets.icon.color)
113            .unwrap_or(theme.on_surface)
114            .fade(status.opacity);
115        let size = self.resolved_size(Some(theme));
116        // The 24×24 grid scaled to the real size, centred in the box — and turned
117        // round, if the icon carries a direction and the reading order is right to left.
118        let ox = bounds.x + (bounds.width - size) * 0.5;
119        let oy = bounds.y + (bounds.height - size) * 0.5;
120        // Where the animation has got to, or the one mark this icon is.
121        let drawn = match self.morph {
122            Some((pair, _)) => pair.at(status.value),
123            None => self.icon,
124        };
125        let path = drawn.placed(size, ox, oy, theme.direction);
126        scene.fill_path(&path, color);
127    }
128
129    fn on_click(&self) -> Option<Msg> {
130        None
131    }
132
133    /// Only a morphing icon has an animated value. A plain one answers `None`, so nothing
134    /// that was here before this existed acquired an animation.
135    fn anim_target(&self) -> Option<f32> {
136        self.morph.map(|(_, target)| target)
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::icons::Icons;
144    use frus_core::Primitive;
145
146    fn paint_icon(icon: Icon) -> Vec<Primitive> {
147        let mut scene = Scene::new();
148        Widget::<()>::paint(
149            &icon,
150            Rect::new(0.0, 0.0, 24.0, 24.0),
151            Status::default(),
152            &Theme::default(),
153            &mut scene,
154        );
155        scene.primitives().to_vec()
156    }
157
158    #[test]
159    fn paints_a_single_filled_path() {
160        let prims = paint_icon(Icon::new(Icons::STAR));
161        assert_eq!(prims.len(), 1);
162        assert!(matches!(
163            prims[0],
164            Primitive::Path {
165                fill: Some(_),
166                stroke: None,
167                ..
168            }
169        ));
170    }
171
172    #[test]
173    fn color_override_beats_theme() {
174        let prims = paint_icon(Icon::new(Icons::FAVORITE).color(Color::rgb(1.0, 0.0, 0.0)));
175        match &prims[0] {
176            Primitive::Path { fill: Some(c), .. } => {
177                assert_eq!(c.r, 1.0);
178                assert_eq!(c.g, 0.0);
179            }
180            _ => panic!("expected a filled path"),
181        }
182    }
183
184    /// **The drawing follows the animated value, not the flag.** A morphing icon painted
185    /// at half its animation is neither of its ends — which is the whole difference
186    /// between this and swapping one icon for another.
187    #[test]
188    fn a_morphing_icon_is_painted_where_the_animation_has_got_to() {
189        let paint_at = |value: f32| {
190            let mut scene = Scene::new();
191            let status = Status {
192                value,
193                ..Status::default()
194            };
195            Widget::<()>::paint(
196                &Icon::animated(crate::AnimatedIcons::MENU_CLOSE, true),
197                Rect::new(0.0, 0.0, 24.0, 24.0),
198                status,
199                &Theme::default(),
200                &mut scene,
201            );
202            match scene.primitives().first() {
203                Some(Primitive::Path { path, .. }) => path.clone(),
204                other => panic!("expected a filled path, got {other:?}"),
205            }
206        };
207        let (shut, half, open) = (paint_at(0.0), paint_at(0.5), paint_at(1.0));
208        assert_ne!(shut.verbs(), half.verbs(), "half way is not the start");
209        assert_ne!(half.verbs(), open.verbs(), "and it is not the end either");
210        assert_eq!(
211            shut.verbs().len(),
212            half.verbs().len(),
213            "the same shape all the way across"
214        );
215    }
216
217    /// The end it is heading for is what the runtime is told, and what an isolated frame
218    /// draws. A plain icon says nothing, so nothing that existed before morphs did
219    /// acquired an animation.
220    #[test]
221    fn only_a_morphing_icon_asks_the_runtime_for_a_value() {
222        let open = Icon::animated(crate::AnimatedIcons::MENU_CLOSE, true);
223        let shut = Icon::animated(crate::AnimatedIcons::MENU_CLOSE, false);
224        assert_eq!(Widget::<()>::anim_target(&open), Some(1.0));
225        assert_eq!(Widget::<()>::anim_target(&shut), Some(0.0));
226        assert_eq!(Widget::<()>::anim_target(&Icon::new(Icons::STAR)), None);
227    }
228
229    #[test]
230    fn size_drives_the_layout_box() {
231        let icon = Icon::new(Icons::CHECK).size(40.0);
232        let style = Widget::<()>::style(&icon);
233        assert_eq!(style.width, Dimension::Length(40.0));
234        assert_eq!(style.height, Dimension::Length(40.0));
235    }
236}