tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1// =============================================================================
2// llimphi-reproductor :: el extremo nativo del cable (W4 de PLAN-LLIMPHI)
3// -----------------------------------------------------------------------------
4// `llimphi-wire-escena` define QUÉ cruza la frontera. Este módulo es quien lo
5// recibe del otro lado y lo convierte en píxeles.
6//
7// El reparto que W4 eligió, visto desde acá:
8//
9// en la jaula (app) view + layout + geometría -> EscenaWire
10// ─────────────────── frontera: bytes postcard ───────────────────
11// en el anfitrión shaping + aplanado + raster <- este módulo
12//
13// La línea está donde está por la medición de W4.1: lo caro es el 79-93% del
14// fotograma y cae entero de este lado, mientras que la app sólo arma cajas —que
15// aguanta el 30,3x del intérprete—. El texto es el caso que más se nota: llega
16// como CADENA y se shapea acá, con el `Typesetter` del anfitrión, que es el
17// mismo de siempre y por lo tanto trae su caché puesto. Ése es el detalle que
18// sostiene el régimen «tibio» de la tabla (53-428 fps): si el reproductor
19// creara un typesetter por fotograma, cada fotograma sería «frío» y estaríamos
20// en 3,4 fps.
21//
22// ## Por qué está escrito contra el ALIAS de lienzo y no contra un backend
23//
24// Hay **dos** anfitriones que reproducen: el kernel de wawa (lienzo de
25// `llimphi-cpu`) y shuma/pata en Linux (lienzo de `vello`). Dos reproductores
26// serían dos verdades sobre lo mismo, y la que se rompa en silencio va a ser la
27// que nadie mira. No hizo falta ningún trait para evitarlo: `llimphi_text::Lienzo`
28// ya es el alias por backend, y el lienzo de `llimphi-cpu` espeja el de `vello`
29// método por método (misma `kurbo` y misma `peniko` en el lock, así que los
30// tipos de los argumentos son literalmente los mismos). Este archivo compila
31// igual de los dos lados, sin una línea de diferencia.
32//
33// Corolario que conviene tener a mano: **este crate no enciende ninguna feature
34// de backend**. Si lo hiciera, la unificación de cargo la juntaría con la del
35// anfitrión y dispararía el `compile_error!` de backends excluyentes que
36// `llimphi-text` tiene justo para delatar eso.
37//
38// Lo que este módulo NO hace: confiar. Una escena que llega por el cable viene
39// de una app en una jaula, así que todo lo que puede estar mal se cuenta y se
40// sigue —índices de estilo fuera de rango, pinceles de imagen sin blob—. Un
41// `unwrap` acá es un pánico en Ring 0.
42// =============================================================================
43
44#![cfg_attr(not(feature = "std"), no_std)]
45#![forbid(unsafe_code)]
46
47extern crate alloc;
48
49use alloc::vec::Vec;
50
51use kurbo::Stroke;
52use llimphi_text::{Lienzo, Typesetter};
53use peniko::Brush;
54use llimphi_wire_escena::{
55 wire_a_afin, wire_a_color, AlineacionWire, EscenaWire, EstiloTextoWire, FormaKurbo, OrdenWire,
56};
57
58/// Qué pasó al reproducir. Numérico a propósito: en un arranque headless nadie
59/// mira la pantalla, así que «anduvo» tiene que poder decirse por el serial.
60#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
61pub struct EstadisticasReproduccion {
62 /// Órdenes que se ejecutaron.
63 pub ejecutadas: usize,
64 /// Órdenes de texto shapeadas de este lado — el punto de W4.
65 pub textos: usize,
66 /// Imágenes cuyo blob no está en el registro. No es un error del cable: es
67 /// que el blob todavía no llegó por su canal aparte.
68 pub imagenes_sin_blob: usize,
69 /// Órdenes que se saltearon por venir mal (índice de estilo fuera de rango,
70 /// pincel irreconstruible). Si esto no es cero, la pantalla difiere de lo
71 /// que la app pidió y alguien tiene que enterarse.
72 pub salteadas: usize,
73 /// Clips que NO se pushearon por pasarse del tope de profundidad. No es un
74 /// error: es el seguro que evita que `vello` devuelva la escena entera
75 /// transparente. Que sea > 0 dice que la app está anidando más de lo que la
76 /// GPU aguanta, y eso se quiere saber.
77 pub clips_capeados: usize,
78}
79
80/// Reproduce una escena que llegó por el cable sobre una `Escena` nativa.
81///
82/// No resetea la escena destino: quien llama decide si el fotograma arranca de
83/// cero o se compone encima de algo (el cromo del kernel, por ejemplo, pinta su
84/// barra y recién después deja que la app pinte debajo).
85pub fn reproducir(
86 wire: &EscenaWire,
87 destino: &mut Lienzo,
88 ts: &mut Typesetter,
89) -> EstadisticasReproduccion {
90 reproducir_con_blobs(wire, destino, ts, &|_| None)
91}
92
93/// Igual que [`reproducir`], pero **con el registro de blobs**: `blobs(huella)`
94/// devuelve los píxeles de una imagen si quien hospeda los tiene.
95///
96/// Existe porque sin él este reproductor **no dibujaba una sola imagen**: las
97/// órdenes de imagen viajan por huella y acá se contaban en `imagenes_sin_blob`
98/// y se seguía de largo. En el explorador enjaulado del cajón de pata eso era
99/// toda miniatura y toda previa **en negro** — el reporte del operador fue
100/// literal: *«no salen los thumbnails, solo sale negro»*.
101///
102/// El registro es una función y no un mapa para no fijar un tipo de colección en
103/// un crate que corre también adentro del kernel: quien hospeda guarda los blobs
104/// como le convenga (ver `llimphi_widget_tablado::Tablado`).
105pub fn reproducir_con_blobs(
106 wire: &EscenaWire,
107 destino: &mut Lienzo,
108 ts: &mut Typesetter,
109 blobs: &dyn Fn(u64) -> Option<peniko::ImageBrush>,
110) -> EstadisticasReproduccion {
111 reproducir_con_tope(wire, destino, ts, blobs, TOPE_CLIPS_SUELTO)
112}
113
114/// Tope de clips cuando la escena se reproduce **sobre un lienzo propio**: el
115/// kernel bare-metal, un test, un volcado. Ahí la cadena empieza en cero.
116pub const TOPE_CLIPS_SUELTO: usize = 3;
117
118/// Tope de clips cuando la escena se reproduce **adentro de otra app**, que es
119/// el caso del tablado: el anfitrión ya gastó su presupuesto antes de llamar.
120///
121/// `llimphi-compositor` capea las capas TOTALES en 4 —medido en esta Iris Xe:
122/// un árbol a profundidad 5 sale 100 % vacío y a 4 pinta— y el árbol del cajón
123/// de pata ya llega a 3. El tablado suma la suya para recortar, así que al
124/// huésped le queda **una**. Apilar más es pedirle a vello que devuelva la
125/// escena entera transparente, que es lo que hacía.
126pub const TOPE_CLIPS_HOSPEDADO: usize = 1;
127
128/// Igual que [`reproducir_con_blobs`], con el tope de clips explícito.
129///
130/// El tope es un **parámetro y no una constante** porque depende de cuánta
131/// cadena traiga quien llama: suelto se empieza en cero, hospedado adentro del
132/// cajón de otra app no. Ver [`TOPE_CLIPS_SUELTO`] y [`TOPE_CLIPS_HOSPEDADO`].
133pub fn reproducir_con_tope(
134 wire: &EscenaWire,
135 destino: &mut Lienzo,
136 ts: &mut Typesetter,
137 blobs: &dyn Fn(u64) -> Option<peniko::ImageBrush>,
138 tope_clips: usize,
139) -> EstadisticasReproduccion {
140 let mut st = EstadisticasReproduccion::default();
141
142 // **El tope de clips anidados, y sin él la pantalla se va en blanco.**
143 //
144 // `vello` devuelve la ESCENA ENTERA 100 % transparente —sin error, sin
145 // panic, sin log— cuando el stack de clip/blend anidado pasa su límite
146 // (medido en esta Iris Xe: los árboles que llegan a 4 pintan, el que llega
147 // a 5 sale vacío). `llimphi-compositor` lo capea desde julio de 2026
148 // (`render::rango::MAX_CLIP_DEPTH`, el bug del «cuerpo fantasma» del drawer
149 // de pata) — pero **la escena del huésped no pasa por ahí**: llega como
150 // órdenes crudas y se reproducen acá, encima de la cadena de capas que el
151 // anfitrión ya traía (barra → cajón → desenrollado → el propio
152 // `push_layer` del tablado).
153 //
154 // Resultado medido en metal el 2026-09-24: el explorador enjaulado en modo
155 // GRILLA —cuyas celdas anidan más que las filas de una lista— pintaba el
156 // cuerpo del cajón **completamente transparente**, dejando ver el fondo de
157 // escritorio, mientras `pintados=586 ordenes=525 salteadas=0` decía que
158 // todo se había ejecutado. En modo lista, con menos anidamiento, se veía
159 // bien: de ahí el «le doy v y aparece» del operador.
160 //
161 // Se capea igual que el compositor, y por la misma razón: un recorte
162 // cosmético a esa profundidad casi nunca recorta nada, y perderlo es
163 // invisible al lado de perder la escena entera.
164 let max_clip_depth = tope_clips;
165 // Una entrada por capa abierta: `true` = se pusheó de verdad y hay que
166 // cerrarla. Sin esto, saltear un `push` desbalancearía los `pop` y el
167 // recorte de lo que viene DESPUÉS saldría mal — que es peor que el clip
168 // que se dejó de hacer.
169 let mut pila_capas: Vec<bool> = Vec::new();
170 let mut prof_clip: usize = 0;
171
172 // **Un pincel de imagen tampoco se reconstruye sin el registro.** Un relleno
173 // o un trazo con textura caía en el mismo `imagenes_sin_blob` que la orden
174 // de imagen suelta, y por el mismo motivo: `PincelWire::a_peniko` no puede
175 // devolver un `Brush::Image` sin los píxeles. Acá se le dan.
176 let brocha_de = |p: &llimphi_wire_escena::PincelWire| -> Option<Brush> {
177 if let llimphi_wire_escena::PincelWire::Imagen { huella } = p {
178 return blobs(*huella).map(Brush::Image);
179 }
180 p.a_peniko()
181 };
182
183 for orden in wire.ordenes() {
184 match orden {
185 OrdenWire::Rellenar {
186 regla,
187 xf,
188 pincel,
189 xf_pincel,
190 forma,
191 } => {
192 let Some(brocha) = brocha_de(pincel) else {
193 st.imagenes_sin_blob += 1;
194 continue;
195 };
196 let xf_p = xf_pincel.map(wire_a_afin);
197 match forma.a_kurbo() {
198 FormaKurbo::Rect(r) => {
199 destino.fill(regla.a_peniko(), wire_a_afin(*xf), &brocha, xf_p, &r)
200 }
201 FormaKurbo::Redondeado(rr) => {
202 destino.fill(regla.a_peniko(), wire_a_afin(*xf), &brocha, xf_p, &rr)
203 }
204 FormaKurbo::Camino(c) => {
205 destino.fill(regla.a_peniko(), wire_a_afin(*xf), &brocha, xf_p, &c)
206 }
207 }
208 st.ejecutadas += 1;
209 }
210
211 OrdenWire::Trazar {
212 ancho,
213 xf,
214 pincel,
215 xf_pincel,
216 forma,
217 } => {
218 let Some(brocha) = brocha_de(pincel) else {
219 st.imagenes_sin_blob += 1;
220 continue;
221 };
222 let estilo = Stroke::new(*ancho as f64);
223 let xf_p = xf_pincel.map(wire_a_afin);
224 match forma.a_kurbo() {
225 FormaKurbo::Rect(r) => {
226 destino.stroke(&estilo, wire_a_afin(*xf), &brocha, xf_p, &r)
227 }
228 FormaKurbo::Redondeado(rr) => {
229 destino.stroke(&estilo, wire_a_afin(*xf), &brocha, xf_p, &rr)
230 }
231 FormaKurbo::Camino(c) => {
232 destino.stroke(&estilo, wire_a_afin(*xf), &brocha, xf_p, &c)
233 }
234 }
235 st.ejecutadas += 1;
236 }
237
238 OrdenWire::AbrirCapa {
239 regla,
240 mezcla,
241 alfa,
242 xf,
243 recorte,
244 } => {
245 // Los clips MÁS PROFUNDOS que el tope no se pushean. Ver la
246 // nota del tope arriba.
247 let recortar = prof_clip < max_clip_depth;
248 if recortar {
249 destino.push_layer(
250 regla.a_peniko(),
251 mezcla.a_peniko(),
252 *alfa,
253 wire_a_afin(*xf),
254 &recorte.a_kurbo().a_camino(),
255 );
256 st.ejecutadas += 1;
257 } else {
258 st.clips_capeados += 1;
259 }
260 pila_capas.push(recortar);
261 prof_clip += 1;
262 }
263
264 OrdenWire::AbrirMascaraLuminancia {
265 regla,
266 alfa,
267 xf,
268 recorte,
269 } => {
270 // La máscara **no** se capea: son raras, someras y
271 // estructurales — capearlas cambiaría el dibujo, no sólo un
272 // recorte. Misma decisión que el compositor.
273 pila_capas.push(true);
274 destino.push_luminance_mask_layer(
275 regla.a_peniko(),
276 *alfa,
277 wire_a_afin(*xf),
278 &recorte.a_kurbo().a_camino(),
279 );
280 st.ejecutadas += 1;
281 }
282
283 OrdenWire::CerrarCapa => {
284 // Se cierra SÓLO lo que se abrió de verdad; y la profundidad de
285 // clip baja igual, la hayamos pusheado o no.
286 match pila_capas.pop() {
287 Some(true) => {
288 destino.pop_layer();
289 st.ejecutadas += 1;
290 }
291 Some(false) => {}
292 // Un `CerrarCapa` sin su apertura es una escena mal armada:
293 // se cuenta y no se toca el destino, que es de quien
294 // hospeda.
295 None => st.salteadas += 1,
296 }
297 prof_clip = prof_clip.saturating_sub(1);
298 }
299
300 // El blob no viaja por el IR (ver `llimphi_wire_escena::pincel`), así
301 // que sin registro no hay nada que dibujar. Se cuenta y se sigue:
302 // una imagen faltante es un hueco en la pantalla, no un kernel caído.
303 OrdenWire::Imagen { huella, xf } => match blobs(*huella) {
304 Some(img) => {
305 destino.draw_image(&img, wire_a_afin(*xf));
306 st.ejecutadas += 1;
307 }
308 // Sigue siendo un estado de verdad y no un error: el blob puede
309 // no haber llegado todavía. Lo que era un defecto es que ésta
310 // fuese la ÚNICA rama.
311 None => st.imagenes_sin_blob += 1,
312 },
313
314 OrdenWire::RectDifuso {
315 xf,
316 rect,
317 color,
318 radio,
319 desvio,
320 } => {
321 destino.draw_blurred_rounded_rect(
322 wire_a_afin(*xf),
323 kurbo::Rect::new(
324 rect[0] as f64,
325 rect[1] as f64,
326 rect[2] as f64,
327 rect[3] as f64,
328 ),
329 wire_a_color(*color),
330 *radio as f64,
331 *desvio as f64,
332 );
333 st.ejecutadas += 1;
334 }
335
336 OrdenWire::Texto(t) => {
337 let Some(estilo) = wire.estilo(t.estilo) else {
338 st.salteadas += 1;
339 continue;
340 };
341 shapear_y_pintar(t, estilo, destino, ts);
342 st.ejecutadas += 1;
343 st.textos += 1;
344 }
345 }
346 }
347
348 st
349}
350
351/// Mide una cadena con el shaper nativo — la respuesta a `sys_medir_texto`.
352///
353/// Es la contrapartida del IR: el camino de ida manda la escena compuesta, y
354/// éste es el de vuelta que el diseño necesita — **taffy no puede dimensionar
355/// una etiqueta sin saber cuánto mide**, y medir es shapear. Si la app midiera
356/// por su cuenta, parley correría en la jaula y el 30,3x del intérprete volvería
357/// justo después de haberlo sacado por la puerta (W4.1: 21,8 fps contra 71).
358///
359/// Vive acá y no en el kernel a propósito. Uno: es lógica de Llimphi, no de
360/// sistema — el kernel sólo tiene que prestar el typesetter y marshalar punteros.
361/// Dos, y más importante: acá **se puede probar con parley de verdad**, y lo que
362/// hay que probar es justamente que la app y el kernel midan IGUAL.
363///
364/// Corre el mismo camino que el compositor elegiría en Linux: `layout_spans` si
365/// el estilo trae rangos, `layout_clamped` si no. Que la decisión sea la misma de
366/// los dos lados es lo que hace que una app se vea igual nativa o enjaulada; si
367/// acá se eligiera otro camino, el texto se mediría distinto de como se pinta.
368pub fn medir_texto(
369 ts: &mut Typesetter,
370 cadena: &str,
371 estilo: &EstiloTextoWire,
372 ancho_max: Option<f32>,
373) -> llimphi_wire_escena::MedidaTexto {
374 use llimphi_wire_escena::MedidaTexto;
375
376 let alin = alineacion(estilo.alineacion);
377 let familia = estilo.familia.as_deref();
378
379 if estilo.rangos.is_empty() {
380 let layout = ts.layout_clamped(
381 cadena,
382 estilo.tamano_px,
383 ancho_max,
384 alin,
385 estilo.alto_linea,
386 estilo.italica,
387 familia,
388 estilo.peso,
389 estilo.max_lineas.map(|n| n as usize),
390 estilo.elipsis,
391 estilo.subrayado,
392 estilo.tachado,
393 estilo.espaciado_letra,
394 estilo.espaciado_palabra,
395 estilo.partir_palabra,
396 &estilo.caracteristicas,
397 &estilo.variaciones,
398 estilo.ancho_fuente,
399 );
400 let m = llimphi_text::measurement(&layout);
401 MedidaTexto {
402 width: m.width,
403 full_width: layout.full_width(),
404 height: m.height,
405 }
406 } else {
407 let rangos: Vec<llimphi_text::TextSpan> = estilo
408 .rangos
409 .iter()
410 .map(|r| llimphi_text::TextSpan {
411 start: r.inicio as usize,
412 end: r.fin as usize,
413 style: llimphi_text::TextSpanStyle {
414 size_px: r.estilo.tamano_px,
415 weight: r.estilo.peso,
416 italic: r.estilo.italica,
417 font_family: r.estilo.familia.clone(),
418 // El COLOR no entra: no cambia el shaping, y meterlo haría
419 // que dos etiquetas idénticas de distinto color fallaran el
420 // caché por nada.
421 color: None,
422 underline: r.estilo.subrayado,
423 strikethrough: r.estilo.tachado,
424 },
425 })
426 .collect();
427 let layout = ts.layout_spans(
428 cadena,
429 estilo.tamano_px,
430 peniko::Color::BLACK,
431 estilo.peso,
432 estilo.alto_linea,
433 estilo.italica,
434 familia,
435 estilo.subrayado,
436 estilo.tachado,
437 &rangos,
438 ancho_max,
439 alin,
440 );
441 MedidaTexto {
442 width: layout.width(),
443 full_width: layout.full_width(),
444 height: layout.height(),
445 }
446 }
447}
448
449/// El corazón de W4: la cadena se shapea ACÁ, nativo.
450///
451/// Se elige entre los dos caminos de `llimphi-text` por la misma regla que usa
452/// el compositor en Linux: sin rangos, el camino uniforme con caché
453/// (`layout_clamped`); con rangos, el RichText (`layout_spans`), que no cachea
454/// porque su clave sería un span-set arbitrario. Que la decisión sea la misma
455/// de los dos lados no es casualidad — es lo que hace que una app se vea igual
456/// corriendo nativa en Linux o enjaulada en wawa.
457fn shapear_y_pintar(
458 t: &llimphi_wire_escena::OrdenTexto,
459 estilo: &EstiloTextoWire,
460 destino: &mut Lienzo,
461 ts: &mut Typesetter,
462) {
463 let xf = t.xf.a_kurbo();
464 let color = wire_a_color(t.color);
465 let familia = estilo.familia.as_deref();
466 let alineacion = alineacion(estilo.alineacion);
467
468 if estilo.rangos.is_empty() {
469 let layout = ts.layout_clamped(
470 &t.cadena,
471 estilo.tamano_px,
472 t.ancho_max,
473 alineacion,
474 estilo.alto_linea,
475 estilo.italica,
476 familia,
477 estilo.peso,
478 estilo.max_lineas.map(|n| n as usize),
479 estilo.elipsis,
480 estilo.subrayado,
481 estilo.tachado,
482 estilo.espaciado_letra,
483 estilo.espaciado_palabra,
484 estilo.partir_palabra,
485 &estilo.caracteristicas,
486 &estilo.variaciones,
487 estilo.ancho_fuente,
488 );
489 llimphi_text::draw_layout_brush_xf(destino, &layout, &Brush::Solid(color), xf);
490 } else {
491 let rangos: Vec<llimphi_text::TextSpan> = estilo
492 .rangos
493 .iter()
494 .map(|r| llimphi_text::TextSpan {
495 start: r.inicio as usize,
496 end: r.fin as usize,
497 style: llimphi_text::TextSpanStyle {
498 size_px: r.estilo.tamano_px,
499 weight: r.estilo.peso,
500 italic: r.estilo.italica,
501 font_family: r.estilo.familia.clone(),
502 color: r.estilo.color.map(wire_a_color),
503 underline: r.estilo.subrayado,
504 strikethrough: r.estilo.tachado,
505 },
506 })
507 .collect();
508
509 let layout = ts.layout_spans(
510 &t.cadena,
511 estilo.tamano_px,
512 color,
513 estilo.peso,
514 estilo.alto_linea,
515 estilo.italica,
516 familia,
517 estilo.subrayado,
518 estilo.tachado,
519 &rangos,
520 t.ancho_max,
521 alineacion,
522 );
523 llimphi_text::draw_layout_runs_xf(destino, &layout, xf);
524 }
525}
526
527fn alineacion(a: AlineacionWire) -> llimphi_text::Alignment {
528 match a {
529 AlineacionWire::Inicio => llimphi_text::Alignment::Start,
530 AlineacionWire::Centro => llimphi_text::Alignment::Center,
531 AlineacionWire::Fin => llimphi_text::Alignment::End,
532 AlineacionWire::Justificado => llimphi_text::Alignment::Justify,
533 }
534}