Skip to main content

frus_widgets/icons/
animated.rs

1//! [`AnimatedIconData`] and [`AnimatedIcons`]: the marks that **turn into one another**
2//! rather than being swapped for one another.
3//!
4//! A hamburger that becomes a cross as a drawer opens, a play that becomes a pause, a
5//! plus that becomes a cross, a chevron that turns over. Each of these is one drawing
6//! with a parameter, not two drawings with a cut between them, and the difference is
7//! visible: a swap tells the reader that something replaced something, and a morph tells
8//! them that the same control changed its mind.
9//!
10//! # Why the pairs are hand-drawn
11//!
12//! Interpolating between two arbitrary outlines is a general path-morphing problem —
13//! matching sub-paths, matching point counts, matching winding — and it has no good
14//! answer for two drawings that were never made to correspond. Three bars and a cross do
15//! not have the same number of anything.
16//!
17//! So a pair is **authored as a function of `t`**, exactly the way
18//! [`IconData::custom`](crate::IconData::custom) lets an application author a static one.
19//! The bundled pairs are hand-drawn on the same `24 × 24` grid, close to the marks of the
20//! same name in the bundled set but not the same outlines: they are drawn to move well,
21//! which is a different constraint from being drawn to sit still.
22//!
23//! ```
24//! use frus_widgets::{AnimatedIcons, Icon};
25//!
26//! // The mark at rest, at the halfway point, and at the end.
27//! let shut = AnimatedIcons::MENU_CLOSE.at(0.0);
28//! let open = AnimatedIcons::MENU_CLOSE.at(1.0);
29//! assert_ne!(shut.path().verbs(), open.path().verbs());
30//! let _widget = Icon::animated(AnimatedIcons::MENU_CLOSE, true);
31//! ```
32
33use frus_core::{Path, Point};
34
35use super::IconData;
36
37/// A mark that is **drawn from a number**: `0.0` is one state, `1.0` is the other, and
38/// everything between is the way across.
39///
40/// It is `Copy` and costs nothing until [`AnimatedIconData::at`] is called. What comes
41/// back from `at` is an ordinary [`IconData`], so **every widget that already takes an
42/// icon takes a morphing one** — an [`Icon`](crate::Icon), an
43/// [`IconButton`](crate::IconButton), a floating action button, a chip, a navigation
44/// destination — with nothing added to any of them.
45#[derive(Clone, Copy, Debug)]
46pub struct AnimatedIconData {
47    draw: fn(f32) -> Path,
48    directional: bool,
49}
50
51impl PartialEq for AnimatedIconData {
52    fn eq(&self, other: &Self) -> bool {
53        std::ptr::fn_addr_eq(self.draw, other.draw) && self.directional == other.directional
54    }
55}
56
57impl Eq for AnimatedIconData {}
58
59impl std::hash::Hash for AnimatedIconData {
60    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
61        ((self.draw as usize), self.directional).hash(state);
62    }
63}
64
65impl AnimatedIconData {
66    /// A pair of the caller's own: `draw` is handed a `t` in `0.0..=1.0` and returns the
67    /// outline for it, on the `24 × 24` grid, y downwards, filled by the non-zero rule.
68    ///
69    /// A function pointer rather than a closure, so a pair stays a `const` and is
70    /// declared exactly where a bundled one would be.
71    ///
72    /// ```
73    /// use frus_core::{Path, Rect};
74    /// use frus_widgets::AnimatedIconData;
75    ///
76    /// /// A bar that grows from nothing.
77    /// fn grow(t: f32) -> Path {
78    ///     Path::rect(Rect::new(12.0 - 9.0 * t, 11.0, 18.0 * t, 2.0))
79    /// }
80    /// const GROW: AnimatedIconData = AnimatedIconData::custom(grow);
81    /// assert!(GROW.at(0.0).path().verbs().len() == GROW.at(1.0).path().verbs().len());
82    /// ```
83    pub const fn custom(draw: fn(f32) -> Path) -> Self {
84        Self {
85            draw,
86            directional: false,
87        }
88    }
89
90    /// The same pair, declared to **carry a direction** — turned round in a right-to-left
91    /// reading order, like [`IconData::mirrored`](crate::IconData::mirrored). The whole
92    /// animation is mirrored, at every `t`, because a mark that points somewhere points
93    /// there all the way across.
94    pub const fn mirrored(self) -> Self {
95        Self {
96            draw: self.draw,
97            directional: true,
98        }
99    }
100
101    /// Whether this pair is turned round for a right-to-left reading order.
102    pub const fn matches_text_direction(self) -> bool {
103        self.directional
104    }
105
106    /// **The mark at `t`**, as an ordinary icon.
107    ///
108    /// `t` is clamped to `0.0..=1.0`, and a `t` that is not a number is read as `0.0`:
109    /// an animation driver that divides by a zero duration must not be able to make an
110    /// icon that compares unequal to itself.
111    pub fn at(self, t: f32) -> IconData {
112        let t = if t.is_finite() {
113            t.clamp(0.0, 1.0)
114        } else {
115            0.0
116        };
117        IconData::morphing(self.draw, t, self.directional)
118    }
119}
120
121// ---------------------------------------------------------------------------
122// Drawing helpers. Everything below is authored on the 24 × 24 grid, y down.
123// ---------------------------------------------------------------------------
124
125/// The middle of the grid, which every one of these turns about.
126const MID: f32 = 12.0;
127
128fn lerp(a: f32, b: f32, t: f32) -> f32 {
129    a + (b - a) * t
130}
131
132/// Adds a `len × thick` bar centred on `(cx, cy)` and turned by `angle` (clockwise, since
133/// y points down) as one closed contour.
134///
135/// A bar rather than a rectangle because that is what these drawings are made of, and a
136/// turned rectangle is not a rectangle: the corners have to be worked out, and working
137/// them out here is what keeps every pair below to a list of bars.
138fn bar(path: Path, cx: f32, cy: f32, len: f32, thick: f32, angle: f32) -> Path {
139    let (sin, cos) = angle.sin_cos();
140    let (hl, ht) = (len * 0.5, thick * 0.5);
141    // The half-length and half-thickness vectors, turned.
142    let (lx, ly) = (hl * cos, hl * sin);
143    let (tx, ty) = (-ht * sin, ht * cos);
144    path.move_to(Point::new(cx - lx - tx, cy - ly - ty))
145        .line_to(Point::new(cx + lx - tx, cy + ly - ty))
146        .line_to(Point::new(cx + lx + tx, cy + ly + ty))
147        .line_to(Point::new(cx - lx + tx, cy - ly + ty))
148        .close()
149}
150
151/// Adds a quadrilateral as one closed contour, corners in the order they were given.
152fn quad(path: Path, corners: [Point; 4]) -> Path {
153    path.move_to(corners[0])
154        .line_to(corners[1])
155        .line_to(corners[2])
156        .line_to(corners[3])
157        .close()
158}
159
160/// The straight line between two quadrilaterals, corner by corner. The corners have to be
161/// listed in the same order in both, or the shape turns itself inside out on the way.
162fn quad_between(path: Path, from: [Point; 4], to: [Point; 4], t: f32) -> Path {
163    let mut corners = from;
164    for (c, (a, b)) in corners.iter_mut().zip(from.iter().zip(to.iter())) {
165        *c = Point::new(lerp(a.x, b.x, t), lerp(a.y, b.y, t));
166    }
167    quad(path, corners)
168}
169
170/// A quarter turn, in radians.
171const QUARTER: f32 = std::f32::consts::FRAC_PI_2;
172
173// ---------------------------------------------------------------------------
174// The pairs.
175// ---------------------------------------------------------------------------
176
177/// Three bars becoming a cross: the top one swings down to the right, the bottom one up
178/// to the right, and the middle one **shortens to nothing** rather than fading, because
179/// an icon is one filled path and half of it cannot be given its own opacity.
180fn menu_close(t: f32) -> Path {
181    const LEN: f32 = 18.0;
182    const THICK: f32 = 2.0;
183    let path = Path::new();
184    let path = bar(
185        path,
186        MID,
187        lerp(7.0, MID, t),
188        lerp(LEN, 19.8, t),
189        THICK,
190        lerp(0.0, QUARTER * 0.5, t),
191    );
192    // The middle bar keeps its centre and loses its **length**, down to none at all. It
193    // is still drawn at t = 1, as a contour of zero area that fills nothing: dropping it
194    // there would change the point count at one end of the animation, and a path whose
195    // shape depends on which frame you asked for is the thing a morph exists not to be.
196    let path = bar(path, MID, MID, LEN * (1.0 - t), THICK, 0.0);
197    bar(
198        path,
199        MID,
200        lerp(17.0, MID, t),
201        lerp(LEN, 19.8, t),
202        THICK,
203        lerp(0.0, -QUARTER * 0.5, t),
204    )
205}
206
207/// A triangle becoming two bars, as **two quadrilaterals** that start out as the two
208/// halves of the triangle. The right half starts with its two right-hand corners on the
209/// same point — the triangle's tip — which is what lets a three-cornered shape and a
210/// four-cornered one be the same shape all the way across.
211fn play_pause(t: f32) -> Path {
212    let p = |x: f32, y: f32| Point::new(x, y);
213    let left_from = [p(8.0, 5.0), p(8.0, 19.0), p(13.5, 15.5), p(13.5, 8.5)];
214    let left_to = [p(6.0, 5.0), p(6.0, 19.0), p(10.0, 19.0), p(10.0, 5.0)];
215    let right_from = [p(13.5, 8.5), p(13.5, 15.5), p(19.0, 12.0), p(19.0, 12.0)];
216    let right_to = [p(14.0, 5.0), p(14.0, 19.0), p(18.0, 19.0), p(18.0, 5.0)];
217    let path = quad_between(Path::new(), left_from, left_to, t);
218    quad_between(path, right_from, right_to, t)
219}
220
221/// A plus turning into a cross. The two shapes are the same shape, so this is a rotation
222/// and nothing else — an eighth of a turn — and lerping the twelve corners instead would
223/// pinch the arms on the way through.
224fn add_close(t: f32) -> Path {
225    let p = |x: f32, y: f32| Point::new(x, y);
226    let plus = Path::new()
227        .move_to(p(11.0, 5.0))
228        .line_to(p(13.0, 5.0))
229        .line_to(p(13.0, 11.0))
230        .line_to(p(19.0, 11.0))
231        .line_to(p(19.0, 13.0))
232        .line_to(p(13.0, 13.0))
233        .line_to(p(13.0, 19.0))
234        .line_to(p(11.0, 19.0))
235        .line_to(p(11.0, 13.0))
236        .line_to(p(5.0, 13.0))
237        .line_to(p(5.0, 11.0))
238        .line_to(p(11.0, 11.0))
239        .close();
240    plus.rotated(Point::new(MID, MID), t * QUARTER * 0.5)
241}
242
243/// A chevron turning over: pointing down at rest, up when the thing it belongs to is
244/// open. Half a turn, so it passes through sideways rather than collapsing into a line —
245/// which is what lerping the corners of a chevron onto its own reflection would do.
246fn expand_collapse(t: f32) -> Path {
247    let p = |x: f32, y: f32| Point::new(x, y);
248    let chevron = Path::new()
249        .move_to(p(5.6, 9.0))
250        .line_to(p(12.0, 15.4))
251        .line_to(p(18.4, 9.0))
252        .line_to(p(18.4, 11.8))
253        .line_to(p(12.0, 18.2))
254        .line_to(p(5.6, 11.8))
255        .close();
256    chevron.rotated(Point::new(MID, MID), t * QUARTER * 2.0)
257}
258
259/// The **bundled pairs**, as one constant each — the same shape of thing [`Icons`] is for
260/// the static set.
261///
262/// These are the ones this framework needs itself. An application that needs another
263/// authors it with [`AnimatedIconData::custom`], which is the same door
264/// [`IconData::custom`](crate::IconData::custom) opens for a static mark.
265///
266/// [`Icons`]: crate::Icons
267pub struct AnimatedIcons;
268
269impl AnimatedIcons {
270    /// **Three bars ↔ a cross.** A drawer's button: `0.0` shut, `1.0` open.
271    pub const MENU_CLOSE: AnimatedIconData = AnimatedIconData::custom(menu_close);
272    /// **A triangle ↔ two bars.** A transport control: `0.0` stopped, `1.0` playing.
273    ///
274    /// It carries a direction: a play arrow points the way the reader reads, so it is
275    /// turned round in a right-to-left order — and the pause it becomes is symmetrical,
276    /// so nothing looks turned round at the end of it.
277    pub const PLAY_PAUSE: AnimatedIconData = AnimatedIconData::custom(play_pause).mirrored();
278    /// **A plus ↔ a cross.** An add button that becomes the way to put away what it
279    /// opened: `0.0` add, `1.0` dismiss.
280    pub const ADD_CLOSE: AnimatedIconData = AnimatedIconData::custom(add_close);
281    /// **A chevron ↔ the same chevron, over.** Down when shut, up when open.
282    pub const EXPAND_COLLAPSE: AnimatedIconData = AnimatedIconData::custom(expand_collapse);
283
284    /// All four, with their names — for a picker, or for a test that wants to say
285    /// something about every pair there is.
286    pub fn all() -> impl Iterator<Item = (&'static str, AnimatedIconData)> {
287        [
288            ("menu_close", Self::MENU_CLOSE),
289            ("play_pause", Self::PLAY_PAUSE),
290            ("add_close", Self::ADD_CLOSE),
291            ("expand_collapse", Self::EXPAND_COLLAPSE),
292        ]
293        .into_iter()
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use frus_core::{PathVerb, TextDirection};
301
302    /// Every point a path visits, on-curve and control alike.
303    fn points(path: &Path) -> Vec<Point> {
304        let mut out = Vec::new();
305        for verb in path.verbs() {
306            match *verb {
307                PathVerb::MoveTo(p) | PathVerb::LineTo(p) => out.push(p),
308                PathVerb::QuadTo { ctrl, to } => out.extend([ctrl, to]),
309                PathVerb::CubicTo { c1, c2, to } => out.extend([c1, c2, to]),
310                PathVerb::Close => {}
311            }
312        }
313        out
314    }
315
316    /// The three values the issue asks to be pinned, for every pair: the two ends and
317    /// the middle. Each is checked for the property that makes it that state, so the
318    /// numbers below are readable rather than a hash of the artwork.
319    #[test]
320    fn every_pair_is_drawn_at_nought_a_half_and_one() {
321        for (name, pair) in AnimatedIcons::all() {
322            for t in [0.0, 0.5, 1.0] {
323                let path = pair.at(t).path();
324                assert!(!path.is_empty(), "{name} at {t} drew nothing");
325                for p in points(&path) {
326                    assert!(
327                        (-0.1..=24.1).contains(&p.x) && (-0.1..=24.1).contains(&p.y),
328                        "{name} at {t} left the grid: {p:?}"
329                    );
330                }
331            }
332        }
333    }
334
335    /// **The middle is a middle**, not a jump. Between `t` and `t + 0.1` no point may
336    /// move further than a few grid units — which is the difference between a drawing
337    /// that morphs and two drawings with a dissolve between them.
338    #[test]
339    fn no_pair_jumps_on_the_way_across() {
340        for (name, pair) in AnimatedIcons::all() {
341            let mut previous = points(&pair.at(0.0).path());
342            for step in 1..=10 {
343                let t = step as f32 / 10.0;
344                let now = points(&pair.at(t).path());
345                assert_eq!(
346                    now.len(),
347                    previous.len(),
348                    "{name} changed its point count at t = {t}"
349                );
350                for (a, b) in previous.iter().zip(&now) {
351                    let moved = ((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt();
352                    assert!(moved < 4.0, "{name} jumped {moved} at t = {t}");
353                }
354                previous = now;
355            }
356        }
357    }
358
359    /// A morph at `t = 0` and the same morph at `t = 0` are the same icon, and one at a
360    /// different `t` is not. `IconData` is a key — a cache, a comparison in a widget's
361    /// `PartialEq` — so two positions of the same animation must not be interchangeable.
362    #[test]
363    fn two_positions_of_one_pair_are_different_icons() {
364        let a = AnimatedIcons::MENU_CLOSE.at(0.0);
365        let b = AnimatedIcons::MENU_CLOSE.at(0.0);
366        let c = AnimatedIcons::MENU_CLOSE.at(1.0);
367        assert_eq!(a, b, "the same pair at the same t");
368        assert_ne!(a, c, "the same pair at a different t");
369        assert_ne!(
370            a,
371            AnimatedIcons::ADD_CLOSE.at(0.0),
372            "two pairs at the same t"
373        );
374    }
375
376    /// `t` outside the range is brought back into it rather than drawing something the
377    /// artwork was never asked about, and a `t` that is not a number is read as the
378    /// start — because an icon that compares unequal to itself breaks `Eq`.
379    #[test]
380    fn a_t_outside_the_range_is_clamped_and_a_nan_is_not() {
381        let pair = AnimatedIcons::PLAY_PAUSE;
382        assert_eq!(pair.at(-3.0), pair.at(0.0));
383        assert_eq!(pair.at(9.0), pair.at(1.0));
384        let nan = pair.at(f32::NAN);
385        assert_eq!(nan, nan, "an icon is equal to itself");
386        assert_eq!(nan, pair.at(0.0), "and it is the start of the animation");
387    }
388
389    /// The signed area of each closed contour, by the shoelace formula — what the fill
390    /// actually covers, which is not the same question as how many points there are.
391    fn areas(path: &Path) -> Vec<f32> {
392        let mut out = Vec::new();
393        let mut contour: Vec<Point> = Vec::new();
394        let flush = |c: &mut Vec<Point>, out: &mut Vec<f32>| {
395            if c.len() > 2 {
396                let mut twice = 0.0;
397                for i in 0..c.len() {
398                    let (a, b) = (c[i], c[(i + 1) % c.len()]);
399                    twice += a.x * b.y - b.x * a.y;
400                }
401                out.push((twice * 0.5).abs());
402            }
403            c.clear();
404        };
405        for verb in path.verbs() {
406            match *verb {
407                PathVerb::MoveTo(p) => {
408                    flush(&mut contour, &mut out);
409                    contour.push(p);
410                }
411                PathVerb::LineTo(p) => contour.push(p),
412                PathVerb::QuadTo { to, .. } | PathVerb::CubicTo { to, .. } => contour.push(to),
413                PathVerb::Close => flush(&mut contour, &mut out),
414            }
415        }
416        flush(&mut contour, &mut out);
417        out
418    }
419
420    /// **Three bars become two**, and the one that goes does so by covering nothing —
421    /// not by leaving the path. Three contours all the way across, and the middle one's
422    /// ink runs out exactly at the end.
423    #[test]
424    fn the_menu_loses_its_middle_bar_without_losing_a_contour() {
425        for t in [0.0, 0.5, 1.0] {
426            let path = AnimatedIcons::MENU_CLOSE.at(t).path();
427            assert_eq!(points(&path).len(), 12, "three bars at t = {t}");
428            assert_eq!(areas(&path).len(), 3, "three contours at t = {t}");
429        }
430        let ink = |t: f32| areas(&AnimatedIcons::MENU_CLOSE.at(t).path())[1];
431        assert!(ink(0.0) > 30.0, "the middle bar starts full: {}", ink(0.0));
432        assert!(
433            (ink(0.5) - ink(0.0) * 0.5).abs() < 0.1,
434            "and is half gone halfway: {}",
435            ink(0.5)
436        );
437        assert_eq!(ink(1.0), 0.0, "and covers nothing at the end");
438    }
439
440    /// A pair that carries a direction is turned round **at every `t`**, not only at the
441    /// ends: a play arrow that pointed the right way when stopped and the wrong way
442    /// halfway through would be worse than one that never turned at all.
443    #[test]
444    fn a_directional_pair_is_mirrored_all_the_way_across() {
445        assert!(AnimatedIcons::PLAY_PAUSE.matches_text_direction());
446        assert!(!AnimatedIcons::MENU_CLOSE.matches_text_direction());
447        for step in 0..=4 {
448            let t = step as f32 / 4.0;
449            let icon = AnimatedIcons::PLAY_PAUSE.at(t);
450            assert!(icon.matches_text_direction(), "at t = {t}");
451            let ltr = icon.placed(24.0, 0.0, 0.0, TextDirection::Ltr);
452            let rtl = icon.placed(24.0, 0.0, 0.0, TextDirection::Rtl);
453            let (a, b) = (points(&ltr), points(&rtl));
454            for (l, r) in a.iter().zip(&b) {
455                assert!(
456                    (l.x + r.x - 24.0).abs() < 1e-3 && (l.y - r.y).abs() < 1e-3,
457                    "at t = {t}: {l:?} against {r:?}"
458                );
459            }
460        }
461    }
462
463    /// A caller's own pair reaches the same machinery as a bundled one, and is not
464    /// mistaken for a bundled icon on the way.
465    #[test]
466    fn a_callers_own_pair_is_an_icon_like_any_other() {
467        fn box_that_grows(t: f32) -> Path {
468            Path::rect(frus_core::Rect::new(4.0, 4.0, 16.0 * t, 16.0))
469        }
470        const MINE: AnimatedIconData = AnimatedIconData::custom(box_that_grows);
471        let icon = MINE.at(0.5);
472        assert!(!icon.is_bundled());
473        assert_eq!(icon.style(), None);
474        assert!(!icon.path().is_empty());
475        assert_eq!(icon, MINE.at(0.5));
476    }
477}