tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main llimphi-svg/src/lib.rs Rust · 191 líneas · 7.2 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! `llimphi-svg` — puente fino entre `vello_svg` y Llimphi.
2//!
3//! `llimphi-icons` cubre el set canónico de ~50 íconos a mano (BezPath en
4//! grid 24×24). Para lo demás — íconos de apps `.desktop` arbitrarias,
5//! logotipos, assets de marca, exports vectoriales de pineal/cosmos — hace
6//! falta cargar SVG real. Este crate es el puente: parsea una vez con
7//! `vello_svg` y stampea la `vello::Scene` resultante en cualquier `View`
8//! escalándola al rect del nodo.
9//!
10//! ## Uso
11//!
12//! ```ignore
13//! use llimphi_svg::SvgAsset;
14//!
15//! // Parsea UNA vez (al cargar la app o el ícono):
16//! let svg = SvgAsset::from_str(include_str!("logo.svg")).expect("logo válido");
17//!
18//! // Pintalo en un View tantas veces como quieras (escala al rect):
19//! View::new(style).children(vec![svg.view::<Msg>()])
20//! ```
21//!
22//! El parse cuesta — el `view()` no. Si vas a stampear el mismo SVG en muchos
23//! nodos (lista de apps con el mismo ícono fallback), parsea una sola vez y
24//! clonea el `SvgAsset` (es barato: `Arc` internamente).
25//!
26//! ## Por qué no parsear en cada paint
27//!
28//! `vello_svg::render` corre el parser de `usvg` (~ms por SVG no trivial). En
29//! una lista con 80 íconos `.desktop`, parsear en cada frame mata el thread
30//! de UI. La regla: **el asset se parsea una vez**, la `Scene` resultante se
31//! retiene en memoria y se stampea con `scene.append(&inner, Some(xf))` —
32//! cuesta lo mismo que dibujar el resto del UI.
33//!
34//! ## Errores
35//!
36//! `SvgAsset::from_str` devuelve `Result<Self, SvgError>` — si el XML está
37//! corrupto o usa features que `usvg` no soporta. Las apps típicas tratan el
38//! error como "fallback a glyph genérico" — no rompen.
39
40#![forbid(unsafe_code)]
41
42use std::sync::Arc;
43
44use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
45use llimphi_ui::llimphi_layout::taffy::Position;
46use llimphi_ui::llimphi_raster::kurbo::Affine;
47use llimphi_ui::llimphi_raster::vello::Scene;
48use llimphi_ui::{PaintRect, View};
49
50/// Asset SVG parseado y listo para stampear. Internamente guarda la
51/// `vello::Scene` ya construida + el viewBox del SVG (para escalar al rect del
52/// nodo manteniendo aspecto). Cloneable barato (`Arc`) — un mismo asset en una
53/// lista de 80 íconos no replica memoria.
54#[derive(Clone)]
55pub struct SvgAsset {
56    inner: Arc<Inner>,
57}
58
59struct Inner {
60    scene: Scene,
61    /// Tamaño del viewBox en unidades del SVG (px nominales). Lo necesitamos
62    /// para escalar el `scene.append(...)` al rect del nodo destino.
63    vb_w: f64,
64    vb_h: f64,
65}
66
67/// Errores al parsear un SVG. Hoy es un wrap del tipo de `vello_svg::Error` —
68/// las apps típicas lo tratan como "fallback" y no inspeccionan la variante.
69#[derive(Debug)]
70pub struct SvgError(pub String);
71
72impl std::fmt::Display for SvgError {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        write!(f, "svg: {}", self.0)
75    }
76}
77
78impl std::error::Error for SvgError {}
79
80impl SvgAsset {
81    /// Parsea un SVG desde su representación XML como string. Devuelve un asset
82    /// inmutable + cloneable. **Hace el parseo completo** (usvg + render a una
83    /// `vello::Scene`); pensado para llamarse UNA vez por asset, no por frame.
84    pub fn from_str(svg: &str) -> Result<Self, SvgError> {
85        let scene = vello_svg::render(svg).map_err(|e| SvgError(e.to_string()))?;
86        // El size del viewBox sale del propio SVG: lo recuperamos con un parse
87        // mínimo de usvg para no acoplar este crate a la geometría del SVG por
88        // fuera de vello_svg.
89        let opt = vello_svg::usvg::Options::default();
90        let tree = vello_svg::usvg::Tree::from_str(svg, &opt).map_err(|e| SvgError(e.to_string()))?;
91        let size = tree.size();
92        Ok(Self {
93            inner: Arc::new(Inner {
94                scene,
95                vb_w: size.width() as f64,
96                vb_h: size.height() as f64,
97            }),
98        })
99    }
100
101    /// Parsea un SVG desde sus bytes UTF-8 crudos (lo que devuelve
102    /// `include_bytes!("…svg")` o `std::fs::read`).
103    pub fn from_bytes(svg: &[u8]) -> Result<Self, SvgError> {
104        let s = std::str::from_utf8(svg).map_err(|e| SvgError(e.to_string()))?;
105        Self::from_str(s)
106    }
107
108    /// Tamaño del viewBox del SVG en px nominales. Útil cuando el caller quiere
109    /// dimensionar el rect destino preservando el aspect ratio en vez de dejar
110    /// que el `view()` lo escale al máximo lado.
111    pub fn size(&self) -> (f64, f64) {
112        (self.inner.vb_w, self.inner.vb_h)
113    }
114
115    /// Pinta el SVG sobre `scene`, ajustado al `rect` indicado. Escala
116    /// uniforme al mínimo lado y **centra** dentro del rect (preserva
117    /// aspect ratio). Útil cuando el caller compone varios assets en un
118    /// `paint_with` propio sin pasar por `view()`.
119    pub fn paint(&self, scene: &mut Scene, rect: PaintRect) {
120        let side_w = rect.w as f64;
121        let side_h = rect.h as f64;
122        if side_w <= 0.0 || side_h <= 0.0 || self.inner.vb_w <= 0.0 || self.inner.vb_h <= 0.0 {
123            return;
124        }
125        let s = (side_w / self.inner.vb_w).min(side_h / self.inner.vb_h);
126        let used_w = self.inner.vb_w * s;
127        let used_h = self.inner.vb_h * s;
128        let tx = rect.x as f64 + (side_w - used_w) * 0.5;
129        let ty = rect.y as f64 + (side_h - used_h) * 0.5;
130        let xform = Affine::translate((tx, ty)) * Affine::scale(s);
131        scene.append(&self.inner.scene, Some(xform));
132    }
133
134    /// Construye un `View` posicionado en absoluto, que ocupa todo el rect del
135    /// padre y pinta el SVG centrado + escalado al mínimo lado. Es el equivalente
136    /// de `icon_view` para SVG arbitrarios. Genérico sobre `Msg` igual que los
137    /// widgets — el `View` no tiene handlers; la app los pone en el padre.
138    pub fn view<Msg>(&self) -> View<Msg> {
139        let asset = self.clone();
140        View::new(Style {
141            position: Position::Absolute,
142            size: Size {
143                width: percent(1.0_f32),
144                height: percent(1.0_f32),
145            },
146            ..Default::default()
147        })
148        .paint_with(move |scene, _ts, rect| {
149            asset.paint(scene, rect);
150        })
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    /// Un SVG mínimo: una caja roja de 24×24 con un círculo blanco al centro.
159    const SVG_OK: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
160        <rect width="24" height="24" fill="#cc0000"/>
161        <circle cx="12" cy="12" r="6" fill="#ffffff"/>
162    </svg>"##;
163
164    #[test]
165    fn from_str_parsea_ok() {
166        let a = SvgAsset::from_str(SVG_OK).expect("parsea");
167        let (w, h) = a.size();
168        assert!((w - 24.0).abs() < 1e-6 && (h - 24.0).abs() < 1e-6);
169    }
170
171    #[test]
172    fn svg_inválido_da_error() {
173        let bad = "<no es svg>";
174        assert!(SvgAsset::from_str(bad).is_err());
175    }
176
177    #[test]
178    fn asset_es_cloneable_barato() {
179        let a = SvgAsset::from_str(SVG_OK).expect("parsea");
180        let b = a.clone();
181        assert_eq!(a.size(), b.size());
182    }
183
184    #[test]
185    fn paint_no_panica_con_rect_cero() {
186        let a = SvgAsset::from_str(SVG_OK).expect("parsea");
187        let mut s = Scene::new();
188        a.paint(&mut s, PaintRect { x: 0.0, y: 0.0, w: 0.0, h: 0.0 });
189        a.paint(&mut s, PaintRect { x: 0.0, y: 0.0, w: 10.0, h: 0.0 });
190    }
191}