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-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}