tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ main llimphi-reproductor/src/lib.rs Rust · 534 lines · 21.7 KB History Who touched what raw

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

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}