aura-gui 0.2.2

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Familias de fuentes elegidas por el usuario (por ejemplo, la de la terminal).
//!
//! Al arrancar solo se cargan unas pocas fuentes; una familia con nombre se busca en las
//! carpetas de fuentes del sistema la primera vez que se pide y se recuerda, exista o no.

use cosmic_text::{FontSystem, fontdb};
use std::path::{Path, PathBuf};

/// Familia registrada en el `TextSystem`; se resuelve por nombre al dar forma al texto.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct FontFamily(pub(crate) u32);

/// Nombres ya pedidos y su resultado. Los nombres viven lo que el proceso: son pocos y cortos.
#[derive(Default)]
pub(crate) struct FontFamilies {
    known: Vec<(String, Option<&'static str>)>,
    names: Vec<&'static str>,
}

impl FontFamilies {
    pub(crate) fn name(&self, family: FontFamily) -> Option<&'static str> {
        self.names.get(family.0 as usize).copied()
    }

    /// Familia por nombre (sin distinguir mayúsculas); `None` si no hay ninguna fuente con ese nombre.
    pub(crate) fn resolve(&mut self, fonts: &mut FontSystem, requested: &str) -> Option<FontFamily> {
        let requested = requested.trim();
        if requested.is_empty() {
            return None;
        }
        let found = match self.known.iter().find(|(name, _)| name.eq_ignore_ascii_case(requested)) {
            Some((_, found)) => *found,
            None => {
                let found = loaded_family(fonts.db(), requested).or_else(|| {
                    load_matching_files(fonts.db_mut(), requested);
                    loaded_family(fonts.db(), requested)
                });
                let found: Option<&'static str> = found.map(|name| Box::leak(name.into_boxed_str()) as &str);
                self.known.push((requested.to_owned(), found));
                found
            }
        }?;
        let index = match self.names.iter().position(|name| *name == found) {
            Some(index) => index,
            None => {
                self.names.push(found);
                self.names.len() - 1
            }
        };
        Some(FontFamily(index as u32))
    }
}

/// Nombre exacto con el que la base de fuentes conoce a `requested`.
fn loaded_family(database: &fontdb::Database, requested: &str) -> Option<String> {
    database
        .faces()
        .flat_map(|face| face.families.iter())
        .find(|(name, _)| name.eq_ignore_ascii_case(requested))
        .map(|(name, _)| name.clone())
}

/// Letras y dígitos en minúscula: «JetBrains Mono» y `JetBrainsMono-Regular.ttf` coinciden.
fn normalized(text: &str) -> String {
    text.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect()
}

fn font_directories() -> Vec<PathBuf> {
    let home = crate::core::platform::home_dir();
    let mut directories: Vec<PathBuf> = if cfg!(target_os = "macos") {
        ["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"].map(PathBuf::from).into()
    } else if cfg!(target_os = "ios") {
        ["/System/Library/Fonts/Core", "/System/Library/Fonts/CoreUI"].map(crate::gpu::fonts::system_path).into()
    } else if cfg!(target_os = "android") {
        vec![PathBuf::from("/system/fonts")]
    } else if cfg!(windows) {
        // Fuentes del sistema (en la unidad donde esté instalado Windows) y las instaladas solo para el usuario.
        let windir = std::env::var_os("WINDIR").map_or_else(|| PathBuf::from("C:\\Windows"), PathBuf::from);
        let per_user = std::env::var_os("LOCALAPPDATA").map(|base| PathBuf::from(base).join("Microsoft/Windows/Fonts"));
        std::iter::once(windir.join("Fonts")).chain(per_user).collect()
    } else {
        vec![PathBuf::from("/usr/share/fonts"), PathBuf::from("/usr/local/share/fonts")]
    };
    if let Some(home) = home {
        directories.extend(["Library/Fonts", ".local/share/fonts", ".fonts"].map(|path| home.join(path)));
    }
    directories
}

/// Carga los archivos cuyo nombre empieza por la familia pedida (dos niveles de carpetas como máximo).
fn load_matching_files(database: &mut fontdb::Database, requested: &str) {
    let wanted = normalized(requested);
    let mut pending: Vec<(PathBuf, u8)> = font_directories().into_iter().map(|path| (path, 0)).collect();
    while let Some((directory, depth)) = pending.pop() {
        let Ok(entries) = std::fs::read_dir(&directory) else { continue };
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                if depth < 2 {
                    pending.push((path, depth + 1));
                }
            } else if is_font_file(&path)
                && file_matches(&path, &wanted)
                && let Err(error) = database.load_font_file(&path)
            {
                eprintln!("no se pudo cargar la fuente {}: {error}", path.display());
            }
        }
    }
}

fn is_font_file(path: &Path) -> bool {
    let extension = path.extension().and_then(|extension| extension.to_str()).unwrap_or_default();
    matches!(extension.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")
}

fn file_matches(path: &Path, wanted: &str) -> bool {
    let stem = path.file_stem().and_then(|stem| stem.to_str()).unwrap_or_default();
    !wanted.is_empty() && normalized(stem).starts_with(wanted)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn el_nombre_del_archivo_se_compara_sin_espacios_ni_mayusculas() {
        let wanted = normalized("JetBrains Mono");
        assert!(file_matches(Path::new("/f/JetBrainsMono-Regular.ttf"), &wanted));
        assert!(!file_matches(Path::new("/f/Menlo.ttc"), &wanted));
        assert!(!file_matches(Path::new("/f/Menlo.ttc"), ""));
        assert!(is_font_file(Path::new("a.TTF")) && !is_font_file(Path::new("a.txt")));
    }

    #[test]
    fn una_familia_desconocida_no_se_registra_y_se_recuerda() {
        let mut fonts = crate::gpu::fonts::build_font_system();
        let mut families = FontFamilies::default();
        assert_eq!(families.resolve(&mut fonts, "  "), None);
        assert_eq!(families.resolve(&mut fonts, "Fuente Que No Existe 123"), None);
        assert_eq!(families.known.len(), 1, "el fallo se recuerda para no volver a recorrer carpetas");
        let loaded = fonts.db().faces().next().and_then(|face| face.families.first()).map(|(name, _)| name.clone());
        if let Some(name) = loaded {
            let family = families.resolve(&mut fonts, &name.to_uppercase()).expect("familia cargada");
            assert_eq!(families.name(family), Some(name.as_str()));
            assert_eq!(families.resolve(&mut fonts, &name), Some(family));
        }
    }
}