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(<r), 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}