use frus_core::{Path, Point};
use super::IconData;
#[derive(Clone, Copy, Debug)]
pub struct AnimatedIconData {
draw: fn(f32) -> Path,
directional: bool,
}
impl PartialEq for AnimatedIconData {
fn eq(&self, other: &Self) -> bool {
std::ptr::fn_addr_eq(self.draw, other.draw) && self.directional == other.directional
}
}
impl Eq for AnimatedIconData {}
impl std::hash::Hash for AnimatedIconData {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
((self.draw as usize), self.directional).hash(state);
}
}
impl AnimatedIconData {
pub const fn custom(draw: fn(f32) -> Path) -> Self {
Self {
draw,
directional: false,
}
}
pub const fn mirrored(self) -> Self {
Self {
draw: self.draw,
directional: true,
}
}
pub const fn matches_text_direction(self) -> bool {
self.directional
}
pub fn at(self, t: f32) -> IconData {
let t = if t.is_finite() {
t.clamp(0.0, 1.0)
} else {
0.0
};
IconData::morphing(self.draw, t, self.directional)
}
}
const MID: f32 = 12.0;
fn lerp(a: f32, b: f32, t: f32) -> f32 {
a + (b - a) * t
}
fn bar(path: Path, cx: f32, cy: f32, len: f32, thick: f32, angle: f32) -> Path {
let (sin, cos) = angle.sin_cos();
let (hl, ht) = (len * 0.5, thick * 0.5);
let (lx, ly) = (hl * cos, hl * sin);
let (tx, ty) = (-ht * sin, ht * cos);
path.move_to(Point::new(cx - lx - tx, cy - ly - ty))
.line_to(Point::new(cx + lx - tx, cy + ly - ty))
.line_to(Point::new(cx + lx + tx, cy + ly + ty))
.line_to(Point::new(cx - lx + tx, cy - ly + ty))
.close()
}
fn quad(path: Path, corners: [Point; 4]) -> Path {
path.move_to(corners[0])
.line_to(corners[1])
.line_to(corners[2])
.line_to(corners[3])
.close()
}
fn quad_between(path: Path, from: [Point; 4], to: [Point; 4], t: f32) -> Path {
let mut corners = from;
for (c, (a, b)) in corners.iter_mut().zip(from.iter().zip(to.iter())) {
*c = Point::new(lerp(a.x, b.x, t), lerp(a.y, b.y, t));
}
quad(path, corners)
}
const QUARTER: f32 = std::f32::consts::FRAC_PI_2;
fn menu_close(t: f32) -> Path {
const LEN: f32 = 18.0;
const THICK: f32 = 2.0;
let path = Path::new();
let path = bar(
path,
MID,
lerp(7.0, MID, t),
lerp(LEN, 19.8, t),
THICK,
lerp(0.0, QUARTER * 0.5, t),
);
let path = bar(path, MID, MID, LEN * (1.0 - t), THICK, 0.0);
bar(
path,
MID,
lerp(17.0, MID, t),
lerp(LEN, 19.8, t),
THICK,
lerp(0.0, -QUARTER * 0.5, t),
)
}
fn play_pause(t: f32) -> Path {
let p = |x: f32, y: f32| Point::new(x, y);
let left_from = [p(8.0, 5.0), p(8.0, 19.0), p(13.5, 15.5), p(13.5, 8.5)];
let left_to = [p(6.0, 5.0), p(6.0, 19.0), p(10.0, 19.0), p(10.0, 5.0)];
let right_from = [p(13.5, 8.5), p(13.5, 15.5), p(19.0, 12.0), p(19.0, 12.0)];
let right_to = [p(14.0, 5.0), p(14.0, 19.0), p(18.0, 19.0), p(18.0, 5.0)];
let path = quad_between(Path::new(), left_from, left_to, t);
quad_between(path, right_from, right_to, t)
}
fn add_close(t: f32) -> Path {
let p = |x: f32, y: f32| Point::new(x, y);
let plus = Path::new()
.move_to(p(11.0, 5.0))
.line_to(p(13.0, 5.0))
.line_to(p(13.0, 11.0))
.line_to(p(19.0, 11.0))
.line_to(p(19.0, 13.0))
.line_to(p(13.0, 13.0))
.line_to(p(13.0, 19.0))
.line_to(p(11.0, 19.0))
.line_to(p(11.0, 13.0))
.line_to(p(5.0, 13.0))
.line_to(p(5.0, 11.0))
.line_to(p(11.0, 11.0))
.close();
plus.rotated(Point::new(MID, MID), t * QUARTER * 0.5)
}
fn expand_collapse(t: f32) -> Path {
let p = |x: f32, y: f32| Point::new(x, y);
let chevron = Path::new()
.move_to(p(5.6, 9.0))
.line_to(p(12.0, 15.4))
.line_to(p(18.4, 9.0))
.line_to(p(18.4, 11.8))
.line_to(p(12.0, 18.2))
.line_to(p(5.6, 11.8))
.close();
chevron.rotated(Point::new(MID, MID), t * QUARTER * 2.0)
}
pub struct AnimatedIcons;
impl AnimatedIcons {
pub const MENU_CLOSE: AnimatedIconData = AnimatedIconData::custom(menu_close);
pub const PLAY_PAUSE: AnimatedIconData = AnimatedIconData::custom(play_pause).mirrored();
pub const ADD_CLOSE: AnimatedIconData = AnimatedIconData::custom(add_close);
pub const EXPAND_COLLAPSE: AnimatedIconData = AnimatedIconData::custom(expand_collapse);
pub fn all() -> impl Iterator<Item = (&'static str, AnimatedIconData)> {
[
("menu_close", Self::MENU_CLOSE),
("play_pause", Self::PLAY_PAUSE),
("add_close", Self::ADD_CLOSE),
("expand_collapse", Self::EXPAND_COLLAPSE),
]
.into_iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
use frus_core::{PathVerb, TextDirection};
fn points(path: &Path) -> Vec<Point> {
let mut out = Vec::new();
for verb in path.verbs() {
match *verb {
PathVerb::MoveTo(p) | PathVerb::LineTo(p) => out.push(p),
PathVerb::QuadTo { ctrl, to } => out.extend([ctrl, to]),
PathVerb::CubicTo { c1, c2, to } => out.extend([c1, c2, to]),
PathVerb::Close => {}
}
}
out
}
#[test]
fn every_pair_is_drawn_at_nought_a_half_and_one() {
for (name, pair) in AnimatedIcons::all() {
for t in [0.0, 0.5, 1.0] {
let path = pair.at(t).path();
assert!(!path.is_empty(), "{name} at {t} drew nothing");
for p in points(&path) {
assert!(
(-0.1..=24.1).contains(&p.x) && (-0.1..=24.1).contains(&p.y),
"{name} at {t} left the grid: {p:?}"
);
}
}
}
}
#[test]
fn no_pair_jumps_on_the_way_across() {
for (name, pair) in AnimatedIcons::all() {
let mut previous = points(&pair.at(0.0).path());
for step in 1..=10 {
let t = step as f32 / 10.0;
let now = points(&pair.at(t).path());
assert_eq!(
now.len(),
previous.len(),
"{name} changed its point count at t = {t}"
);
for (a, b) in previous.iter().zip(&now) {
let moved = ((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt();
assert!(moved < 4.0, "{name} jumped {moved} at t = {t}");
}
previous = now;
}
}
}
#[test]
fn two_positions_of_one_pair_are_different_icons() {
let a = AnimatedIcons::MENU_CLOSE.at(0.0);
let b = AnimatedIcons::MENU_CLOSE.at(0.0);
let c = AnimatedIcons::MENU_CLOSE.at(1.0);
assert_eq!(a, b, "the same pair at the same t");
assert_ne!(a, c, "the same pair at a different t");
assert_ne!(
a,
AnimatedIcons::ADD_CLOSE.at(0.0),
"two pairs at the same t"
);
}
#[test]
fn a_t_outside_the_range_is_clamped_and_a_nan_is_not() {
let pair = AnimatedIcons::PLAY_PAUSE;
assert_eq!(pair.at(-3.0), pair.at(0.0));
assert_eq!(pair.at(9.0), pair.at(1.0));
let nan = pair.at(f32::NAN);
assert_eq!(nan, nan, "an icon is equal to itself");
assert_eq!(nan, pair.at(0.0), "and it is the start of the animation");
}
fn areas(path: &Path) -> Vec<f32> {
let mut out = Vec::new();
let mut contour: Vec<Point> = Vec::new();
let flush = |c: &mut Vec<Point>, out: &mut Vec<f32>| {
if c.len() > 2 {
let mut twice = 0.0;
for i in 0..c.len() {
let (a, b) = (c[i], c[(i + 1) % c.len()]);
twice += a.x * b.y - b.x * a.y;
}
out.push((twice * 0.5).abs());
}
c.clear();
};
for verb in path.verbs() {
match *verb {
PathVerb::MoveTo(p) => {
flush(&mut contour, &mut out);
contour.push(p);
}
PathVerb::LineTo(p) => contour.push(p),
PathVerb::QuadTo { to, .. } | PathVerb::CubicTo { to, .. } => contour.push(to),
PathVerb::Close => flush(&mut contour, &mut out),
}
}
flush(&mut contour, &mut out);
out
}
#[test]
fn the_menu_loses_its_middle_bar_without_losing_a_contour() {
for t in [0.0, 0.5, 1.0] {
let path = AnimatedIcons::MENU_CLOSE.at(t).path();
assert_eq!(points(&path).len(), 12, "three bars at t = {t}");
assert_eq!(areas(&path).len(), 3, "three contours at t = {t}");
}
let ink = |t: f32| areas(&AnimatedIcons::MENU_CLOSE.at(t).path())[1];
assert!(ink(0.0) > 30.0, "the middle bar starts full: {}", ink(0.0));
assert!(
(ink(0.5) - ink(0.0) * 0.5).abs() < 0.1,
"and is half gone halfway: {}",
ink(0.5)
);
assert_eq!(ink(1.0), 0.0, "and covers nothing at the end");
}
#[test]
fn a_directional_pair_is_mirrored_all_the_way_across() {
assert!(AnimatedIcons::PLAY_PAUSE.matches_text_direction());
assert!(!AnimatedIcons::MENU_CLOSE.matches_text_direction());
for step in 0..=4 {
let t = step as f32 / 4.0;
let icon = AnimatedIcons::PLAY_PAUSE.at(t);
assert!(icon.matches_text_direction(), "at t = {t}");
let ltr = icon.placed(24.0, 0.0, 0.0, TextDirection::Ltr);
let rtl = icon.placed(24.0, 0.0, 0.0, TextDirection::Rtl);
let (a, b) = (points(<r), points(&rtl));
for (l, r) in a.iter().zip(&b) {
assert!(
(l.x + r.x - 24.0).abs() < 1e-3 && (l.y - r.y).abs() < 1e-3,
"at t = {t}: {l:?} against {r:?}"
);
}
}
}
#[test]
fn a_callers_own_pair_is_an_icon_like_any_other() {
fn box_that_grows(t: f32) -> Path {
Path::rect(frus_core::Rect::new(4.0, 4.0, 16.0 * t, 16.0))
}
const MINE: AnimatedIconData = AnimatedIconData::custom(box_that_grows);
let icon = MINE.at(0.5);
assert!(!icon.is_bundled());
assert_eq!(icon.style(), None);
assert!(!icon.path().is_empty());
assert_eq!(icon, MINE.at(0.5));
}
}