aura-gui 0.3.5

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.
//! Iconos SVG (máscara teñible) e imágenes (PNG, JPEG, GIF, WebP y SVG a color) en el atlas.
//!
//! Las imágenes pesadas (> 32 KB) se decodifican, redimensionan y premultiplican automáticamente en el
//! `BackgroundExecutor` (núcleos de eficiencia) para nunca bloquear el hilo de la UI durante `paint`.

use crate::core::tasks::{BackgroundExecutor, ForegroundSender};
use crate::gpu::atlas::AtlasTile;
use crate::gpu::text_paint::{GlyphTarget, premultiply_rgba};
use resvg::{tiny_skia, usvg};
use rustc_hash::{FxHashMap, FxHashSet};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, OnceLock};

/// A partir de este tamaño (32 KB), la imagen se decodifica y escala en el pool de hilos en vez del hilo de UI.
const ASYNC_DECODE_THRESHOLD: usize = 32 * 1024;

type DecodedImage = (u64, Option<(u32, u32, Vec<u8>)>);

/// Imagen codificada con un identificador estable para la caché.
#[derive(Clone)]
pub struct ImageSource {
    pub id: u64,
    pub bytes: Arc<[u8]>,
}

pub struct SpriteCache {
    icons: FxHashMap<(&'static str, u32), Option<AtlasTile>>,
    images: FxHashMap<u64, Option<AtlasTile>>,
    in_flight: FxHashSet<u64>,
    tx: Sender<DecodedImage>,
    rx: Receiver<DecodedImage>,
    worker: Option<(BackgroundExecutor, ForegroundSender)>,
}

impl Default for SpriteCache {
    fn default() -> Self {
        let (tx, rx) = mpsc::channel();
        Self {
            icons: FxHashMap::default(),
            images: FxHashMap::default(),
            in_flight: FxHashSet::default(),
            tx,
            rx,
            worker: None,
        }
    }
}

impl SpriteCache {
    /// Conecta el pool de hilos y el despertador de la ventana para decodificar imágenes grandes en segundo plano.
    pub fn set_async_worker(&mut self, executor: BackgroundExecutor, sender: ForegroundSender) {
        self.worker = Some((executor, sender));
    }

    fn flush_decoded(&mut self, target: &mut GlyphTarget) {
        while let Ok((id, decoded)) = self.rx.try_recv() {
            if !self.in_flight.remove(&id) {
                continue;
            }
            let tile = decoded.and_then(|(width, height, premul)| {
                target.polychrome_atlas.insert(target.queue, width, height, &premul)
            });
            self.images.insert(id, tile);
        }
    }

    /// Rasteriza un SVG a `size_px` píxeles físicos; el canal alfa se usa como máscara.
    pub fn icon(
        &mut self,
        name: &'static str,
        svg: &[u8],
        size_px: u32,
        target: &mut GlyphTarget,
    ) -> Option<AtlasTile> {
        if let Some(cached) = self.icons.get(&(name, size_px)) {
            return *cached;
        }
        let tile = rasterize_svg(svg, size_px)
            .and_then(|(width, height, mask)| target.monochrome_atlas.insert(target.queue, width, height, &mask));
        self.icons.insert((name, size_px), tile);
        tile
    }

    pub fn image(&mut self, source: &ImageSource, target: &mut GlyphTarget) -> Option<AtlasTile> {
        self.flush_decoded(target);
        if let Some(cached) = self.images.get(&source.id) {
            return *cached;
        }
        if self.in_flight.contains(&source.id) {
            return None;
        }
        if source.bytes.len() >= ASYNC_DECODE_THRESHOLD
            && let Some((executor, sender)) = &self.worker
        {
            self.in_flight.insert(source.id);
            let id = source.id;
            let bytes = source.bytes.clone();
            let tx = self.tx.clone();
            let sender = sender.clone();
            executor.spawn(move || {
                let decoded = decode_image(&bytes).map(|(w, h, rgba)| (w, h, premultiply_rgba(&rgba)));
                let _ = tx.send((id, decoded));
                sender.send(|entities| {
                    entities.timers.request_animation_frame();
                });
            });
            return None;
        }
        let tile = decode_image(&source.bytes).and_then(|(width, height, rgba)| {
            target.polychrome_atlas.insert(target.queue, width, height, &premultiply_rgba(&rgba))
        });
        self.images.insert(source.id, tile);
        tile
    }

    /// Suelta una imagen que no volverá a pintarse (por ejemplo, un recorte de un diagrama ampliado ya movido).
    pub fn forget_image(&mut self, id: u64, target: &mut GlyphTarget) {
        self.in_flight.remove(&id);
        if let Some(Some(tile)) = self.images.remove(&id) {
            target.polychrome_atlas.remove(&tile);
        }
    }

