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
273fn legacy_glyphs(doc: &Doc<'_>, key: &str, value: &Value<'_>, glyphs: IconGlyphs) -> Result<IconGlyphs, Diagnostic> {
275 if !animation::LEGACY_ICONS.iter().any(|(icon, _)| *icon == key) {
276 return Ok(glyphs);
277 }
278 animation::check_legacy(&glyphs).map_err(|message| doc.error(&value.span(), format!("icon `{key}`: {message}")))?;
279 Ok(glyphs)
280}
281
282fn layer_animations(
285 animations: &mut BTreeMap<String, Arc<CellAnimation>>,
286 glyphs: &BTreeMap<String, IconGlyphs>,
287 own: impl IntoIterator<Item = (String, Arc<CellAnimation>)>,
288) {
289 for (icon, name) in animation::LEGACY_ICONS {
290 if let Some(glyphs) = glyphs.get(icon) {
291 animation::apply_legacy(animations, name, glyphs);
292 }
293 }
294 animations.extend(own);
295}
296
297#[derive(Debug, Clone)]
299struct IconSetSource {
300 name: String,
301 glyphs: BTreeMap<String, IconGlyphs>,
302 animations: BTreeMap<String, Arc<CellAnimation>>,
303}
304
305#[derive(Debug, Clone)]
315pub struct IconSetRegistry {
316 sets: BTreeMap<String, IconSetSource>,
317 added: Vec<String>,
319 diagnostics: Vec<Diagnostic>,
320}
321
322impl IconSetRegistry {
323 #[must_use]
325 pub fn builtin() -> Self {
326 let mut registry = Self { sets: BTreeMap::new(), added: Vec::new(), diagnostics: Vec::new() };
327 for (id, text) in assets::ICON_SETS {
328 registry.add_source(id, &format!("{id}.toml"), text);
329 }
330 registry.added.clear();
331 registry
332 }
333
334 pub fn add_source(&mut self, id: &str, file: &str, text: &str) -> bool {
339 let doc = Doc::new(file, text);
340 let root = match doc.parse() {
341 Ok(root) => root,
342 Err(diagnostic) => {
343 self.diagnostics.push(diagnostic);
344 return false;
345 }
346 };
347 for (key, value) in &root {
348 if !["meta", "icons", "animations"].contains(&key.get_ref().as_ref()) {
349 self.diagnostics.push(doc.error(
350 &value.span(),
351 format!("unknown section `{}`; expected meta, icons and animations", key.get_ref()),
352 ));
353 }
354 }
355 let name = self.read_name(&doc, &root).unwrap_or_else(|| id.to_owned());
356 let mut glyphs = BTreeMap::new();
357 match doc::get(&root, "icons") {
358 Some(icons) => match doc.table(icons, "icons") {
359 Ok(table) => read_icon_table(&doc, table, &mut glyphs, &mut self.diagnostics),
360 Err(diagnostic) => self.diagnostics.push(diagnostic),
361 },
362 None => self.diagnostics.push(Diagnostic::error(None, format!("{file}: missing [icons] table"))),
363 }
364 let mut animations = BTreeMap::new();
365 if let Some(table) = doc::get(&root, "animations") {
366 match doc.table(table, "animations") {
367 Ok(table) => animation::read_animation_table(&doc, table, &mut animations, &mut self.diagnostics),
368 Err(diagnostic) => self.diagnostics.push(diagnostic),
369 }
370 }
371 let animations = animations.into_iter().map(|(name, animation)| (name, Arc::new(animation))).collect();
372 self.sets.insert(id.to_owned(), IconSetSource { name, glyphs, animations });
373 self.added.retain(|added| added != id);
374 self.added.push(id.to_owned());
375 true
376 }
377
378 fn read_name(&mut self, doc: &Doc<'_>, root: &DeTable<'_>) -> Option<String> {
380 let meta = match doc.table(doc::get(root, "meta")?, "meta") {
381 Ok(meta) => meta,
382 Err(diagnostic) => {
383 self.diagnostics.push(diagnostic);
384 return None;
385 }
386 };
387 let mut name = None;
388 for (key, value) in meta {
389 if key.get_ref() != "name" {
390 self.diagnostics.push(doc.error(&value.span(), format!("unknown key `meta.{}`", key.get_ref())));
391 continue;
392 }
393 match doc.string(value, "meta.name") {
394 Ok(text) => name = Some(text.to_owned()),
395 Err(diagnostic) => self.diagnostics.push(diagnostic),
396 }
397 }
398 name
399 }
400
401 pub fn load_dir(&mut self, dir: &Path) -> io::Result<()> {
408 let found = assets::read_toml_dir(dir)?;
409 self.diagnostics.extend(found.skipped);
410 for (id, file, text) in found.files {
411 self.add_source(&id, &file, &text);
412 }
413 Ok(())
414 }
415
416 #[must_use]
418 pub fn list(&self) -> Vec<(String, String)> {
419 self.sets.iter().map(|(id, set)| (id.clone(), set.name.clone())).collect()
420 }
421
422 #[must_use]
424 pub fn diagnostics(&self) -> &[Diagnostic] {
425 &self.diagnostics
426 }
427
428 #[must_use]
432 pub fn icons(&self, id: &str, overrides: &BTreeMap<String, IconGlyphs>, mode: GlyphMode) -> Icons {
433 self.icons_with_animations(id, overrides, &BTreeMap::new(), mode)
434 }
435
436 #[must_use]
444 pub fn icons_with_animations(
445 &self,
446 id: &str,
447 overrides: &BTreeMap<String, IconGlyphs>,
448 animations: &BTreeMap<String, Arc<CellAnimation>>,
449 mode: GlyphMode,
450 ) -> Icons {
451 let fallback = self.sets.get("default");
452 let chosen = self.sets.get(id);
453 let owned = |source: &IconSetSource| source.animations.clone();
454 let mut layered = BTreeMap::new();
455 if let Some(default) = fallback {
456 layer_animations(&mut layered, &default.glyphs, owned(default));
457 }
458 if let Some(set) = chosen.filter(|_| id != "default") {
459 layer_animations(&mut layered, &set.glyphs, owned(set));
460 }
461 layer_animations(&mut layered, overrides, animations.clone());
462 let mut glyphs = self.application_keys();
463 glyphs.extend(chosen.or(fallback).map(|set| set.glyphs.clone()).unwrap_or_default());
464 glyphs.extend(overrides.iter().map(|(k, v)| (k.clone(), v.clone())));
465 glyphs.retain(|key, _| !animation::LEGACY_ICONS.iter().any(|(icon, _)| icon == key));
466 Icons { glyphs, animations: layered, mode }
467 }
468
469 fn application_keys(&self) -> BTreeMap<String, IconGlyphs> {
472 let builtin = self.sets.get("default").map(|set| &set.glyphs);
473 let mut keys = BTreeMap::new();
474 for set in self.added.iter().filter_map(|id| self.sets.get(id)) {
475 let new = set.glyphs.iter().filter(|(key, _)| builtin.is_none_or(|builtin| !builtin.contains_key(*key)));
476 keys.extend(new.map(|(key, glyphs)| (key.clone(), glyphs.clone())));
477 }
478 keys
479 }
480}
481
482#[derive(Debug, Clone, PartialEq, Eq)]
497pub enum Glyph {
498 Key(String),
500 Literal(String),
502}
503
504impl Glyph {
505 #[must_use]
507 pub fn key(key: impl Into<String>) -> Self {
508 Self::Key(key.into())
509 }
510
511 #[must_use]
515 pub fn literal(glyph: impl Into<String>) -> Self {
516 Self::Literal(glyph.into())
517 }
518
519 #[must_use]
521 pub fn resolve<'a>(&'a self, icons: &'a Icons) -> Cow<'a, str> {
522 match self {
523 Self::Key(key) => icons.glyph(key),
524 Self::Literal(glyph) => Cow::Borrowed(glyph),
525 }
526 }
527}
528
529impl From<&str> for Glyph {
530 fn from(key: &str) -> Self {
531 Self::key(key)
532 }
533}
534
535impl From<String> for Glyph {
536 fn from(key: String) -> Self {
537 Self::Key(key)
538 }
539}
540
541#[derive(Debug, Clone, PartialEq, Eq)]
543pub struct Icons {
544 glyphs: BTreeMap<String, IconGlyphs>,
545 animations: BTreeMap<String, Arc<CellAnimation>>,
546 mode: GlyphMode,
547}
548
549impl Icons {
550 #[must_use]
552 pub fn mode(&self) -> GlyphMode {
553 self.mode
554 }
555
556 pub fn set_mode(&mut self, mode: GlyphMode) {
558 self.mode = mode;
559 }
560
561 #[must_use]
563 pub fn glyph(&self, key: &str) -> Cow<'_, str> {
564 match self.glyphs.get(key) {
565 Some(glyphs) => Cow::Borrowed(glyphs.for_mode(self.mode)),
566 None => Cow::Owned(format!("⟦{key}⟧")),
567 }
568 }
569
570 #[must_use]
572 pub fn frames(&self, key: &str) -> Vec<String> {
573 self.glyph(key).graphemes(true).map(str::to_owned).collect()
574 }
575
576 #[must_use]
578 pub fn glyphs(&self, key: &str) -> Option<&IconGlyphs> {
579 self.glyphs.get(key)
580 }
581
582 #[must_use]
584 pub fn contains(&self, key: &str) -> bool {
585 self.glyphs.contains_key(key)
586 }
587
588 pub fn keys(&self) -> impl Iterator<Item = &str> {
590 self.glyphs.keys().map(String::as_str)
591 }
592
593 #[must_use]
595 pub fn animation(&self, name: &str) -> Option<&Arc<CellAnimation>> {
596 self.animations.get(name)
597 }
598
599 pub fn animation_names(&self) -> impl Iterator<Item = &str> {
601 self.animations.keys().map(String::as_str)
602 }
603}
604
605#[cfg(test)]
606mod tests;