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.
//! Shaping con `cosmic-text`: texto simple y enriquecido, con ajuste de línea opcional.

use crate::core::{Pixels, Rgba, Size};
use crate::gpu::font_families::FontFamilies;
pub use crate::gpu::font_families::FontFamily;
use crate::gpu::fonts::build_font_system;
use crate::gpu::text_paint::GlyphTile;
use crate::gpu::text_traits::TextTraits;
use cosmic_text::{Attrs, Buffer, CacheKey, Color, Family, FontSystem, Metrics, Shaping, Style, SwashCache, Weight};
use std::collections::HashMap;

pub use crate::gpu::text_paint::{GlyphTarget, TextPlacement};

/// Marca en `Attrs::metadata` para pintar subrayado bajo esos glifos.
pub(crate) const UNDERLINE_FLAG: usize = 1;
/// Código en línea: fuente monoespaciada y un fondo suave detrás (como el `code` de Markdown).
pub(crate) const CODE_FLAG: usize = 2;
/// Tachado (`~~texto~~`): una línea a media altura del glifo.
pub(crate) const STRIKE_FLAG: usize = 4;

/// Texto ya maquetado; se puede pintar muchas veces sin volver a hacer shaping.
pub struct ShapedText {
    pub(crate) buffer: Buffer,
    pub size: Size,
    pub line_height: f32,
}

impl ShapedText {
    /// Posición horizontal del borde izquierdo de cada carácter (en bytes) de la primera línea,
    /// útil para colocar cursores en un `Input`.
    pub fn caret_x(&self, byte_index: usize) -> f32 {
        let Some(run) = self.buffer.layout_runs().next() else { return 0.0 };
        for glyph in run.glyphs {
            if glyph.start >= byte_index {
                return glyph.x;
            }
        }
        run.line_w
    }

    /// Índice de byte más cercano a `x` en la primera línea.
    pub fn byte_at_x(&self, x: f32) -> usize {
        let Some(run) = self.buffer.layout_runs().next() else { return 0 };
        for glyph in run.glyphs {
            if x < glyph.x + glyph.w / 2.0 {
                return glyph.start;
            }
        }
        run.glyphs.last().map_or(0, |glyph| glyph.end)
    }
}

/// Tramo de texto enriquecido; `length` se mide en bytes. El color se aplica por glifo,
/// pero negrita y cursiva se resuelven por palabra (limitación de cosmic-text).
#[derive(Clone, Debug, Default, PartialEq, Hash)]
pub struct TextRun {
    pub length: usize,
    pub color: Option<[u8; 4]>,
    pub bold: bool,
    pub italic: bool,
    pub underline: bool,
    /// Código en línea: monoespaciado y con fondo, para que no se confunda con un enlace.
    pub code: bool,
    pub strikethrough: bool,
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TextStyle {
    pub font_size: f32,
    pub line_height: f32,
    pub monospace: bool,
    /// Familia concreta (de `TextSystem::font_family`); sin ella se usa la sans o la monoespaciada.
    pub family: Option<FontFamily>,
    /// Con ancho, el texto se ajusta por palabras.
    pub max_width: Option<f32>,
    /// Peso, alineación, espaciado, recorte y capitalización.
    pub traits: TextTraits,
}

pub struct TextSystem {
    pub(crate) font_system: FontSystem,
    pub(crate) swash_cache: SwashCache,
    pub(crate) tiles: HashMap<(CacheKey, bool), Option<GlyphTile>>,
    families: FontFamilies,
}

pub fn rgba_bytes(color: Rgba) -> [u8; 4] {
    let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8;
    [channel(color.r), channel(color.g), channel(color.b), channel(color.a)]
}

impl TextSystem {
    pub fn new() -> Self {
        Self {
            font_system: build_font_system(),
            swash_cache: SwashCache::new(),
            tiles: HashMap::new(),
            families: FontFamilies::default(),
        }
    }

    /// Familia instalada con ese nombre, cargándola la primera vez; `None` si no existe.
    pub fn font_family(&mut self, name: &str) -> Option<FontFamily> {
        self.families.resolve(&mut self.font_system, name)
    }