    /// Las entradas apuntan a regiones del atlas: se invalidan junto con él.
    pub fn clear(&mut self) {
        self.icons.clear();
        self.images.clear();
        self.in_flight.clear();
    }
}

fn parse_svg(svg: &[u8]) -> Option<usvg::Tree> {
    // Las fuentes del sistema solo se indexan la primera vez que un SVG trae texto (diagramas, logos).
    static FONTS: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
    let text = svg.windows(5).any(|window| window == b"<text");
    let options = usvg::Options {
        fontdb: if text {
            FONTS
                .get_or_init(|| {
                    let mut database = usvg::fontdb::Database::new();
                    database.load_system_fonts();
                    Arc::new(database)
                })
                .clone()
        } else {
            Arc::default()
        },
        ..usvg::Options::default()
    };
    usvg::Tree::from_data(svg, &options).map_err(|error| eprintln!("SVG inválido: {error}")).ok()
}

fn is_svg(bytes: &[u8]) -> bool {
    let start = &bytes[..bytes.len().min(256)];
    start.windows(4).any(|window| window == b"<svg")
}

/// Tamaño natural de una imagen (en SVG, el de su `viewBox`) sin decodificar sus píxeles.
pub fn image_size(bytes: &[u8]) -> Option<(f32, f32)> {
    if is_svg(bytes) {
        let size = parse_svg(bytes)?.size();
        return Some((size.width(), size.height()));
    }
    let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
    reader.into_dimensions().ok().map(|(width, height)| (width as f32, height as f32))
}

fn rasterize_svg(svg: &[u8], size_px: u32) -> Option<(u32, u32, Vec<u8>)> {
    let tree = parse_svg(svg)?;
    let size = tree.size();
    let scale = size_px as f32 / size.width().max(size.height()).max(1.0);
    let mut pixmap = tiny_skia::Pixmap::new(size_px.max(1), size_px.max(1))?;
    resvg::render(&tree, tiny_skia::Transform::from_scale(scale, scale), &mut pixmap.as_mut());
    let mask = pixmap.data().as_chunks::<4>().0.iter().map(|pixel| pixel[3]).collect();
    Some((pixmap.width(), pixmap.height(), mask))
}

/// Lado máximo de la imagen que entra al atlas: una foto de cámara no cabe y una miniatura no la necesita.
const MAX_IMAGE_SIDE: u32 = 1024;

/// Un SVG (un diagrama ancho, un recorte ampliado) puede ocupar casi todo el lado del atlas, pero no más de un
/// cuarto de su área: así caben varios a la vista sin vaciarlo en cada frame.
const MAX_SVG_SIDE: f32 = 2046.0;
const MAX_SVG_AREA: f32 = 1_000_000.0;

/// SVG a color al doble de su tamaño (pantallas Retina), dentro del lado y el área máximos.
fn rasterize_svg_color(svg: &[u8]) -> Option<(u32, u32, Vec<u8>)> {
    let tree = parse_svg(svg)?;
    let size = tree.size();
    let (side, area) = (size.width().max(size.height()).max(1.0), (size.width() * size.height()).max(1.0));
    let scale = (MAX_SVG_SIDE / side).min((MAX_SVG_AREA / area).sqrt()).min(2.0);
    let (width, height) = ((size.width() * scale).ceil() as u32, (size.height() * scale).ceil() as u32);
    let mut pixmap = tiny_skia::Pixmap::new(width.max(1), height.max(1))?;
    resvg::render(&tree, tiny_skia::Transform::from_scale(scale, scale), &mut pixmap.as_mut());
    // `tiny_skia` ya da el alfa premultiplicado; el atlas lo premultiplica de nuevo, así que se deshace aquí.
    let rgba = pixmap
        .pixels()
        .iter()
        .flat_map(|pixel| {
            let color = pixel.demultiply();
            [color.red(), color.green(), color.blue(), color.alpha()]
        })
        .collect();
    Some((pixmap.width(), pixmap.height(), rgba))
}

fn decode_image(bytes: &[u8]) -> Option<(u32, u32, Vec<u8>)> {
    if is_svg(bytes) {
        return rasterize_svg_color(bytes);
    }
    // `load_from_memory` reconoce el formato por la cabecera; el primer fotograma basta para una miniatura.
    let image = image::load_from_memory(bytes).map_err(|error| eprintln!("imagen inválida: {error}")).ok()?.to_rgba8();
    let (width, height) = image.dimensions();
    if width.max(height) > MAX_IMAGE_SIDE {
        let scale = MAX_IMAGE_SIDE as f32 / width.max(height) as f32;
        let resized = image::imageops::thumbnail(
            &image,
            (width as f32 * scale).max(1.0) as u32,
            (height as f32 * scale).max(1.0) as u32,
        );
        return Some((resized.width(), resized.height(), resized.into_raw()));
    }
    Some((width, height, image.into_raw()))
}

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

    #[test]
    fn screenshot_fits_in_color_atlas() {
        let image = image::RgbaImage::from_pixel(1920, 1080, image::Rgba([80, 120, 180, 255]));
        let mut encoded = std::io::Cursor::new(Vec::new());
        image.write_to(&mut encoded, image::ImageFormat::Png).unwrap();
        let source = ImageSource { id: 1, bytes: encoded.into_inner().into() };
        let mut mono = crate::gpu::atlas::TextureAtlas::cpu_monochrome();
        let mut color = crate::gpu::atlas::TextureAtlas::cpu_polychrome();
        let mut target = GlyphTarget { queue: None, monochrome_atlas: &mut mono, polychrome_atlas: &mut color };
        let mut cache = SpriteCache::default();
        let tile = cache.image(&source, &mut target).expect("la captura debe caber en el atlas");
        assert_eq!((tile.width, tile.height), (1024, 576));
        assert!(cache.image(&source, &mut target).is_some(), "la caché conserva la miniatura");
        assert!(!color.take_overflow());
    }

    #[test]
    fn svg_images_keep_color_and_report_their_size() {
        let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="#ff0000"/></svg>"##;
        assert_eq!(image_size(svg), Some((40.0, 20.0)));
        let (width, height, rgba) = decode_image(svg).expect("SVG válido");
        assert_eq!((width, height), (80, 40));
        assert_eq!(&rgba[..4], &[255, 0, 0, 255]);
    }

    #[test]
    fn rasterizes_svg_into_an_alpha_mask() {
        let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="5"/></svg>"#;
        let (width, height, mask) = rasterize_svg(svg, 20).expect("SVG válido");
        assert_eq!((width, height), (20, 20));
        assert_eq!(mask[0], 255, "la mitad superior está cubierta");
        assert_eq!(mask[mask.len() - 1], 0, "la mitad inferior está vacía");
    }
}