use frus_core::{Path, Point, TextDirection};
mod animated;
mod names;
pub use animated::{AnimatedIconData, AnimatedIcons};
const MAGIC: &[u8; 8] = b"FRUSICO1";
#[derive(Clone, Copy)]
struct Blob(&'static [u8]);
impl Blob {
const fn le_u32(self, at: usize) -> u32 {
let b = self.0;
u32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
}
const fn count(self) -> usize {
self.le_u32(8) as usize
}
const fn upem(self) -> f32 {
self.le_u32(12) as f32
}
const fn grid(self) -> f32 {
self.le_u32(16) as f32
}
const fn data(self) -> usize {
8 + 12 + 4 * (self.count() + 1)
}
const fn stream(self, index: usize) -> (usize, usize) {
let table = 8 + 12 + 4 * index;
(
self.data() + self.le_u32(table) as usize,
self.data() + self.le_u32(table + 4) as usize,
)
}
const fn check(self) -> bool {
assert!(self.0.len() >= 20, "the icon blob is truncated");
let mut i = 0;
while i < 8 {
assert!(
self.0[i] == MAGIC[i],
"the icon blob is not a frus icon blob"
);
i += 1;
}
assert!(
self.0.len() == self.data() + self.le_u32(20 + 4 * self.count()) as usize,
"the icon blob's offset table and its data disagree"
);
assert!(
self.upem() == FILLED.upem() && self.grid() == FILLED.grid(),
"the styles were generated on different grids"
);
true
}
}
const FILLED: Blob = Blob(include_bytes!("../../assets/material-icons.bin"));
#[cfg(feature = "icons-outlined")]
const OUTLINED: Blob = Blob(include_bytes!("../../assets/material-icons-outlined.bin"));
#[cfg(feature = "icons-rounded")]
const ROUNDED: Blob = Blob(include_bytes!("../../assets/material-icons-rounded.bin"));
#[cfg(feature = "icons-sharp")]
const SHARP: Blob = Blob(include_bytes!("../../assets/material-icons-sharp.bin"));
const _: () = {
assert!(FILLED.check());
#[cfg(feature = "icons-outlined")]
assert!(OUTLINED.check());
#[cfg(feature = "icons-rounded")]
assert!(ROUNDED.check());
#[cfg(feature = "icons-sharp")]
assert!(SHARP.check());
};
pub(crate) const GRID: f32 = FILLED.grid();
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum IconStyle {
Filled,
Outlined,
Rounded,
Sharp,
}
impl IconStyle {
fn blob(self) -> Option<Blob> {
match self {
IconStyle::Filled => Some(FILLED),
#[cfg(feature = "icons-outlined")]
IconStyle::Outlined => Some(OUTLINED),
#[cfg(feature = "icons-rounded")]
IconStyle::Rounded => Some(ROUNDED),
#[cfg(feature = "icons-sharp")]
IconStyle::Sharp => Some(SHARP),
#[cfg(not(all(
feature = "icons-outlined",
feature = "icons-rounded",
feature = "icons-sharp"
)))]
_ => None,
}
}
pub const fn suffix(self) -> &'static str {
match self {
IconStyle::Filled => "",
IconStyle::Outlined => "_outlined",
IconStyle::Rounded => "_rounded",
IconStyle::Sharp => "_sharp",
}
}
}
const OP_CLOSE: u8 = 0;
const OP_MOVE_D: u8 = 1;
const OP_MOVE_A: u8 = 2;
const OP_LINE_D: u8 = 3;
const OP_LINE_A: u8 = 4;
const OP_CUBIC_D: u8 = 5;
const OP_CUBIC_A: u8 = 6;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct IconData {
source: Source,
directional: bool,
}
#[derive(Clone, Copy, Debug)]
enum Source {
Bundled(IconStyle, u16),
Custom(fn() -> Path),
Morph(fn(f32) -> Path, f32),
}
impl PartialEq for Source {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Source::Bundled(sa, a), Source::Bundled(sb, b)) => sa == sb && a == b,
(Source::Custom(a), Source::Custom(b)) => std::ptr::fn_addr_eq(*a, *b),
(Source::Morph(a, ta), Source::Morph(b, tb)) => {
std::ptr::fn_addr_eq(*a, *b) && ta.to_bits() == tb.to_bits()
}
_ => false,
}
}
}
impl Eq for Source {}
impl std::hash::Hash for Source {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
match self {
Source::Bundled(style, index) => (0u8, *style, *index as usize).hash(state),
Source::Custom(draw) => (1u8, *draw as usize).hash(state),
Source::Morph(draw, t) => (2u8, *draw as usize, t.to_bits()).hash(state),
}
}
}
impl IconData {
pub(crate) const fn bundled(style: IconStyle, index: u16) -> Self {
Self {
source: Source::Bundled(style, index),
directional: false,
}
}
pub const fn custom(draw: fn() -> Path) -> Self {
Self {
source: Source::Custom(draw),
directional: false,
}
}
pub(crate) const fn morphing(draw: fn(f32) -> Path, t: f32, directional: bool) -> Self {
Self {
source: Source::Morph(draw, t),
directional,
}
}
pub const fn mirrored(self) -> Self {
Self {
source: self.source,
directional: true,
}
}
pub const fn matches_text_direction(self) -> bool {
self.directional
}
pub fn path(self) -> Path {
match self.source {
Source::Bundled(style, index) => decode(style, index as usize),
Source::Custom(draw) => draw(),
Source::Morph(draw, t) => draw(t),
}
}
pub fn style(self) -> Option<IconStyle> {
match self.source {
Source::Bundled(style, _) => Some(style),
Source::Custom(_) | Source::Morph(..) => None,
}
}
pub fn placed(self, side: f32, x: f32, y: f32, direction: TextDirection) -> Path {
let path = self.path().scaled(side / GRID).translated(x, y);
if self.directional && direction == TextDirection::Rtl {
path.mirrored_x(x + side * 0.5)
} else {
path
}
}
pub fn is_bundled(self) -> bool {
matches!(self.source, Source::Bundled(..))
}
}
#[derive(Clone, Copy, Debug)]
pub struct Icons;
impl Icons {
pub const COUNT: usize = FILLED.count();
pub fn by_name(name: &str) -> Option<IconData> {
fn find(table: &'static [(&'static str, IconData)], name: &str) -> Option<IconData> {
table
.binary_search_by(|(known, _)| (*known).cmp(name))
.ok()
.map(|i| table[i].1)
}
if let Some(icon) = find(&names::FILLED, name) {
return Some(icon);
}
#[cfg(feature = "icons-outlined")]
if let Some(icon) = find(&names::OUTLINED, name) {
return Some(icon);
}
#[cfg(feature = "icons-rounded")]
if let Some(icon) = find(&names::ROUNDED, name) {
return Some(icon);
}
#[cfg(feature = "icons-sharp")]
if let Some(icon) = find(&names::SHARP, name) {
return Some(icon);
}
None
}
pub fn all() -> impl Iterator<Item = (&'static str, IconData)> {
let styles: [&'static [(&'static str, IconData)]; 4] = [
&names::FILLED,
#[cfg(feature = "icons-outlined")]
&names::OUTLINED,
#[cfg(not(feature = "icons-outlined"))]
&[],
#[cfg(feature = "icons-rounded")]
&names::ROUNDED,
#[cfg(not(feature = "icons-rounded"))]
&[],
#[cfg(feature = "icons-sharp")]
&names::SHARP,
#[cfg(not(feature = "icons-sharp"))]
&[],
];
styles.into_iter().flatten().copied()
}
pub fn of_style(style: IconStyle) -> impl Iterator<Item = (&'static str, IconData)> {
Self::all().filter(move |(_, icon)| icon.style() == Some(style))
}
}
fn decode(style: IconStyle, index: usize) -> Path {
let Some(blob) = style.blob() else {
return Path::new();
};
if index >= blob.count() {
return Path::new();
}
let (mut p, end) = blob.stream(index);
let bytes = blob.0;
let scale = blob.grid() / blob.upem();
let grid = blob.grid();
let (mut cx, mut cy) = (0i32, 0i32);
let mut path = Path::new();
let point = |x: i32, y: i32| Point::new(x as f32 * scale, grid - y as f32 * scale);
let i8_at = |at: usize| bytes[at] as i8 as i32;
let i16_at = |at: usize| i16::from_le_bytes([bytes[at], bytes[at + 1]]) as i32;
while p < end {
let op = bytes[p];
p += 1;
match op {
OP_CLOSE => path = path.close(),
OP_MOVE_D | OP_LINE_D => {
cx += i8_at(p);
cy += i8_at(p + 1);
p += 2;
let to = point(cx, cy);
path = if op == OP_MOVE_D {
path.move_to(to)
} else {
path.line_to(to)
};
}
OP_MOVE_A | OP_LINE_A => {
cx = i16_at(p);
cy = i16_at(p + 2);
p += 4;
let to = point(cx, cy);
path = if op == OP_MOVE_A {
path.move_to(to)
} else {
path.line_to(to)
};
}
OP_CUBIC_D => {
let d = [
i8_at(p),
i8_at(p + 1),
i8_at(p + 2),
i8_at(p + 3),
i8_at(p + 4),
i8_at(p + 5),
];
p += 6;
let (x1, y1) = (cx + d[0], cy + d[1]);
let (x2, y2) = (x1 + d[2], y1 + d[3]);
cx = x2 + d[4];
cy = y2 + d[5];
path = path.cubic_to(point(x1, y1), point(x2, y2), point(cx, cy));
}
OP_CUBIC_A => {
let (x1, y1) = (i16_at(p), i16_at(p + 2));
let (x2, y2) = (i16_at(p + 4), i16_at(p + 6));
cx = i16_at(p + 8);
cy = i16_at(p + 10);
p += 12;
path = path.cubic_to(point(x1, y1), point(x2, y2), point(cx, cy));
}
_ => break,
}
}
path
}
#[cfg(test)]
mod tests {
use super::*;
use frus_core::PathVerb;
#[test]
fn every_compiled_style_agrees_with_its_blob() {
assert_eq!(FILLED.upem(), 512.0);
assert_eq!(GRID, 24.0);
for (style, table) in compiled_styles() {
let blob = style.blob().expect("a compiled style has a blob");
assert_eq!(
blob.count(),
table.len(),
"{style:?}: blob and names disagree on the count"
);
let (_, end) = blob.stream(blob.count() - 1);
assert_eq!(end, blob.0.len(), "{style:?}");
}
assert_eq!(Icons::COUNT, names::FILLED.len());
}
#[test]
fn each_table_carries_its_own_suffix() {
for (style, table) in compiled_styles() {
for (name, icon) in table {
assert_eq!(icon.style(), Some(style), "{name} is filed under {style:?}");
if !style.suffix().is_empty() {
assert!(
name.ends_with(style.suffix()),
"{name} should end with {}",
style.suffix()
);
}
}
}
}
fn compiled_styles() -> Vec<(IconStyle, &'static [(&'static str, IconData)])> {
let styles: [(IconStyle, &'static [(&'static str, IconData)]); 4] = [
(IconStyle::Filled, &names::FILLED),
#[cfg(feature = "icons-outlined")]
(IconStyle::Outlined, &names::OUTLINED),
#[cfg(not(feature = "icons-outlined"))]
(IconStyle::Outlined, &[]),
#[cfg(feature = "icons-rounded")]
(IconStyle::Rounded, &names::ROUNDED),
#[cfg(not(feature = "icons-rounded"))]
(IconStyle::Rounded, &[]),
#[cfg(feature = "icons-sharp")]
(IconStyle::Sharp, &names::SHARP),
#[cfg(not(feature = "icons-sharp"))]
(IconStyle::Sharp, &[]),
];
styles
.into_iter()
.filter(|(style, _)| style.blob().is_some())
.collect()
}
#[test]
fn every_icon_decodes_to_a_closed_filled_outline() {
for (name, icon) in Icons::all() {
let path = icon.path();
assert!(!path.is_empty(), "{name} decoded to nothing");
assert!(
matches!(path.verbs().first(), Some(PathVerb::MoveTo(_))),
"{name} should start with a MoveTo"
);
assert!(
matches!(path.verbs().last(), Some(PathVerb::Close)),
"{name} should end closed — an unclosed contour fills wrong"
);
}
}
#[test]
fn every_icon_stays_within_its_grid() {
let unit = GRID / FILLED.upem();
let control_slack = 2.0;
for (name, icon) in Icons::all() {
for verb in icon.path().verbs() {
let (controls, ends): (Vec<Point>, Vec<Point>) = match verb {
PathVerb::MoveTo(p) | PathVerb::LineTo(p) => (vec![], vec![*p]),
PathVerb::QuadTo { ctrl, to } => (vec![*ctrl], vec![*to]),
PathVerb::CubicTo { c1, c2, to } => (vec![*c1, *c2], vec![*to]),
PathVerb::Close => (vec![], vec![]),
};
for p in ends {
assert!(
p.x >= -unit && p.x <= GRID + unit && p.y >= -unit && p.y <= GRID + unit,
"{name} has an on-curve point off the grid at {p:?}"
);
}
for p in controls {
assert!(
p.x >= -control_slack && p.x <= GRID + control_slack,
"{name} has a control point far off the grid at {p:?}"
);
assert!(
p.y >= -control_slack && p.y <= GRID + control_slack,
"{name} has a control point far off the grid at {p:?}"
);
}
}
}
}
#[test]
fn each_name_table_is_sorted_and_unique() {
for (style, table) in compiled_styles() {
let names: Vec<&str> = table.iter().map(|(n, _)| *n).collect();
assert!(
names.windows(2).all(|w| w[0] < w[1]),
"{style:?}: the name table must ascend with no duplicates"
);
}
assert_eq!(names::FILLED.len(), Icons::COUNT);
}
#[test]
fn a_name_that_ends_like_a_suffix_is_still_its_own_icon() {
let chart = Icons::by_name("insert_chart_outlined").expect("a filled icon");
assert_eq!(chart, Icons::INSERT_CHART_OUTLINED);
assert_eq!(chart.style(), Some(IconStyle::Filled));
let wifi = Icons::by_name("wifi_tethering_error_rounded").expect("a filled icon");
assert_eq!(wifi.style(), Some(IconStyle::Filled));
assert_eq!(Icons::by_name("class"), Some(Icons::CLASS));
}
#[test]
fn a_name_resolves_to_the_same_icon_as_its_constant() {
assert_eq!(Icons::by_name("add"), Some(Icons::ADD));
assert_eq!(Icons::by_name("arrow_back"), Some(Icons::ARROW_BACK));
assert_eq!(
Icons::by_name("visibility_off"),
Some(Icons::VISIBILITY_OFF)
);
assert_eq!(Icons::by_name("no_such_icon"), None);
assert_eq!(Icons::by_name("ten_k"), Some(Icons::TEN_K));
}
#[test]
fn add_is_a_centred_plus() {
let path = Icons::ADD.path();
let points: Vec<Point> = path
.verbs()
.iter()
.filter_map(|v| match v {
PathVerb::MoveTo(p) | PathVerb::LineTo(p) => Some(*p),
_ => None,
})
.collect();
let (min_x, max_x) = bounds(points.iter().map(|p| p.x));
let (min_y, max_y) = bounds(points.iter().map(|p| p.y));
assert!((min_x - 5.0).abs() < 0.1, "left edge at {min_x}");
assert!((max_x - 19.0).abs() < 0.1, "right edge at {max_x}");
assert!((min_y - 5.0).abs() < 0.1, "top edge at {min_y}");
assert!((max_y - 19.0).abs() < 0.1, "bottom edge at {max_y}");
assert_eq!(points.len(), 13);
}
fn bounds(values: impl Iterator<Item = f32>) -> (f32, f32) {
values.fold((f32::MAX, f32::MIN), |(lo, hi), v| (lo.min(v), hi.max(v)))
}
#[test]
fn a_custom_icon_draws_its_own_path() {
fn bar() -> Path {
Path::rect(frus_core::Rect::new(4.0, 10.0, 16.0, 4.0))
}
const BAR: IconData = IconData::custom(bar);
assert!(!BAR.is_bundled());
assert!(Icons::ADD.is_bundled());
assert_eq!(BAR.path().verbs().len(), bar().verbs().len());
assert_eq!(BAR, IconData::custom(bar));
assert_ne!(BAR, Icons::ADD);
}
#[test]
fn an_index_past_the_end_is_empty() {
assert!(IconData::bundled(IconStyle::Filled, u16::MAX)
.path()
.is_empty());
for style in [IconStyle::Outlined, IconStyle::Rounded, IconStyle::Sharp] {
if style.blob().is_none() {
assert!(IconData::bundled(style, 0).path().is_empty());
assert_eq!(Icons::by_name(&format!("add{}", style.suffix())), None);
}
}
}
#[test]
fn the_filled_style_is_never_optional() {
assert!(IconStyle::Filled.blob().is_some());
assert!(!Icons::ADD.path().is_empty());
assert_eq!(Icons::ADD.style(), Some(IconStyle::Filled));
}
#[test]
fn the_directional_icons_are_the_ones_that_point_somewhere() {
for icon in [
Icons::ARROW_BACK,
Icons::ARROW_FORWARD,
Icons::CHEVRON_LEFT,
Icons::CHEVRON_RIGHT,
Icons::REPLY,
Icons::UNDO,
Icons::FORMAT_INDENT_INCREASE,
Icons::TRENDING_UP,
] {
assert!(icon.matches_text_direction(), "{icon:?} points somewhere");
}
for icon in [Icons::CHECK, Icons::STAR, Icons::SEARCH, Icons::FAVORITE] {
assert!(!icon.matches_text_direction(), "{icon:?} points nowhere");
}
let filled: Vec<&str> = names::FILLED
.iter()
.filter(|(_, icon)| icon.matches_text_direction())
.map(|(name, _)| *name)
.collect();
assert_eq!(filled.len(), 76);
for (style, table) in compiled_styles() {
if style == IconStyle::Filled {
continue;
}
for base in &filled {
let spelled = format!("{base}{}", style.suffix());
if let Some(icon) = Icons::by_name(&spelled) {
assert!(
icon.matches_text_direction(),
"{spelled} should carry the direction its filled twin does"
);
}
}
let turning = table
.iter()
.filter(|(_, icon)| icon.matches_text_direction())
.count();
assert!(
turning <= filled.len() && turning + 2 >= filled.len(),
"{style:?} turns {turning} icons round, the filled set {}",
filled.len()
);
}
}
#[test]
fn placed_is_the_old_scale_and_translate_in_ltr() {
for icon in [Icons::CHECK, Icons::ARROW_BACK] {
let placed = icon.placed(16.0, 3.0, 7.0, TextDirection::Ltr);
let by_hand = icon.path().scaled(16.0 / GRID).translated(3.0, 7.0);
assert_eq!(placed.verbs(), by_hand.verbs(), "{icon:?}");
}
}
#[test]
fn rtl_mirrors_a_directional_icon_within_its_box() {
let (side, x, y) = (16.0, 3.0, 7.0);
let ltr = Icons::ARROW_BACK.placed(side, x, y, TextDirection::Ltr);
let rtl = Icons::ARROW_BACK.placed(side, x, y, TextDirection::Rtl);
assert_ne!(ltr.verbs(), rtl.verbs(), "a back arrow should turn round");
let axis = x + side * 0.5;
for (before, after) in points(<r).into_iter().zip(points(&rtl)) {
assert!((after.x - (2.0 * axis - before.x)).abs() < 1e-4);
assert!((after.y - before.y).abs() < 1e-4, "only x is reflected");
}
let (lo_ltr, hi_ltr) = bounds(points(<r).into_iter().map(|p| p.x));
let (lo_rtl, hi_rtl) = bounds(points(&rtl).into_iter().map(|p| p.x));
assert!((lo_ltr - (2.0 * axis - hi_rtl)).abs() < 1e-4);
assert!((hi_ltr - (2.0 * axis - lo_rtl)).abs() < 1e-4);
}
#[test]
fn rtl_leaves_an_icon_that_points_nowhere_alone() {
let ltr = Icons::CHECK.placed(16.0, 3.0, 7.0, TextDirection::Ltr);
let rtl = Icons::CHECK.placed(16.0, 3.0, 7.0, TextDirection::Rtl);
assert_eq!(ltr.verbs(), rtl.verbs());
}
#[test]
fn a_custom_icon_can_carry_a_direction() {
fn wedge() -> Path {
Path::new()
.move_to(Point::new(4.0, 12.0))
.line_to(Point::new(20.0, 4.0))
.line_to(Point::new(20.0, 20.0))
.close()
}
const BACK: IconData = IconData::custom(wedge).mirrored();
const MARK: IconData = IconData::custom(wedge);
assert!(BACK.matches_text_direction());
assert!(!MARK.matches_text_direction());
let rtl = BACK.placed(24.0, 0.0, 0.0, TextDirection::Rtl);
assert!(
matches!(rtl.verbs().first(), Some(PathVerb::MoveTo(p)) if (p.x - 20.0).abs() < 1e-4)
);
assert_eq!(
MARK.placed(24.0, 0.0, 0.0, TextDirection::Rtl).verbs(),
MARK.placed(24.0, 0.0, 0.0, TextDirection::Ltr).verbs()
);
}
fn points(path: &Path) -> Vec<Point> {
path.verbs()
.iter()
.flat_map(|v| match v {
PathVerb::MoveTo(p) | PathVerb::LineTo(p) => vec![*p],
PathVerb::QuadTo { ctrl, to } => vec![*ctrl, *to],
PathVerb::CubicTo { c1, c2, to } => vec![*c1, *c2, *to],
PathVerb::Close => vec![],
})
.collect()
}
}