1mod detect;
24pub mod nerd_font;
25mod sample;
26
27use std::borrow::Cow;
28use std::collections::BTreeMap;
29use std::io;
30use std::path::Path;
31use std::sync::Arc;
32
33use toml::de::DeTable;
34use unicode_segmentation::UnicodeSegmentation;
35
36pub use detect::{default_font_dirs, detect_glyph_mode};
37pub use sample::GlyphSample;
38
39use crate::animation::{self, CellAnimation};
40use crate::assets;
41use crate::diagnostics::Diagnostic;
42use crate::doc::{self, Doc, Value};
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct IconGlyphs {
47 pub nerd: String,
49 pub unicode: String,
51 pub ascii: String,
53}
54
55impl IconGlyphs {
56 #[must_use]
58 pub fn for_mode(&self, mode: GlyphMode) -> &str {
59 match mode {
60 GlyphMode::Nerd => &self.nerd,
61 GlyphMode::Unicode => &self.unicode,
62 GlyphMode::Ascii => &self.ascii,
63 }
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
69pub enum IconMode {
70 #[default]
72 Auto,
73 Nerd,
75 Unicode,
77 Ascii,
79}
80
81impl IconMode {
82 pub const ALL: [Self; 4] = [Self::Auto, Self::Nerd, Self::Unicode, Self::Ascii];
84
85 #[must_use]
87 pub fn name(self) -> &'static str {
88 match self {
89 Self::Auto => "auto",
90 Self::Nerd => "nerd",
91 Self::Unicode => "unicode",
92 Self::Ascii => "ascii",
93 }
94 }
95
96 #[must_use]
98 pub fn from_name(name: &str) -> Option<Self> {
99 let name = name.trim().to_ascii_lowercase();
100 Self::ALL.into_iter().find(|mode| mode.name() == name)
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum GlyphMode {
107 Nerd,
109 Unicode,
111 Ascii,
113}
114
115const BANNED_ASCII: [char; 6] = ['[', ']', '(', ')', '{', '}'];
117
118pub(crate) fn parse_glyphs(
125 doc: &Doc<'_>,
126 key: &str,
127 value: &Value<'_>,
128 report: &mut Vec<Diagnostic>,
129) -> Result<IconGlyphs, Diagnostic> {
130 let table = doc.table(value, &format!("icon `{key}`"))?;
131 let field = |name: &str| -> Result<Option<String>, Diagnostic> {
132 let Some(entry) = doc::get(table, name) else {
133 return Ok(None);
134 };
135 let text = doc.string(entry, &format!("icon `{key}`.{name}"))?;
136 if text.is_empty() {
137 return Err(doc.error(&entry.span(), format!("icon `{key}`.{name} must not be empty")));
138 }
139 Ok(Some(text.to_owned()))
140 };
141 let (nerd, unicode, ascii) = (field("nerd")?, field("unicode")?, field("ascii")?);
142 if let Some((unknown, entry)) =
143 table.iter().find(|(name, _)| !["nerd", "unicode", "ascii"].contains(&name.get_ref().as_ref()))
144 {
145 return Err(doc.error(
146 &entry.span(),
147 format!("icon `{key}` has unknown field `{}`; use nerd, unicode and ascii", unknown.get_ref()),
148 ));
149 }
150 let Some(ascii) = ascii else {
151 return Err(doc.error(
152 &value.span(),
153 format!("icon `{key}` is missing its `ascii` glyph, which every terminal can draw; the icon is skipped"),
154 ));
155 };
156 let ascii_ok = ascii.chars().all(|c| c.is_ascii() && !c.is_ascii_control());
157 if !ascii_ok {
158 return Err(doc.error(&value.span(), format!("icon `{key}`.ascii must contain only printable ASCII")));
159 }
160 if let Some(bad) = ascii.chars().find(|c| BANNED_ASCII.contains(c)) {
161 return Err(
162 doc.error(&value.span(), format!("icon `{key}`.ascii uses `{bad}`; brackets are not allowed as glyphs"))
163 );
164 }
165 let mut stand_in = |missing: &str, used: &str| {
166 report.push(doc.warning(
167 &value.span(),
168 format!("icon `{key}` is missing its `{missing}` glyph; its `{used}` glyph stands in"),
169 ));
170 };
171 let (unicode, plainer) = match unicode {
172 Some(unicode) => (unicode, "unicode"),
173 None => {
174 stand_in("unicode", "ascii");
175 (ascii.clone(), "ascii")
176 }
177 };
178 let nerd = nerd.unwrap_or_else(|| {
179 stand_in("nerd", plainer);
180 unicode.clone()
181 });
182 Ok(IconGlyphs { nerd, unicode, ascii })
183}
184
185pub const PILLAR: &str = "pillar";
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum PillarStyle {
191 Thick,
193 Thin,
195}
196
197impl PillarStyle {
198 pub const ALL: [Self; 2] = [Self::Thick, Self::Thin];
200
201 #[must_use]
203 pub fn name(self) -> &'static str {
204 match self {
205 Self::Thick => "thick",
206 Self::Thin => "thin",
207 }
208 }
209
210 #[must_use]
212 pub fn from_name(name: &str) -> Option<Self> {
213 let name = name.trim().to_ascii_lowercase();
214 Self::ALL.into_iter().find(|style| style.name() == name)
215 }
216
217 #[must_use]
219 pub fn glyphs(self) -> IconGlyphs {
220 pillar_glyphs(match self {
221 Self::Thick => "▌",
222 Self::Thin => "▎",
223 })
224 }
225}
226
227fn pillar_glyphs(glyph: &str) -> IconGlyphs {
229 IconGlyphs { nerd: glyph.to_owned(), unicode: glyph.to_owned(), ascii: " ".to_owned() }
230}
231
232fn parse_pillar(doc: &Doc<'_>, value: &Value<'_>) -> Result<IconGlyphs, Diagnostic> {
235 let text = doc.string(value, "icon `pillar`")?;
236 if let Some(style) = PillarStyle::from_name(text) {
237 return Ok(style.glyphs());
238 }
239 if crate::text::width(text) == 1 && text.chars().count() == 1 {
240 Ok(pillar_glyphs(text))
241 } else {
242 Err(doc.error(
243 &value.span(),
244 format!("icon `pillar` is `{text}`; use \"thick\", \"thin\" or a single one-cell character"),
245 ))
246 }
247}
248
249pub(crate) fn read_icon_table(
252 doc: &Doc<'_>,
253 table: &DeTable<'_>,
254 glyphs: &mut BTreeMap<String, IconGlyphs>,
255 report: &mut Vec<Diagnostic>,
256) {
257 for (key, value) in table {
258 let parsed = if key.get_ref() == PILLAR && value.get_ref().as_str().is_some() {
259 parse_pillar(doc, value)
260 } else {
261 parse_glyphs(doc, key.get_ref(), value, report)
262 .and_then(|glyphs| legacy_glyphs(doc, key.get_ref(), value, glyphs))
263 };
264 match parsed {
265 Ok(parsed) => {
266 glyphs.insert(key.get_ref().to_string(), parsed);
267 }
268 Err(diagnostic) => report.push(diagnostic),
269 }
270 }
271}
272
273const FORMER_KEYS: &[(&str, &str)] = &[("family", "ecosystem")];
277
278fn legacy_glyphs(doc: &Doc<'_>, key: &str, value: &Value<'_>, glyphs: IconGlyphs) -> Result<IconGlyphs, Diagnostic> {
280 if !animation::LEGACY_ICONS.iter().any(|(icon, _)| *icon == key) {
281 return Ok(glyphs);
282 }
283 animation::check_legacy(&glyphs).map_err(|message| doc.error(&value.span(), format!("icon `{key}`: {message}")))?;
284 Ok(glyphs)
285}
286
287fn layer_animations(
290 animations: &mut BTreeMap<String, Arc<CellAnimation>>,
291 glyphs: &BTreeMap<String, IconGlyphs>,
292 own: impl IntoIterator<Item = (String, Arc<CellAnimation>)>,
293) {
294 for (icon, name) in animation::LEGACY_ICONS {
295 if let Some(glyphs) = glyphs.get(icon) {
296 animation::apply_legacy(animations, name, glyphs);
297 }
298 }
299 animations.extend(own);
300}
301
302#[derive(Debug, Clone)]
304struct IconSetSource {
305 name: String,
306 glyphs: BTreeMap<String, IconGlyphs>,
307 animations: BTreeMap<String, Arc<CellAnimation>>,
308}
309
310#[derive(Debug, Clone)]
320pub struct IconSetRegistry {
321 sets: BTreeMap<String, IconSetSource>,
322 added: Vec<String>,
324 diagnostics: Vec<Diagnostic>,
325}
326
327impl IconSetRegistry {
328 #[must_use]
330 pub fn builtin() -> Self {
331 let mut registry = Self { sets: BTreeMap::new(), added: Vec::new(), diagnostics: Vec::new() };
332 for (id, text) in assets::ICON_SETS {
333 registry.add_source(id, &format!("{id}.toml"), text);
334 }
335 registry.added.clear();
336 registry
337 }
338
339 pub fn add_source(&mut self, id: &str, file: &str, text: &str) -> bool {
344 let doc = Doc::new(file, text);
345 let root = match doc.parse() {
346 Ok(root) => root,
347 Err(diagnostic) => {
348 self.diagnostics.push(diagnostic);
349 return false;
350 }
351 };
352 for (key, value) in &root {
353 if !["meta", "icons", "animations"].contains(&key.get_ref().as_ref()) {
354 self.diagnostics.push(doc.error(
355 &value.span(),
356 format!("unknown section `{}`; expected meta, icons and animations", key.get_ref()),
357 ));
358 }
359 }
360 let name = self.read_name(&doc, &root).unwrap_or_else(|| id.to_owned());
361 let mut glyphs = BTreeMap::new();
362 match doc::get(&root, "icons") {
363 Some(icons) => match doc.table(icons, "icons") {
364 Ok(table) => read_icon_table(&doc, table, &mut glyphs, &mut self.diagnostics),
365 Err(diagnostic) => self.diagnostics.push(diagnostic),
366 },
367 None => self.diagnostics.push(Diagnostic::error(None, format!("{file}: missing [icons] table"))),
368 }
369 let mut animations = BTreeMap::new();
370 if let Some(table) = doc::get(&root, "animations") {
371 match doc.table(table, "animations") {
372 Ok(table) => animation::read_animation_table(&doc, table, &mut animations, &mut self.diagnostics),
373 Err(diagnostic) => self.diagnostics.push(diagnostic),
374 }
375 }
376 let animations = animations.into_iter().map(|(name, animation)| (name, Arc::new(animation))).collect();
377 self.sets.insert(id.to_owned(), IconSetSource { name, glyphs, animations });
378 self.added.retain(|added| added != id);
379 self.added.push(id.to_owned());
380 true
381 }
382
383 fn read_name(&mut self, doc: &Doc<'_>, root: &DeTable<'_>) -> Option<String> {
385 let meta = match doc.table(doc::get(root, "meta")?, "meta") {
386 Ok(meta) => meta,
387 Err(diagnostic) => {
388 self.diagnostics.push(diagnostic);
389 return None;
390 }
391 };
392 let mut name = None;
393 for (key, value) in meta {
394 if key.get_ref() != "name" {
395 self.diagnostics.push(doc.error(&value.span(), format!("unknown key `meta.{}`", key.get_ref())));
396 continue;
397 }
398 match doc.string(value, "meta.name") {
399 Ok(text) => name = Some(text.to_owned()),
400 Err(diagnostic) => self.diagnostics.push(diagnostic),
401 }
402 }
403 name
404 }
405
406 pub fn load_dir(&mut self, dir: &Path) -> io::Result<()> {
413 let found = assets::read_toml_dir(dir)?;
414 self.diagnostics.extend(found.skipped);
415 for (id, file, text) in found.files {
416 self.add_source(&id, &file, &text);
417 }
418 Ok(())
419 }
420
421 #[must_use]
423 pub fn list(&self) -> Vec<(String, String)> {
424 self.sets.iter().map(|(id, set)| (id.clone(), set.name.clone())).collect()
425 }
426
427 #[must_use]
429 pub fn diagnostics(&self) -> &[Diagnostic] {
430 &self.diagnostics
431 }
432
433 #[must_use]
437 pub fn icons(&self, id: &str, overrides: &BTreeMap<String, IconGlyphs>, mode: GlyphMode) -> Icons {
438 self.icons_with_animations(id, overrides, &BTreeMap::new(), mode)
439 }
440
441 #[must_use]
449 pub fn icons_with_animations(
450 &self,
451 id: &str,
452 overrides: &BTreeMap<String, IconGlyphs>,
453 animations: &BTreeMap<String, Arc<CellAnimation>>,
454 mode: GlyphMode,
455 ) -> Icons {
456 let fallback = self.sets.get("default");
457 let chosen = self.sets.get(id);
458 let owned = |source: &IconSetSource| source.animations.clone();
459 let mut layered = BTreeMap::new();
460 if let Some(default) = fallback {
461 layer_animations(&mut layered, &default.glyphs, owned(default));
462 }
463 if let Some(set) = chosen.filter(|_| id != "default") {
464 layer_animations(&mut layered, &set.glyphs, owned(set));
465 }
466 layer_animations(&mut layered, overrides, animations.clone());
467 let mut glyphs = self.application_keys();
468 glyphs.extend(chosen.or(fallback).map(|set| set.glyphs.clone()).unwrap_or_default());
469 glyphs.extend(overrides.iter().map(|(k, v)| (k.clone(), v.clone())));
470 glyphs.retain(|key, _| !animation::LEGACY_ICONS.iter().any(|(icon, _)| icon == key));
471 Icons { glyphs, animations: layered, mode }
472 }
473
474 fn application_keys(&self) -> BTreeMap<String, IconGlyphs> {
477 let builtin = self.sets.get("default").map(|set| &set.glyphs);
478 let mut keys = BTreeMap::new();
479 for set in self.added.iter().filter_map(|id| self.sets.get(id)) {
480 let new = set.glyphs.iter().filter(|(key, _)| builtin.is_none_or(|builtin| !builtin.contains_key(*key)));
481 keys.extend(new.map(|(key, glyphs)| (key.clone(), glyphs.clone())));
482 }
483 keys
484 }
485}
486
487#[derive(Debug, Clone, PartialEq, Eq)]
502pub enum Glyph {
503 Key(String),
505 Literal(String),
507}
508
509impl Glyph {
510 #[must_use]
512 pub fn key(key: impl Into<String>) -> Self {
513 Self::Key(key.into())
514 }
515
516 #[must_use]
520 pub fn literal(glyph: impl Into<String>) -> Self {
521 Self::Literal(glyph.into())
522 }
523
524 #[must_use]
526 pub fn resolve<'a>(&'a self, icons: &'a Icons) -> Cow<'a, str> {
527 match self {
528 Self::Key(key) => icons.glyph(key),
529 Self::Literal(glyph) => Cow::Borrowed(glyph),
530 }
531 }
532}
533
534impl From<&str> for Glyph {
535 fn from(key: &str) -> Self {
536 Self::key(key)
537 }
538}
539
540impl From<String> for Glyph {
541 fn from(key: String) -> Self {
542 Self::Key(key)
543 }
544}
545
546#[derive(Debug, Clone, PartialEq, Eq)]
548pub struct Icons {
549 glyphs: BTreeMap<String, IconGlyphs>,
550 animations: BTreeMap<String, Arc<CellAnimation>>,
551 mode: GlyphMode,
552}
553
554impl Icons {
555 #[must_use]
557 pub fn mode(&self) -> GlyphMode {
558 self.mode
559 }
560
561 pub fn set_mode(&mut self, mode: GlyphMode) {
563 self.mode = mode;
564 }
565
566 fn lookup(&self, key: &str) -> Option<&IconGlyphs> {
568 self.glyphs.get(key).or_else(|| {
569 let (_, now) = FORMER_KEYS.iter().find(|(former, _)| *former == key)?;
570 self.glyphs.get(*now)
571 })
572 }
573
574 #[must_use]
576 pub fn glyph(&self, key: &str) -> Cow<'_, str> {
577 match self.lookup(key) {
578 Some(glyphs) => Cow::Borrowed(glyphs.for_mode(self.mode)),
579 None => Cow::Owned(format!("⟦{key}⟧")),
580 }
581 }
582
583 #[must_use]
585 pub fn frames(&self, key: &str) -> Vec<String> {
586 self.glyph(key).graphemes(true).map(str::to_owned).collect()
587 }
588
589 #[must_use]
591 pub fn glyphs(&self, key: &str) -> Option<&IconGlyphs> {
592 self.lookup(key)
593 }
594
595 #[must_use]
597 pub fn contains(&self, key: &str) -> bool {
598 self.lookup(key).is_some()
599 }
600
601 pub fn keys(&self) -> impl Iterator<Item = &str> {
603 self.glyphs.keys().map(String::as_str)
604 }
605
606 #[must_use]
608 pub fn animation(&self, name: &str) -> Option<&Arc<CellAnimation>> {
609 self.animations.get(name)
610 }
611
612 pub fn animation_names(&self) -> impl Iterator<Item = &str> {
614 self.animations.keys().map(String::as_str)
615 }
616}
617
618#[cfg(test)]
619mod tests;