aura-gui 0.2.1

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.
//! Temas definidos por el usuario en JSON (`~/.config/aura/themes/*.json`).
//!
//! Un tema parte de uno base (`"extends"`, por defecto el oscuro o el claro según `"dark"`) y solo
//! sobrescribe los campos que declara. Los colores son `#rrggbb` o `#rrggbbaa`.

use crate::view::theme::Theme;
use crate::view::themes::theme_by_name;
use crate::core::Rgba;
use serde_json::{Map, Value};
use std::path::Path;
use std::sync::RwLock;

/// Temas cargados de disco; se reemplazan completos en cada recarga.
static USER_THEMES: RwLock<Vec<Theme>> = RwLock::new(Vec::new());

/// Campo de color de la interfaz: nombre en el JSON y acceso mutable al campo del tema.
type ColorField = (&'static str, fn(&mut Theme) -> &mut Rgba);

const COLORS: &[ColorField] = &[
    ("canvas", |t| &mut t.canvas),
    ("surface", |t| &mut t.surface),
    ("elevated_surface", |t| &mut t.elevated_surface),
    ("border", |t| &mut t.border),
    ("border_subtle", |t| &mut t.border_subtle),
    ("border_focused", |t| &mut t.border_focused),
    ("text", |t| &mut t.text),
    ("text_muted", |t| &mut t.text_muted),
    ("accent", |t| &mut t.accent),
    ("accent_hover", |t| &mut t.accent_hover),
    ("accent_text", |t| &mut t.accent_text),
    ("danger", |t| &mut t.danger),
    ("danger_hover", |t| &mut t.danger_hover),
    ("danger_text", |t| &mut t.danger_text),
    ("warning", |t| &mut t.warning),
    ("success", |t| &mut t.success),
    ("created", |t| &mut t.created),
    ("modified", |t| &mut t.modified),
    ("deleted", |t| &mut t.deleted),
    ("conflict", |t| &mut t.conflict),
    ("hover", |t| &mut t.hover),
    ("active", |t| &mut t.active),
    ("selection", |t| &mut t.selection),
    ("shadow", |t| &mut t.shadow),
];

const SYNTAX: &[ColorField] = &[
    ("keyword", |t| &mut t.syntax.keyword),
    ("function", |t| &mut t.syntax.function),
    ("type", |t| &mut t.syntax.type_name),
    ("string", |t| &mut t.syntax.string),
    ("comment", |t| &mut t.syntax.comment),
    ("constant", |t| &mut t.syntax.constant),
    ("attribute", |t| &mut t.syntax.attribute),
    ("operator", |t| &mut t.syntax.operator),
    ("property", |t| &mut t.syntax.property),
    ("tag", |t| &mut t.syntax.tag),
    ("title", |t| &mut t.syntax.title),
    ("link", |t| &mut t.syntax.link),
];

/// Interpreta `#rrggbb` o `#rrggbbaa`.
pub fn parse_color(text: &str) -> Result<Rgba, String> {
    let digits = text.strip_prefix('#').ok_or_else(|| format!("«{text}» debe empezar con #"))?;
    let value = u32::from_str_radix(digits, 16).map_err(|_| format!("«{text}» no es un color hexadecimal"))?;
    match digits.len() {
        6 => Ok(Rgba::hex(value)),
        8 => Ok(Rgba::hex(value >> 8).with_alpha((value & 0xff) as f32 / 255.0)),
        _ => Err(format!("«{text}» debe tener 6 u 8 dígitos")),
    }
}

fn apply_colors(
    theme: &mut Theme,
    table: &[ColorField],
    values: &Map<String, Value>,
    section: &str,
) -> Result<(), String> {
    for (key, value) in values {
        let (_, field) =
            table.iter().find(|(name, _)| name == key).ok_or_else(|| format!("{section}.{key}: campo desconocido"))?;
        let text = value.as_str().ok_or_else(|| format!("{section}.{key}: se esperaba un texto"))?;
        *field(theme) = parse_color(text).map_err(|error| format!("{section}.{key}: {error}"))?;
    }
    Ok(())
}

/// Construye un tema desde su JSON, completando lo que falte con el tema base.
pub fn parse_theme(json: &str) -> Result<Theme, String> {
    let root: Value = serde_json::from_str(json).map_err(|error| error.to_string())?;
    let root = root.as_object().ok_or("se esperaba un objeto")?;
    let name = root.get("name").and_then(Value::as_str).ok_or("falta «name»")?;
    let dark = root.get("dark").and_then(Value::as_bool);
    let base = match root.get("extends").and_then(Value::as_str) {
        Some(base) => theme_by_name(base).ok_or_else(|| format!("extends: no existe el tema «{base}»"))?,
        None if dark == Some(false) => Theme::light(),
        None => Theme::dark(),
    };
    let mut theme = Theme { name: name.to_owned(), is_dark: dark.unwrap_or(base.is_dark), ..base };
    if let Some(colors) = root.get("colors").and_then(Value::as_object) {
        apply_colors(&mut theme, COLORS, colors, "colors")?;
    }
    if let Some(syntax) = root.get("syntax").and_then(Value::as_object) {
        apply_colors(&mut theme, SYNTAX, syntax, "syntax")?;
    }
    if let Some(ansi) = root.get("ansi") {
        let list = ansi.as_array().filter(|list| list.len() == 16).ok_or("ansi: se esperaban 16 colores")?;
        for (slot, value) in theme.ansi.iter_mut().zip(list) {
            *slot = parse_color(value.as_str().unwrap_or_default()).map_err(|error| format!("ansi: {error}"))?;
        }
    }
    Ok(theme)
}

/// Lee todos los `*.json` de `dir`. Devuelve los temas válidos y un error por cada archivo roto.
pub fn load_theme_dir(dir: &Path) -> (Vec<Theme>, Vec<String>) {
    let Ok(entries) = std::fs::read_dir(dir) else { return (Vec::new(), Vec::new()) };
    let mut paths: Vec<_> = entries.flatten().map(|entry| entry.path()).collect();
    paths.retain(|path| path.extension().is_some_and(|extension| extension == "json"));
    paths.sort();
    let (mut themes, mut errors) = (Vec::new(), Vec::new());
    for path in paths {
        let file = path.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default();
        match std::fs::read_to_string(&path).map_err(|error| error.to_string()).and_then(|text| parse_theme(&text)) {
            Ok(theme) => themes.push(theme),
            Err(error) => errors.push(format!("Tema {file}: {error}")),
        }
    }
    (themes, errors)
}

/// Reemplaza los temas de usuario registrados.
pub fn set_user_themes(themes: Vec<Theme>) {
    if let Ok(mut current) = USER_THEMES.write() {
        *current = themes;
    }
}

pub fn user_themes() -> Vec<Theme> {
    USER_THEMES.read().map(|themes| themes.clone()).unwrap_or_default()
}

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

    #[test]
    fn hereda_y_sobrescribe() {
        let theme = parse_theme(r##"{"name":"Mio","extends":"One Dark","colors":{"accent":"#ff0000"}}"##).unwrap();
        assert_eq!(theme.name, "Mio");
        assert_eq!(theme.accent, Rgba::hex(0xff0000));
        assert_eq!(theme.canvas, theme_by_name("One Dark").unwrap().canvas);
    }

    #[test]
    fn color_con_alfa() {
        let color = parse_color("#00ff0080").unwrap();
        assert!((color.a - 128.0 / 255.0).abs() < 1e-6);
    }

    #[test]
    fn errores_nombran_el_campo() {
        let error = parse_theme(r##"{"name":"X","colors":{"accent":"rojo"}}"##).unwrap_err();
        assert!(error.starts_with("colors.accent"), "{error}");
        assert!(parse_theme(r##"{"name":"X","colors":{"nada":"#fff000"}}"##).unwrap_err().contains("desconocido"));
        assert!(parse_theme(r##"{"name":"X","extends":"No Existe"}"##).is_err());
    }
}