    fn base_attrs(&self, style: &TextStyle) -> Attrs<'static> {
        let named = style.family.and_then(|family| self.families.name(family)).map(Family::Name);
        let attrs =
            Attrs::new().family(named.unwrap_or(if style.monospace { Family::Monospace } else { Family::SansSerif }));
        style.traits.apply(attrs)
    }

    pub fn shape(&mut self, text: &str, style: &TextStyle) -> ShapedText {
        self.shape_rich(text, &[], style)
    }

    /// Los bytes que no cubran los tramos usan el estilo base.
    pub fn shape_rich(&mut self, text: &str, runs: &[TextRun], style: &TextStyle) -> ShapedText {
        let mut buffer = Buffer::new(&mut self.font_system, Metrics::new(style.font_size, style.line_height));
        buffer.set_size(style.max_width, None);
        buffer.set_ellipsize(style.traits.ellipsize());
        let text = style.traits.transformed(text, !runs.is_empty());
        let text = text.as_ref();
        let base = self.base_attrs(style);
        let mut spans: Vec<(&str, Attrs)> = Vec::with_capacity(runs.len() + 1);
        let mut offset = 0;
        for run in runs {
            let end = (offset + run.length).min(text.len());
            let Some(slice) = text.get(offset..end) else { break };
            let mut attrs = base.clone();
            if let Some([red, green, blue, alpha]) = run.color {
                attrs = attrs.color(Color::rgba(red, green, blue, alpha));
            }
            if run.bold {
                attrs = attrs.weight(Weight::BOLD);
            }
            if run.italic {
                attrs = attrs.style(Style::Italic);
            }
            if run.code {
                attrs = attrs.family(Family::Monospace);
            }
            let flags = if run.underline { UNDERLINE_FLAG } else { 0 }
                | if run.code { CODE_FLAG } else { 0 }
                | if run.strikethrough { STRIKE_FLAG } else { 0 };
            if flags != 0 {
                attrs = attrs.metadata(flags);
            }
            spans.push((slice, attrs));
            offset = end;
        }
        if let Some(rest) = text.get(offset..).filter(|rest| !rest.is_empty()) {
            spans.push((rest, base.clone()));
        }
        buffer.set_rich_text(spans, &base, Shaping::Advanced, style.traits.alignment());
        buffer.shape_until_scroll(&mut self.font_system, false);

        let mut width = 0.0f32;
        let mut lines = 0usize;
        for run in buffer.layout_runs() {
            width = width.max(run.line_w);
            lines += 1;
        }
        let height = lines.max(1) as f32 * style.line_height;
        ShapedText { buffer, size: Size::new(Pixels(width), Pixels(height)), line_height: style.line_height }
    }

    /// Libera cachés temporales; se llama al minimizar u ocultar la ventana.
    pub fn shrink_caches(&mut self) {
        self.tiles = HashMap::new();
        self.swash_cache = SwashCache::new();
    }
}

impl Default for TextSystem {
    fn default() -> Self {
        Self::new()
    }
}

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

    #[test]
    fn bold_runs_do_not_leak_into_following_text() {
        let mut system = TextSystem::new();
        let style = TextStyle {
            font_size: 14.0,
            line_height: 20.0,
            monospace: true,
            family: None,
            max_width: None,
            traits: TextTraits::default(),
        };
        let shaped = system.shape_rich("ab cd", &[TextRun { length: 2, bold: true, ..Default::default() }], &style);
        let run = shaped.buffer.layout_runs().next().expect("una línea");
        let fonts: Vec<_> = run.glyphs.iter().map(|glyph| glyph.font_id).collect();
        assert_ne!(fonts[0], fonts[4], "la negrita debe terminar en el tramo: {fonts:?}");

        let runs =
            [TextRun { length: 3, ..Default::default() }, TextRun { length: 2, bold: true, ..Default::default() }];
        let shaped = system.shape_rich("ab cd ef", &runs, &style);
        let run = shaped.buffer.layout_runs().next().expect("una línea");
        let fonts: Vec<_> = run.glyphs.iter().map(|glyph| (glyph.start, glyph.font_id)).collect();
        assert_eq!(fonts[0].1, fonts[6].1, "después del tramo en negrita vuelve la fuente normal: {fonts:?}");
    }
}