gioser

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Definiciones

⎇ main llimphi-wawa/src/lib.rs Rust · 871 líneas · 35.5 KB Historia Quién tocó qué crudo

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

1// =============================================================================
2//  llimphi-wawa :: la cáscara  (W3.c de 03_ukupacha/wawa/PLAN-LLIMPHI.md)
3// -----------------------------------------------------------------------------
4//  `llimphi-ui` corre el ciclo de Llimphi sobre winit: crea ventana, bombea
5//  eventos del sistema, y al final del fotograma le entrega la escena a la GPU.
6//  Nada de eso existe en wawa — no hay ventanas, no hay winit, no hay GPU y no
7//  hay `std`. Pero el ciclo SÍ existe, y es el mismo:
8//
9//      View<Msg> → mount → layout → paint → volcar al framebuffer
10//
11//  Este crate es esa cáscara. Deliberadamente delgada: no reimplementa una línea
12//  del compositor, sólo lo enchufa a otro sustrato. Todo lo caro —el árbol, el
13//  layout de taffy, el paint, el hit-test— es el MISMO código que corre en
14//  Linux, compilado con otras features. Esa es la promesa de la Regla 2 del repo
15//  llevada hasta el final: un modelo, un pincel, dos backends.
16//
17//  Lo que la cáscara NO hace, a propósito:
18//
19//    - **No tiene bucle propio.** Quien manda es el reactor cooperativo del
20//      kernel; la cáscara ofrece `fotograma()` y `click()` para que el reactor
21//      los invoque cuando corresponda. Un bucle acá pelearía con el de afuera.
22//    - **No sabe la hora.** El instante del fotograma entra por argumento (el
23//      kernel lo saca de su PIT). Es la misma disciplina que ya impone
24//      `llimphi_compositor::Instant`, que no tiene `now()`.
25//    - **No toca el hardware.** Recibe un `&mut [u8]` y su stride. Quién mapeó
26//      esa memoria —GOP del firmware, scanout de virtio-gpu— es asunto del
27//      kernel.
28// =============================================================================
29
30#![cfg_attr(not(any(feature = "std", test)), no_std)]
31
32extern crate alloc;
33
34use llimphi_compositor::Mounted;
35use llimphi_layout::ComputedLayout;
36use llimphi_cpu::{Escena, Pintor, Pixmap};
37
38/// El orden de los canales del framebuffer que recibe [`Cascara::volcar`].
39///
40/// Se re-exporta porque **es parte del contrato**: quien consigue la memoria
41/// —el kernel de wawa, un dumb buffer DRM— tiene que poder nombrarlo sin
42/// depender de `llimphi-cpu` por su cuenta.
43pub use llimphi_cpu::FormatoPixel;
44use llimphi_layout::LayoutTree;
45
46/// El extremo nativo del cable **ya no vive acá**.
47///
48/// Se mudó a `llimphi-reproductor` (24-ago-2026) porque hay dos anfitriones que
49/// reproducen —este kernel y shuma/pata en Linux— y dos copias serían dos
50/// verdades sobre lo mismo: la que se rompa en silencio iba a ser la que nadie
51/// mira. No hizo falta partirlo en un trait: está escrito contra
52/// `llimphi_text::Lienzo`, que ya es el alias por backend.
53///
54/// Se re-exporta con el mismo nombre para que nada de acá adentro —ni de afuera—
55/// tenga que enterarse.
56pub use llimphi_reproductor as reproductor;
57pub use llimphi_reproductor::{medir_texto, reproducir, EstadisticasReproduccion};
58
59pub use llimphi_compositor::Instant;
60pub use llimphi_cpu::FormatoPixel as Formato;
61/// El IR de escena, reexportado: quien tenga la cáscara tiene el cable.
62pub use llimphi_wire_escena as wire;
63
64// El vocabulario mínimo para escribir una vista, reexportado acá para que el
65// kernel no tenga que declarar cuatro crates de Llimphi por su cuenta. Es la
66// misma cortesía que hace `llimphi-ui` del lado Linux.
67pub use llimphi_compositor::View;
68pub use llimphi_cpu::peniko::Color;
69pub use llimphi_layout::taffy;
70pub use llimphi_layout::Style;
71
72// Lo mínimo para GRABAR una escena sin montar una vista — el camino de W4. Se
73// reexporta por la misma razón que lo de arriba: que el kernel no tenga que
74// declarar `llimphi-cpu` ni arriesgarse a traer otra versión de peniko y
75// terminar con dos tipos `Color` que el compilador reporta como incompatibles.
76// `RoundedRect` está acá aunque parezca de más: es la forma que el compositor
77// emite SIEMPRE para el relleno de un nodo (`node_rrect`, radio 0 incluido), y
78// por lo tanto la única manera de grabar a mano una escena que se rasterice por
79// el mismo camino que una vista de verdad — `fill_path`, no `fill_rect`.
80pub use llimphi_cpu::kurbo::{Affine as Afin, Circle as Circulo, Rect, RoundedRect};
81pub use llimphi_cpu::peniko::Fill;
82
83// Y el vocabulario de imágenes, por la MISMA razón y con un motivo extra: una
84// imagen construida con otra versión de peniko no falla al compilar en el sitio
85// obvio, falla como «expected ImageBrush, found ImageBrush». Además es el camino
86// que ejercita el atlas de la GPU, que en el backend GL tiene su propia trampa
87// (ver `ajustes_para` en `llimphi-hybrid`).
88pub use llimphi_cpu::peniko::{
89    Blob, ImageAlphaType, ImageBrush, ImageData, ImageFormat, ImageSampler,
90};
91
92// El motor de texto, para que el kernel pueda sostener UN typesetter compartido
93// y contestarle las medidas a las apps enjauladas (`sys_medir_texto`). Se
94// reexporta el crate entero además del tipo: medir usa `Alignment`, `TextSpan` y
95// `measurement`, y hacerle declarar `llimphi-text` por su cuenta al kernel sería
96// invitar a que traiga otra versión de peniko.
97pub use llimphi_text;
98pub use llimphi_text::Typesetter;
99
100/// La cáscara **sin typesetter**: rasteriza escenas que llegan por el cable
101/// usando un shaper que le prestan.
102///
103/// Existe por una razón concreta, y no es estética. [`Cascara`] tiene su propio
104/// `Typesetter`, que está bien cuando ella es la única que pinta. Pero desde W4.4
105/// el kernel tiene UN typesetter del sistema —el que contesta `sys_medir_texto`—
106/// y si la rasterización usara otro habría **dos cachés de shaping separadas**:
107/// la app mediría contra una y el kernel pintaría contra la otra. Con las mismas
108/// fuentes daría lo mismo, pero se pagaría el arranque en frío dos veces y, peor,
109/// quedaría una divergencia latente esperando a que las dos se desincronicen.
110///
111/// Así que el shaper entra por argumento. Es la misma disciplina que ya siguen
112/// `Instant` y el framebuffer en esta cáscara: lo compartido no se posee.
113pub struct Rasterizador {
114    pintor: Pintor,
115    escena: Escena,
116    lienzo: Pixmap,
117    ancho: u16,
118    alto: u16,
119}
120
121impl Rasterizador {
122    /// Un rasterizador para un lienzo de `ancho` x `alto` píxeles.
123    pub fn new(ancho: u16, alto: u16) -> Self {
124        let pintor = Pintor::new(ancho, alto);
125        let lienzo = pintor.lienzo_nuevo();
126        Self {
127            pintor,
128            escena: Escena::new(),
129            lienzo,
130            ancho,
131            alto,
132        }
133    }
134
135    /// Ancho del lienzo en píxeles.
136    pub fn ancho(&self) -> u16 {
137        self.ancho
138    }
139
140    /// Alto del lienzo en píxeles.
141    pub fn alto(&self) -> u16 {
142        self.alto
143    }
144
145    /// Compone un `View<Msg>` con un typesetter PRESTADO y lo rasteriza.
146    ///
147    /// Es el `Cascara::fotograma` para quien ya tiene un typesetter del sistema
148    /// y no quiere dos cachés de shaping (ver el encabezado del tipo). A
149    /// diferencia de aquél, éste sí pasa por `compute_with_measure`: el cromo
150    /// por fotograma (launcher, marcos) tiene texto de ancho variable — la
151    /// query del operador, nombres de apps — y medirlo con el mismo typesetter
152    /// que después pinta es lo que mantiene medida y pintado en la misma
153    /// entrada de caché.
154    pub fn componer<Msg: Clone>(
155        &mut self,
156        vista: View<Msg>,
157        ts: &mut llimphi_text::Typesetter,
158    ) -> Mounted<Msg> {
159        let mut arbol = LayoutTree::new();
160        let montado = llimphi_compositor::mount(&mut arbol, vista);
161        let tmap = &montado.text_measures;
162        let computado = arbol
163            .compute_with_measure(
164                montado.root,
165                (self.ancho as f32, self.alto as f32),
166                |nid, k, a| match tmap.get(&nid) {
167                    Some(tm) => llimphi_compositor::measure_text_node(ts, tm, k, a),
168                    None => taffy::Size {
169                        width: 0.0,
170                        height: 0.0,
171                    },
172                },
173            )
174            .unwrap_or_default();
175
176        self.escena.reset();
177        llimphi_compositor::paint(&mut self.escena, &montado, &computado, ts, None, None);
178        self.pintor.pintar(&self.escena, &mut self.lienzo);
179        montado
180    }
181
182    /// Ejecuta una escena que llegó por el cable, shapeando con `ts`.
183    pub fn ejecutar(
184        &mut self,
185        wire: &llimphi_wire_escena::EscenaWire,
186        ts: &mut llimphi_text::Typesetter,
187    ) -> EstadisticasReproduccion {
188        self.escena.reset();
189        let st = reproductor::reproducir(wire, &mut self.escena, ts);
190        self.pintor.pintar(&self.escena, &mut self.lienzo);
191        st
192    }
193
194    /// Vuelca el último fotograma a un framebuffer con su stride.
195    pub fn volcar(&self, destino: &mut [u8], stride_bytes: usize, formato: FormatoPixel) -> usize {
196        llimphi_cpu::volcar(&self.lienzo, destino, stride_bytes, formato)
197    }
198
199    /// El pixmap rasterizado.
200    pub fn lienzo(&self) -> &Pixmap {
201        &self.lienzo
202    }
203}
204
205/// La cáscara: todo el estado que un fotograma necesita, vivo entre fotogramas.
206///
207/// Se sostiene y se reusa a propósito. El `Typesetter` guarda el caché de
208/// shaping —que W4.1 midió como la diferencia entre 53 fps y 3,4—, el `Pintor`
209/// reusa sus búferes de rasterizado, y la `Escena` conserva la capacidad ya
210/// reservada. Recrear esto por fotograma sería tirar todo ese trabajo y, en un
211/// kernel con `linked_list_allocator`, castigar además al asignador.
212pub struct Cascara {
213    pintor: Pintor,
214    escena: Escena,
215    typesetter: llimphi_text::Typesetter,
216    lienzo: Pixmap,
217    /// El último árbol montado, para que el hit-test del input sepa contra qué
218    /// probar. Se guarda como `ComputedLayout` porque es lo único que el
219    /// hit-test necesita junto al `Mounted`, que es genérico en `Msg` y por eso
220    /// no puede vivir acá (ver [`Cascara::fotograma`]).
221    computado: Option<ComputedLayout>,
222    ancho: u16,
223    alto: u16,
224}
225
226impl Cascara {
227    /// Una cáscara para una pantalla de `ancho` x `alto` píxeles.
228    ///
229    /// Construir el `Typesetter` registra las fuentes embebidas (Inter, DejaVu,
230    /// Liberation Mono): es lo caro del arranque y se paga una vez.
231    pub fn new(ancho: u16, alto: u16) -> Self {
232        let pintor = Pintor::new(ancho, alto);
233        let lienzo = pintor.lienzo_nuevo();
234        Self {
235            pintor,
236            escena: Escena::new(),
237            typesetter: llimphi_text::Typesetter::new(),
238            lienzo,
239            computado: None,
240            ancho,
241            alto,
242        }
243    }
244
245    /// Ancho de la pantalla en píxeles.
246    pub fn ancho(&self) -> u16 {
247        self.ancho
248    }
249
250    /// Alto de la pantalla en píxeles.
251    pub fn alto(&self) -> u16 {
252        self.alto
253    }
254
255    /// El `Typesetter` compartido, por si el kernel quiere medir texto sin
256    /// pintar (anchos de menú, columnas de una tabla).
257    pub fn typesetter(&mut self) -> &mut llimphi_text::Typesetter {
258        &mut self.typesetter
259    }
260
261    /// Corre un fotograma completo: monta la vista, calcula el layout, pinta y
262    /// deja el resultado en el lienzo interno.
263    ///
264    /// Devuelve el `Mounted` para que quien llama pueda hacer hit-test contra
265    /// ESTE fotograma. Se devuelve en vez de guardarse porque es genérico en
266    /// `Msg` y la cáscara no lo es: hacerla genérica obligaría al kernel a tener
267    /// una cáscara por tipo de mensaje, que es exactamente la clase de
268    /// monomorfización que no queremos en un binario bare-metal.
269    pub fn fotograma<Msg: Clone>(&mut self, vista: View<Msg>) -> Mounted<Msg> {
270        let montado = self.componer(vista);
271        self.pintor.pintar(&self.escena, &mut self.lienzo);
272        montado
273    }
274
275    /// La primera mitad de [`Cascara::fotograma`]: monta, calcula el layout y
276    /// **pinta en la `Escena`**, sin rasterizar.
277    ///
278    /// Existe porque la `Escena` es un IR, y un IR con un solo consumidor no es
279    /// un IR. El kernel de wawa la rasteriza en CPU (`fotograma`), pero
280    /// `llimphi-hybrid` la rasteriza en GPU sin compute — el único camino a
281    /// píxeles en el Mali del teléfono, según
282    /// `02_ruway/llimphi/android/DIAGNOSTICO.md`. Los dos quieren exactamente
283    /// esta mitad y ninguno quiere la otra: pedir `fotograma` para después
284    /// ignorar el lienzo sería rasterizar dos veces por fotograma.
285    ///
286    /// Devuelve el `Mounted` por el mismo motivo que `fotograma` — ver ahí.
287    pub fn componer<Msg: Clone>(&mut self, vista: View<Msg>) -> Mounted<Msg> {
288        let mut arbol = LayoutTree::new();
289        let montado = llimphi_compositor::mount(&mut arbol, vista);
290        let computado = arbol
291            .compute(montado.root, (self.ancho as f32, self.alto as f32))
292            .unwrap_or_default();
293
294        self.escena.reset();
295        llimphi_compositor::paint(
296            &mut self.escena,
297            &montado,
298            &computado,
299            &mut self.typesetter,
300            None,
301            None,
302        );
303        self.computado = Some(computado);
304        montado
305    }
306
307    /// La escena compuesta por el último [`Cascara::componer`] o
308    /// [`Cascara::fotograma`]. Lo que consume un rasterizador de afuera.
309    pub fn escena(&self) -> &Escena {
310        &self.escena
311    }
312
313    /// El layout del último fotograma, contra el que probar los clicks.
314    pub fn computado(&self) -> Option<&ComputedLayout> {
315        self.computado.as_ref()
316    }
317
318    /// Corre un fotograma que llegó por el cable, en vez de uno compuesto acá.
319    ///
320    /// Es el camino de W4 y el gemelo exacto de [`Cascara::fotograma`]: aquél
321    /// recibe un `View<Msg>` y lo monta; éste recibe una escena ya compuesta por
322    /// una app enjaulada y sólo la ejecuta. Del `pintar` para abajo son el mismo
323    /// código, que es justamente lo que el IR compra — el kernel no necesita dos
324    /// rasterizadores porque no hay dos clases de dibujo.
325    ///
326    /// El `Typesetter` es el de la cáscara, no uno nuevo: el texto llega sin
327    /// shapear y se resuelve acá, así que el caché tiene que sobrevivir entre
328    /// fotogramas o cada uno sería «frío» (3,4 fps en vez de 53, según W4.1).
329    ///
330    /// **No hay hit-test para este camino, y es correcto.** El árbol de nodos se
331    /// quedó del otro lado de la frontera; quien sabe qué hay bajo el puntero es
332    /// la app, no el kernel. El input viaja de vuelta como coordenada y la app
333    /// lo resuelve con su propio `Mounted`.
334    pub fn fotograma_wire(
335        &mut self,
336        wire: &llimphi_wire_escena::EscenaWire,
337    ) -> EstadisticasReproduccion {
338        self.escena.reset();
339        let st = reproductor::reproducir(wire, &mut self.escena, &mut self.typesetter);
340        self.pintor.pintar(&self.escena, &mut self.lienzo);
341        // El árbol de este fotograma no existe de este lado: invalidar el
342        // anterior evita que un click se resuelva contra un layout que ya no
343        // corresponde a lo que hay en pantalla.
344        self.computado = None;
345        st
346    }
347
348    /// Vuelca el último fotograma al framebuffer del kernel.
349    ///
350    /// `stride_bytes` es la distancia de una fila a la siguiente **en bytes**,
351    /// que casi nunca es `ancho * 4`: el firmware alinea las filas. Devuelve
352    /// cuántas filas se copiaron.
353    pub fn volcar(&self, destino: &mut [u8], stride_bytes: usize, formato: FormatoPixel) -> usize {
354        llimphi_cpu::volcar(&self.lienzo, destino, stride_bytes, formato)
355    }
356
357    /// El lienzo rasterizado, por si el kernel quiere componerlo él mismo (por
358    /// ejemplo, encima de otra capa o dentro de una ventana).
359    pub fn lienzo(&self) -> &Pixmap {
360        &self.lienzo
361    }
362
363    /// El `Msg` del nodo clickeable bajo `(x, y)`, si lo hay.
364    ///
365    /// `montado` tiene que ser el del último [`Cascara::fotograma`]: el
366    /// hit-test se resuelve contra el layout de ese fotograma, no contra uno
367    /// nuevo. Pedirlo por argumento hace imposible el error de probar un click
368    /// contra un árbol viejo.
369    pub fn click<Msg: Clone>(&self, montado: &Mounted<Msg>, x: f32, y: f32) -> Option<Msg> {
370        let computado = self.computado.as_ref()?;
371        let idx = llimphi_compositor::hit_test_click(montado, computado, x, y)?;
372        montado.nodes.get(idx).and_then(|n| n.on_click.clone())
373    }
374}
375
376#[cfg(test)]
377mod pruebas {
378    use super::*;
379    use alloc::vec;
380    use llimphi_cpu::peniko::Color;
381    use llimphi_layout::taffy::prelude::*;
382    use llimphi_layout::Style;
383
384    #[derive(Clone, Debug, PartialEq)]
385    enum Msg {
386        Tocado,
387    }
388
389    /// Un `Style` de caja de tamaño fijo — el equivalente del `w_px/h_px` que
390    /// uno esperaría, escrito con el vocabulario real de taffy.
391    /// Un `Style` que llena el viewport. Hace falta explicitarlo: el default de
392    /// taffy es `auto`, o sea que la raíz se dimensiona POR CONTENIDO. Sin esto
393    /// el fondo sólo cubriría la caja del hijo — que es correcto, pero no lo que
394    /// uno espera de un fondo de pantalla.
395    fn pantalla_completa() -> Style {
396        Style {
397            size: Size {
398                width: percent(1.0),
399                height: percent(1.0),
400            },
401            ..Default::default()
402        }
403    }
404
405    fn caja(w: f32, h: f32) -> Style {
406        Style {
407            size: Size {
408                width: length(w),
409                height: length(h),
410            },
411            ..Default::default()
412        }
413    }
414
415    /// El ciclo entero, de `View<Msg>` a bytes de framebuffer.
416    ///
417    /// Es la prueba de que la cáscara cierra: si el mount, el layout, el paint
418    /// o el volcado fallaran, el framebuffer quedaría en negro y esto lo dice.
419    #[test]
420    fn el_ciclo_completo_llega_al_framebuffer() {
421        let mut cascara = Cascara::new(64, 48);
422        let vista: View<Msg> = View::new(pantalla_completa())
423            .fill(Color::from_rgba8(20, 20, 30, 255))
424            .children(vec![View::new(caja(20.0, 10.0))
425                .fill(Color::from_rgba8(255, 0, 0, 255))
426                .on_click(Msg::Tocado)]);
427        let _montado = cascara.fotograma(vista);
428
429        // Framebuffer con stride mayor que el ancho, como el de un GOP real.
430        const STRIDE: usize = 80 * 4;
431        let mut fb = vec![0u8; STRIDE * 48];
432        let filas = cascara.volcar(&mut fb, STRIDE, FormatoPixel::Bgra8);
433        assert_eq!(filas, 48, "se vuelcan todas las filas");
434
435        let px = |x: usize, y: usize| {
436            let i = y * STRIDE + x * 4;
437            (fb[i], fb[i + 1], fb[i + 2])
438        };
439        // El hijo rojo, donde el layout lo puso (esquina superior izquierda).
440        assert_eq!(px(5, 5), (0, 0, 255), "el hijo rojo, en BGRA");
441        // Y el fondo índigo fuera de él: si el ciclo se cortara en cualquier
442        // punto, esto seguiría en cero.
443        assert_eq!(px(40, 30), (30, 20, 20), "BGRA del fondo índigo");
444    }
445
446    /// Un click sobre el hijo tiene que devolver su `Msg`; fuera, nada.
447    #[test]
448    fn el_hit_test_encuentra_el_nodo_clickeable() {
449        let mut cascara = Cascara::new(64, 48);
450        let vista: View<Msg> = View::new(Style::default()).children(vec![
451            View::new(caja(20.0, 10.0)).on_click(Msg::Tocado),
452        ]);
453        let montado = cascara.fotograma(vista);
454
455        assert_eq!(
456            cascara.click(&montado, 5.0, 5.0),
457            Some(Msg::Tocado),
458            "adentro del hijo"
459        );
460        assert_eq!(cascara.click(&montado, 50.0, 40.0), None, "afuera");
461    }
462
463    /// El texto también llega — es lo que W2 y W3.a habilitaron.
464    #[test]
465    fn el_texto_se_pinta_en_el_framebuffer() {
466        let mut cascara = Cascara::new(120, 40);
467        let vista: View<Msg> = View::new(pantalla_completa())
468            .fill(Color::BLACK)
469            .children(vec![View::new(Style::default()).text(
470                "Hola wawa",
471                18.0,
472                Color::WHITE,
473            )]);
474        let _ = cascara.fotograma(vista);
475
476        const STRIDE: usize = 120 * 4;
477        let mut fb = vec![0u8; STRIDE * 40];
478        cascara.volcar(&mut fb, STRIDE, FormatoPixel::Bgra8);
479
480        // Contar píxeles claros: el texto es blanco sobre negro, así que
481        // cualquier cosa por encima del fondo es un glifo.
482        let claros = fb.chunks_exact(4).filter(|p| p[0] > 60).count();
483        assert!(
484            claros > 50,
485            "el texto tiene que encender píxeles claros, encendió {claros}"
486        );
487    }
488
489    // -------------------------------------------------------------------------
490    //  W4 :: el camino por el cable
491    // -------------------------------------------------------------------------
492
493    /// De una escena que cruzó la frontera a píxeles, sin `View` de por medio.
494    ///
495    /// Es la prueba de que W4 cierra: la app —que acá se simula, porque el
496    /// encoder desde el compositor todavía no existe— emitió una escena, la
497    /// serializó, y de este lado se deserializó, se shapeó nativo y se
498    /// rasterizó. Si algún eslabón fallara el framebuffer quedaría negro.
499    #[test]
500    fn una_escena_que_cruzo_la_frontera_llega_a_pixeles() {
501        use llimphi_cpu::kurbo::{Affine, Rect};
502        use llimphi_wire_escena::{EscenaWire, EstiloTextoWire, Texto};
503
504        // --- lado app (jaula): compone y serializa ---------------------------
505        let mut wire = EscenaWire::new();
506        wire.fill(
507            llimphi_cpu::peniko::Fill::NonZero,
508            Affine::IDENTITY,
509            Color::BLACK,
510            None,
511            &Rect::new(0.0, 0.0, 160.0, 40.0),
512        );
513        wire.texto(Texto {
514            cadena: alloc::string::String::from("Hola wawa"),
515            estilo: EstiloTextoWire {
516                tamano_px: 18.0,
517                ..Default::default()
518            },
519            color: [255, 255, 255, 255],
520            ancho_max: None,
521            xf: llimphi_wire_escena::UbicacionWire::Punto([4.0, 4.0]),
522            hint: true,
523        });
524        let bytes = postcard::to_allocvec(&wire).expect("serializar");
525
526        // --- la frontera ------------------------------------------------------
527        let recibida: EscenaWire = postcard::from_bytes(&bytes).expect("deserializar");
528
529        // --- lado kernel: shapea y rasteriza ----------------------------------
530        let mut cascara = Cascara::new(160, 40);
531        let st = cascara.fotograma_wire(&recibida);
532
533        assert_eq!(st.ejecutadas, 2);
534        assert_eq!(st.textos, 1, "el texto se shapeó de ESTE lado");
535        assert_eq!(st.salteadas, 0);
536        assert_eq!(st.imagenes_sin_blob, 0);
537
538        const STRIDE: usize = 160 * 4;
539        let mut fb = vec![0u8; STRIDE * 40];
540        cascara.volcar(&mut fb, STRIDE, FormatoPixel::Bgra8);
541
542        let claros = fb.chunks_exact(4).filter(|p| p[0] > 60).count();
543        assert!(
544            claros > 50,
545            "el texto que cruzó el cable tiene que encender píxeles, encendió {claros}"
546        );
547        // El cable pesó lo que tenía que pesar: una caja y nueve letras.
548        assert!(bytes.len() < 200, "la escena pesó {} B", bytes.len());
549    }
550
551    /// Un índice de estilo corrupto no puede voltear el kernel.
552    ///
553    /// La escena viene de una app en una jaula. Que el IR sea un formato propio
554    /// no lo hace confiable: lo hace *nuestro*. Un `unwrap` en el reproductor
555    /// sería un pánico en Ring 0 disparable desde userspace.
556    /// Los bytes que emitió el compositor de verdad, del otro lado de la
557    /// frontera. Los genera `llimphi-jaula/examples/emitir_fixture.rs`.
558    const FIXTURE_JAULA: &[u8] = include_bytes!("../pruebas/fixture_jaula.postcard");
559
560    /// **El test que cierra W4**: una escena compuesta por el ENCODER real llega
561    /// a píxeles por el reproductor real.
562    ///
563    /// Los dos extremos no pueden compilarse juntos —`wire` y `cpu` son backends
564    /// excluyentes de `llimphi-text`, con `compile_error!` puesto a propósito—,
565    /// así que la costura del test es la misma que la de producción: bytes. El
566    /// otro test de acá arriba fabrica su escena a mano; éste consume lo que
567    /// `render.rs` emitió de verdad, con su mount, su layout y su paint.
568    ///
569    /// Si alguien cambia el formato del cable de un solo lado, esto lo dice.
570    /// **Los clips anidados de más NO se pushean, y lo que sigue se ve igual.**
571    ///
572    /// `vello` devuelve la escena ENTERA transparente —sin error ni log— cuando
573    /// el stack de clip/blend anidado pasa su límite. El compositor lo capea
574    /// desde julio de 2026, pero la escena de un huésped enjaulado **no pasa
575    /// por el compositor**: llega como órdenes crudas y se reproducen encima de
576    /// la cadena de capas que el anfitrión ya traía.
577    ///
578    /// Medido en metal el 2026-09-24: el explorador enjaulado en modo GRILLA
579    /// —cuyas celdas anidan más que las filas de una lista— pintaba el cuerpo
580    /// del cajón completamente transparente, dejando ver el fondo de
581    /// escritorio, mientras los contadores decían que todo se había ejecutado.
582    /// En modo lista se veía bien. Ése era el «nahual invisible».
583    #[test]
584    fn los_clips_de_mas_se_capean_y_el_dibujo_sigue_saliendo() {
585        use llimphi_cpu::kurbo::{Affine, Rect};
586        use llimphi_wire_escena::EscenaWire;
587        let hondo = 8usize;
588        let mut wire = EscenaWire::new();
589        for _ in 0..hondo {
590            wire.push_layer(
591                llimphi_cpu::peniko::Fill::NonZero,
592                llimphi_cpu::peniko::BlendMode::default(),
593                1.0,
594                Affine::IDENTITY,
595                &Rect::new(0.0, 0.0, 80.0, 40.0),
596            );
597        }
598        // Adentro del pozo, algo que se TIENE que ver.
599        wire.fill(
600            llimphi_cpu::peniko::Fill::NonZero,
601            Affine::IDENTITY,
602            Color::WHITE,
603            None,
604            &Rect::new(0.0, 0.0, 80.0, 40.0),
605        );
606        for _ in 0..hondo {
607            wire.pop_layer();
608        }
609        let bytes = postcard::to_allocvec(&wire).expect("serializar");
610        let recibida: EscenaWire = postcard::from_bytes(&bytes).expect("deserializar");
611
612        let mut cascara = Cascara::new(80, 40);
613        let st = cascara.fotograma_wire(&recibida);
614
615        assert_eq!(
616            st.clips_capeados,
617            hondo - 3,
618            "sólo los tres primeros clips se pushean; los otros {} se capean",
619            hondo - 3
620        );
621        assert_eq!(st.salteadas, 0, "ningún `CerrarCapa` queda huérfano");
622
623        // Y lo de adentro SIGUE pintando: el recorte que se perdió es cosmético
624        // (a esa profundidad ya no recorta nada), pero la escena no se va en
625        // blanco — que es exactamente lo que este cap existe para evitar.
626        const STRIDE: usize = 80 * 4;
627        let mut fb = vec![0u8; STRIDE * 40];
628        cascara.volcar(&mut fb, STRIDE, FormatoPixel::Bgra8);
629        let claros = fb.chunks_exact(4).filter(|p| p[0] > 200).count();
630        assert!(claros > 1000, "el relleno de adentro se ve: {claros} px claros");
631    }
632
633    #[test]
634    fn la_escena_que_emitio_el_compositor_llega_a_pixeles() {
635        use llimphi_wire_escena::EscenaWire;
636
637        let escena: EscenaWire =
638            postcard::from_bytes(FIXTURE_JAULA).expect("el fixture tiene que deserializar");
639
640        // Lo que la jaula reportó al generarlo: 3 órdenes, 2 textos, 2 estilos.
641        assert_eq!(escena.len(), 3);
642        assert_eq!(escena.textos(), 2);
643        assert_eq!(escena.estilos().len(), 2);
644
645        let mut cascara = Cascara::new(320, 72);
646        let st = cascara.fotograma_wire(&escena);
647
648        assert_eq!(st.ejecutadas, 3);
649        assert_eq!(st.textos, 2, "los dos textos se shapearon de ESTE lado");
650        assert_eq!(st.salteadas, 0, "el cable llegó entero");
651
652        const STRIDE: usize = 320 * 4;
653        let mut fb = vec![0u8; STRIDE * 72];
654        cascara.volcar(&mut fb, STRIDE, FormatoPixel::Bgra8);
655
656        // El fondo del fixture es (22,26,38); cualquier píxel distinto de él y
657        // del negro de afuera salió de un glifo. Contarlos aparte del alfa es la
658        // lección de W3.d: el texto va sobre relleno opaco, así que contar sólo
659        // «encendidos» daría lo mismo con letras o sin ellas.
660        let de_texto = fb
661            .chunks_exact(4)
662            .filter(|p| p[3] > 0 && (p[0] != 38 || p[1] != 26 || p[2] != 22))
663            .count();
664        assert!(
665            de_texto > 200,
666            "el texto del compositor tenía que encender píxeles, encendió {de_texto}"
667        );
668    }
669
670    // -------------------------------------------------------------------------
671    //  W4 :: la medición, el camino de vuelta
672    // -------------------------------------------------------------------------
673
674    /// La petición cruza y vuelve con un número que sirve.
675    ///
676    /// Es el contrato de `sys_medir_texto` ejercido de punta a punta menos el
677    /// marshalling de wasmi: se serializa como lo haría la app, se deserializa
678    /// como lo hace el kernel, se mide con el shaper de verdad, y se empaqueta
679    /// la respuesta en sus doce bytes.
680    #[test]
681    fn la_peticion_de_medida_vuelve_con_un_numero_util() {
682        use llimphi_wire_escena::{EstiloTextoWire, MedidaTexto, PeticionMedida};
683
684        let peticion = PeticionMedida {
685            cadena: alloc::string::String::from("Hola wawa"),
686            estilo: EstiloTextoWire {
687                tamano_px: 18.0,
688                ..Default::default()
689            },
690            ancho_max: None,
691        };
692
693        // --- lado app: serializa y manda ---
694        let bytes = postcard::to_allocvec(&peticion).expect("serializar");
695
696        // --- lado kernel: deserializa, mide con parley, contesta 12 bytes ---
697        let recibida: PeticionMedida = postcard::from_bytes(&bytes).expect("deserializar");
698        let mut ts = llimphi_text::Typesetter::new();
699        let medida = medir_texto(
700            &mut ts,
701            &recibida.cadena,
702            &recibida.estilo,
703            recibida.ancho_max,
704        );
705        let respuesta = medida.a_bytes();
706
707        // --- lado app: desempaqueta ---
708        let vuelta = MedidaTexto::de_bytes(&respuesta);
709        assert_eq!(vuelta, medida, "la respuesta cambió al cruzar");
710
711        // Nueve caracteres a 18 px no pueden medir cero ni una barbaridad. El
712        // rango es amplio a propósito: lo que se certifica es que shapeó de
713        // verdad, no una tipografía concreta.
714        assert!(
715            vuelta.width > 30.0 && vuelta.width < 200.0,
716            "ancho fuera de rango: {}",
717            vuelta.width
718        );
719        assert!(vuelta.full_width >= vuelta.width);
720        assert!(
721            vuelta.height > 10.0 && vuelta.height < 60.0,
722            "alto fuera de rango: {}",
723            vuelta.height
724        );
725    }
726
727    /// **El test que justifica el syscall**: medir por el contrato da lo MISMO
728    /// que medir directo.
729    ///
730    /// Si estos dos números divergieran, una app enjaulada layoutearía con una
731    /// medida y el kernel pintaría con otra: el texto saldría corrido respecto
732    /// de su caja, y el motivo sería invisible desde los dos lados.
733    #[test]
734    fn medir_por_el_contrato_da_lo_mismo_que_medir_directo() {
735        use llimphi_wire_escena::EstiloTextoWire;
736
737        let mut ts = llimphi_text::Typesetter::new();
738        let estilo = EstiloTextoWire {
739            tamano_px: 15.0,
740            ..Default::default()
741        };
742        let cadena = "escena emitida por la jaula";
743
744        // Por el contrato de W4.
745        let por_contrato = medir_texto(&mut ts, cadena, &estilo, None);
746
747        // Directo, como lo haría el compositor en Linux.
748        let layout = ts.layout_clamped(
749            cadena,
750            15.0,
751            None,
752            llimphi_text::Alignment::Start,
753            estilo.alto_linea,
754            false,
755            None,
756            estilo.peso,
757            None,
758            false,
759            false,
760            false,
761            0.0,
762            0.0,
763            false,
764            "",
765            "",
766            estilo.ancho_fuente,
767        );
768        let directo = llimphi_text::measurement(&layout);
769
770        assert_eq!(por_contrato.width, directo.width, "el ancho difiere");
771        assert_eq!(por_contrato.height, directo.height, "el alto difiere");
772        assert_eq!(por_contrato.full_width, layout.full_width());
773    }
774
775    /// El estilo con rangos toma el otro camino, y también mide.
776    #[test]
777    fn el_texto_con_rangos_se_mide_por_el_camino_de_richtext() {
778        use llimphi_wire_escena::{EstiloRangoWire, EstiloTextoWire, RangoWire};
779
780        let mut ts = llimphi_text::Typesetter::new();
781        let cadena = "titulo y cuerpo";
782        let plano = EstiloTextoWire {
783            tamano_px: 14.0,
784            ..Default::default()
785        };
786        let con_rango = EstiloTextoWire {
787            rangos: vec![RangoWire {
788                inicio: 0,
789                fin: 6,
790                estilo: EstiloRangoWire {
791                    tamano_px: Some(28.0),
792                    ..Default::default()
793                },
794            }],
795            ..plano.clone()
796        };
797
798        let m_plano = medir_texto(&mut ts, cadena, &plano, None);
799        let m_rango = medir_texto(&mut ts, cadena, &con_rango, None);
800
801        // Un span al doble de tamaño tiene que ensanchar Y agrandar la línea. Si
802        // el camino de rangos se ignorara, los dos números serían idénticos.
803        assert!(
804            m_rango.width > m_plano.width,
805            "el span de 28 px no ensanchó: {} vs {}",
806            m_rango.width,
807            m_plano.width
808        );
809        assert!(
810            m_rango.height > m_plano.height,
811            "el span de 28 px no agrandó la línea: {} vs {}",
812            m_rango.height,
813            m_plano.height
814        );
815    }
816
817    #[test]
818    fn un_estilo_fuera_de_rango_se_saltea_en_vez_de_entrar_en_panico() {
819        use llimphi_wire_escena::{EscenaWire, OrdenTexto, OrdenWire};
820
821        // Una escena con la tabla de estilos VACÍA y un texto que pide el 99.
822        // Es lo que llegaría de un cable roto o de una app hostil: postcard lo
823        // acepta —el tipo no valida rangos— así que el reproductor tiene que.
824        let mut wire = EscenaWire::new();
825        wire.empujar_crudo(OrdenWire::Texto(OrdenTexto {
826            cadena: alloc::string::String::from("x"),
827            estilo: 99,
828            color: [255, 255, 255, 255],
829            ancho_max: None,
830            xf: llimphi_wire_escena::UbicacionWire::Punto([0.0, 0.0]),
831            hint: false,
832        }));
833        let bytes = postcard::to_allocvec(&wire).unwrap();
834        let recibida: EscenaWire = postcard::from_bytes(&bytes).unwrap();
835
836        let mut cascara = Cascara::new(32, 16);
837        let st = cascara.fotograma_wire(&recibida);
838        assert_eq!(st.salteadas, 1, "se saltea y se cuenta");
839        assert_eq!(st.textos, 0);
840    }
841
842    /// F7 — el relleno de la vista raíz tiene que llegar a la primera y a la
843    /// última columna de la pantalla.
844    ///
845    /// En el teléfono (1080 de ancho) las columnas 0-3 y 1076-1079 quedaban
846    /// negras: el `fill` de la raíz, cuyo rect es 0..1080, no las alcanzaba.
847    /// Hoy lo tapa el `color_base` del pintor hybrid, así que el síntoma sólo
848    /// se ve cuando la base es de otro color — pero el agujero está igual.
849    #[test]
850    fn el_relleno_de_la_raiz_llega_a_las_columnas_del_borde() {
851        const ANCHO: u16 = 1080;
852        const ALTO: u16 = 64;
853        let mut cascara = Cascara::new(ANCHO, ALTO);
854        let vista: View<Msg> =
855            View::new(caja(ANCHO as f32, ALTO as f32)).fill(Color::from_rgba8(24, 26, 31, 255));
856        let _ = cascara.fotograma(vista);
857
858        const STRIDE: usize = ANCHO as usize * 4;
859        let mut fb = vec![0u8; STRIDE * ALTO as usize];
860        cascara.volcar(&mut fb, STRIDE, FormatoPixel::Bgra8);
861
862        let fila = ALTO as usize / 2;
863        let vacias: alloc::vec::Vec<usize> = (0..ANCHO as usize)
864            .filter(|x| {
865                let i = fila * STRIDE + x * 4;
866                fb[i] == 0 && fb[i + 1] == 0 && fb[i + 2] == 0
867            })
868            .collect();
869        assert!(vacias.is_empty(), "columnas sin pintar: {vacias:?}");
870    }
871}