tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
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}