mod detect;
pub mod nerd_font;
mod sample;
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::io;
use std::path::Path;
use std::sync::Arc;
use toml::de::DeTable;
use unicode_segmentation::UnicodeSegmentation;
pub use detect::{default_font_dirs, detect_glyph_mode};
pub use sample::GlyphSample;
use crate::animation::{self, CellAnimation};
use crate::assets;
use crate::diagnostics::Diagnostic;
use crate::doc::{self, Doc, Value};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IconGlyphs {
pub nerd: String,
pub unicode: String,
pub ascii: String,
}
impl IconGlyphs {
#[must_use]
pub fn for_mode(&self, mode: GlyphMode) -> &str {
match mode {
GlyphMode::Nerd => &self.nerd,
GlyphMode::Unicode => &self.unicode,
GlyphMode::Ascii => &self.ascii,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum IconMode {
#[default]
Auto,
Nerd,
Unicode,
Ascii,
}
impl IconMode {
pub const ALL: [Self; 4] = [Self::Auto, Self::Nerd, Self::Unicode, Self::Ascii];
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Nerd => "nerd",
Self::Unicode => "unicode",
Self::Ascii => "ascii",
}
}
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
let name = name.trim().to_ascii_lowercase();
Self::ALL.into_iter().find(|mode| mode.name() == name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GlyphMode {
Nerd,
Unicode,
Ascii,
}
const BANNED_ASCII: [char; 6] = ['[', ']', '(', ')', '{', '}'];
pub(crate) fn parse_glyphs(
doc: &Doc<'_>,
key: &str,
value: &Value<'_>,
report: &mut Vec<Diagnostic>,
) -> Result<IconGlyphs, Diagnostic> {
let table = doc.table(value, &format!("icon `{key}`"))?;
let field = |name: &str| -> Result<Option<String>, Diagnostic> {
let Some(entry) = doc::get(table, name) else {
return Ok(None);
};
let text = doc.string(entry, &format!("icon `{key}`.{name}"))?;
if text.is_empty() {
return Err(doc.error(&entry.span(), format!("icon `{key}`.{name} must not be empty")));
}
Ok(Some(text.to_owned()))
};
let (nerd, unicode, ascii) = (field("nerd")?, field("unicode")?, field("ascii")?);
if let Some((unknown, entry)) =
table.iter().find(|(name, _)| !["nerd", "unicode", "ascii"].contains(&name.get_ref().as_ref()))
{
return Err(doc.error(
&entry.span(),
format!("icon `{key}` has unknown field `{}`; use nerd, unicode and ascii", unknown.get_ref()),
));
}
let Some(ascii) = ascii else {
return Err(doc.error(
&value.span(),
format!("icon `{key}` is missing its `ascii` glyph, which every terminal can draw; the icon is skipped"),
));
};
let ascii_ok = ascii.chars().all(|c| c.is_ascii() && !c.is_ascii_control());
if !ascii_ok {
return Err(doc.error(&value.span(), format!("icon `{key}`.ascii must contain only printable ASCII")));
}
if let Some(bad) = ascii.chars().find(|c| BANNED_ASCII.contains(c)) {
return Err(
doc.error(&value.span(), format!("icon `{key}`.ascii uses `{bad}`; brackets are not allowed as glyphs"))
);
}
let mut stand_in = |missing: &str, used: &str| {
report.push(doc.warning(
&value.span(),
format!("icon `{key}` is missing its `{missing}` glyph; its `{used}` glyph stands in"),
));
};
let (unicode, plainer) = match unicode {
Some(unicode) => (unicode, "unicode"),
None => {
stand_in("unicode", "ascii");
(ascii.clone(), "ascii")
}
};
let nerd = nerd.unwrap_or_else(|| {
stand_in("nerd", plainer);
unicode.clone()
});
Ok(IconGlyphs { nerd, unicode, ascii })
}
pub const PILLAR: &str = "pillar";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PillarStyle {
Thick,
Thin,
}
impl PillarStyle {
pub const ALL: [Self; 2] = [Self::Thick, Self::Thin];
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Thick => "thick",
Self::Thin => "thin",
}
}
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
let name = name.trim().to_ascii_lowercase();
Self::ALL.into_iter().find(|style| style.name() == name)
}
#[must_use]
pub fn glyphs(self) -> IconGlyphs {
pillar_glyphs(match self {
Self::Thick => "▌",
Self::Thin => "▎",
})
}
}
fn pillar_glyphs(glyph: &str) -> IconGlyphs {
IconGlyphs { nerd: glyph.to_owned(), unicode: glyph.to_owned(), ascii: " ".to_owned() }
}
fn parse_pillar(doc: &Doc<'_>, value: &Value<'_>) -> Result<IconGlyphs, Diagnostic> {
let text = doc.string(value, "icon `pillar`")?;
if let Some(style) = PillarStyle::from_name(text) {
return Ok(style.glyphs());
}
if crate::text::width(text) == 1 && text.chars().count() == 1 {
Ok(pillar_glyphs(text))
} else {
Err(doc.error(
&value.span(),
format!("icon `pillar` is `{text}`; use \"thick\", \"thin\" or a single one-cell character"),
))
}
}
pub(crate) fn read_icon_table(
doc: &Doc<'_>,
table: &DeTable<'_>,
glyphs: &mut BTreeMap<String, IconGlyphs>,
report: &mut Vec<Diagnostic>,
) {
for (key, value) in table {
let parsed = if key.get_ref() == PILLAR && value.get_ref().as_str().is_some() {
parse_pillar(doc, value)
} else {
parse_glyphs(doc, key.get_ref(), value, report)
.and_then(|glyphs| legacy_glyphs(doc, key.get_ref(), value, glyphs))
};
match parsed {
Ok(parsed) => {
glyphs.insert(key.get_ref().to_string(), parsed);
}
Err(diagnostic) => report.push(diagnostic),
}
}
}
const FORMER_KEYS: &[(&str, &str)] = &[("family", "ecosystem")];
fn legacy_glyphs(doc: &Doc<'_>, key: &str, value: &Value<'_>, glyphs: IconGlyphs) -> Result<IconGlyphs, Diagnostic> {
if !animation::LEGACY_ICONS.iter().any(|(icon, _)| *icon == key) {
return Ok(glyphs);
}
animation::check_legacy(&glyphs).map_err(|message| doc.error(&value.span(), format!("icon `{key}`: {message}")))?;
Ok(glyphs)
}
fn layer_animations(
animations: &mut BTreeMap<String, Arc<CellAnimation>>,
glyphs: &BTreeMap<String, IconGlyphs>,
own: impl IntoIterator<Item = (String, Arc<CellAnimation>)>,
) {
for (icon, name) in animation::LEGACY_ICONS {
if let Some(glyphs) = glyphs.get(icon) {
animation::apply_legacy(animations, name, glyphs);
}
}
animations.extend(own);
}
#[derive(Debug, Clone)]
struct IconSetSource {
name: String,
glyphs: BTreeMap<String, IconGlyphs>,
animations: BTreeMap<String, Arc<CellAnimation>>,
}
#[derive(Debug, Clone)]
pub struct IconSetRegistry {
sets: BTreeMap<String, IconSetSource>,
added: Vec<String>,
diagnostics: Vec<Diagnostic>,
}
impl IconSetRegistry {
#[must_use]
pub fn builtin() -> Self {
let mut registry = Self { sets: BTreeMap::new(), added: Vec::new(), diagnostics: Vec::new() };
for (id, text) in assets::ICON_SETS {
registry.add_source(id, &format!("{id}.toml"), text);
}
registry.added.clear();
registry
}
pub fn add_source(&mut self, id: &str, file: &str, text: &str) -> bool {
let doc = Doc::new(file, text);
let root = match doc.parse() {
Ok(root) => root,
Err(diagnostic) => {
self.diagnostics.push(diagnostic);
return false;
}
};
for (key, value) in &root {
if !["meta", "icons", "animations"].contains(&key.get_ref().as_ref()) {
self.diagnostics.push(doc.error(
&value.span(),
format!("unknown section `{}`; expected meta, icons and animations", key.get_ref()),
));
}
}
let name = self.read_name(&doc, &root).unwrap_or_else(|| id.to_owned());
let mut glyphs = BTreeMap::new();
match doc::get(&root, "icons") {
Some(icons) => match doc.table(icons, "icons") {
Ok(table) => read_icon_table(&doc, table, &mut glyphs, &mut self.diagnostics),
Err(diagnostic) => self.diagnostics.push(diagnostic),
},
None => self.diagnostics.push(Diagnostic::error(None, format!("{file}: missing [icons] table"))),
}
let mut animations = BTreeMap::new();
if let Some(table) = doc::get(&root, "animations") {
match doc.table(table, "animations") {
Ok(table) => animation::read_animation_table(&doc, table, &mut animations, &mut self.diagnostics),
Err(diagnostic) => self.diagnostics.push(diagnostic),
}
}
let animations = animations.into_iter().map(|(name, animation)| (name, Arc::new(animation))).collect();
self.sets.insert(id.to_owned(), IconSetSource { name, glyphs, animations });
self.added.retain(|added| added != id);
self.added.push(id.to_owned());
true
}
fn read_name(&mut self, doc: &Doc<'_>, root: &DeTable<'_>) -> Option<String> {
let meta = match doc.table(doc::get(root, "meta")?, "meta") {
Ok(meta) => meta,
Err(diagnostic) => {
self.diagnostics.push(diagnostic);
return None;
}
};
let mut name = None;
for (key, value) in meta {
if key.get_ref() != "name" {
self.diagnostics.push(doc.error(&value.span(), format!("unknown key `meta.{}`", key.get_ref())));
continue;
}
match doc.string(value, "meta.name") {
Ok(text) => name = Some(text.to_owned()),
Err(diagnostic) => self.diagnostics.push(diagnostic),
}
}
name
}
pub fn load_dir(&mut self, dir: &Path) -> io::Result<()> {
let found = assets::read_toml_dir(dir)?;
self.diagnostics.extend(found.skipped);
for (id, file, text) in found.files {
self.add_source(&id, &file, &text);
}
Ok(())
}
#[must_use]
pub fn list(&self) -> Vec<(String, String)> {
self.sets.iter().map(|(id, set)| (id.clone(), set.name.clone())).collect()
}
#[must_use]
pub fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
#[must_use]
pub fn icons(&self, id: &str, overrides: &BTreeMap<String, IconGlyphs>, mode: GlyphMode) -> Icons {
self.icons_with_animations(id, overrides, &BTreeMap::new(), mode)
}
#[must_use]
pub fn icons_with_animations(
&self,
id: &str,
overrides: &BTreeMap<String, IconGlyphs>,
animations: &BTreeMap<String, Arc<CellAnimation>>,
mode: GlyphMode,
) -> Icons {
let fallback = self.sets.get("default");
let chosen = self.sets.get(id);
let owned = |source: &IconSetSource| source.animations.clone();
let mut layered = BTreeMap::new();
if let Some(default) = fallback {
layer_animations(&mut layered, &default.glyphs, owned(default));
}
if let Some(set) = chosen.filter(|_| id != "default") {
layer_animations(&mut layered, &set.glyphs, owned(set));
}
layer_animations(&mut layered, overrides, animations.clone());
let mut glyphs = self.application_keys();
glyphs.extend(chosen.or(fallback).map(|set| set.glyphs.clone()).unwrap_or_default());
glyphs.extend(overrides.iter().map(|(k, v)| (k.clone(), v.clone())));
glyphs.retain(|key, _| !animation::LEGACY_ICONS.iter().any(|(icon, _)| icon == key));
Icons { glyphs, animations: layered, mode }
}
fn application_keys(&self) -> BTreeMap<String, IconGlyphs> {
let builtin = self.sets.get("default").map(|set| &set.glyphs);
let mut keys = BTreeMap::new();
for set in self.added.iter().filter_map(|id| self.sets.get(id)) {
let new = set.glyphs.iter().filter(|(key, _)| builtin.is_none_or(|builtin| !builtin.contains_key(*key)));
keys.extend(new.map(|(key, glyphs)| (key.clone(), glyphs.clone())));
}
keys
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Glyph {
Key(String),
Literal(String),
}
impl Glyph {
#[must_use]
pub fn key(key: impl Into<String>) -> Self {
Self::Key(key.into())
}
#[must_use]
pub fn literal(glyph: impl Into<String>) -> Self {
Self::Literal(glyph.into())
}
#[must_use]
pub fn resolve<'a>(&'a self, icons: &'a Icons) -> Cow<'a, str> {
match self {
Self::Key(key) => icons.glyph(key),
Self::Literal(glyph) => Cow::Borrowed(glyph),
}
}
}
impl From<&str> for Glyph {
fn from(key: &str) -> Self {
Self::key(key)
}
}
impl From<String> for Glyph {
fn from(key: String) -> Self {
Self::Key(key)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Icons {
glyphs: BTreeMap<String, IconGlyphs>,
animations: BTreeMap<String, Arc<CellAnimation>>,
mode: GlyphMode,
}
impl Icons {
#[must_use]
pub fn mode(&self) -> GlyphMode {
self.mode
}
pub fn set_mode(&mut self, mode: GlyphMode) {
self.mode = mode;
}
fn lookup(&self, key: &str) -> Option<&IconGlyphs> {
self.glyphs.get(key).or_else(|| {
let (_, now) = FORMER_KEYS.iter().find(|(former, _)| *former == key)?;
self.glyphs.get(*now)
})
}
#[must_use]
pub fn glyph(&self, key: &str) -> Cow<'_, str> {
match self.lookup(key) {
Some(glyphs) => Cow::Borrowed(glyphs.for_mode(self.mode)),
None => Cow::Owned(format!("⟦{key}⟧")),
}
}
#[must_use]
pub fn frames(&self, key: &str) -> Vec<String> {
self.glyph(key).graphemes(true).map(str::to_owned).collect()
}
#[must_use]
pub fn glyphs(&self, key: &str) -> Option<&IconGlyphs> {
self.lookup(key)
}
#[must_use]
pub fn contains(&self, key: &str) -> bool {
self.lookup(key).is_some()
}
pub fn keys(&self) -> impl Iterator<Item = &str> {
self.glyphs.keys().map(String::as_str)
}
#[must_use]
pub fn animation(&self, name: &str) -> Option<&Arc<CellAnimation>> {
self.animations.get(name)
}
pub fn animation_names(&self) -> impl Iterator<Item = &str> {
self.animations.keys().map(String::as_str)
}
}
#[cfg(test)]
mod tests;