// ============================================================================= // llimphi-wawa :: la cáscara (W3.c de 03_ukupacha/wawa/PLAN-LLIMPHI.md) // ----------------------------------------------------------------------------- // `llimphi-ui` corre el ciclo de Llimphi sobre winit: crea ventana, bombea // eventos del sistema, y al final del fotograma le entrega la escena a la GPU. // Nada de eso existe en wawa — no hay ventanas, no hay winit, no hay GPU y no // hay `std`. Pero el ciclo SÍ existe, y es el mismo: // // View → mount → layout → paint → volcar al framebuffer // // Este crate es esa cáscara. Deliberadamente delgada: no reimplementa una línea // del compositor, sólo lo enchufa a otro sustrato. Todo lo caro —el árbol, el // layout de taffy, el paint, el hit-test— es el MISMO código que corre en // Linux, compilado con otras features. Esa es la promesa de la Regla 2 del repo // llevada hasta el final: un modelo, un pincel, dos backends. // // Lo que la cáscara NO hace, a propósito: // // - **No tiene bucle propio.** Quien manda es el reactor cooperativo del // kernel; la cáscara ofrece `fotograma()` y `click()` para que el reactor // los invoque cuando corresponda. Un bucle acá pelearía con el de afuera. // - **No sabe la hora.** El instante del fotograma entra por argumento (el // kernel lo saca de su PIT). Es la misma disciplina que ya impone // `llimphi_compositor::Instant`, que no tiene `now()`. // - **No toca el hardware.** Recibe un `&mut [u8]` y su stride. Quién mapeó // esa memoria —GOP del firmware, scanout de virtio-gpu— es asunto del // kernel. // ============================================================================= #![cfg_attr(not(any(feature = "std", test)), no_std)] extern crate alloc; use llimphi_compositor::Mounted; use llimphi_layout::ComputedLayout; use llimphi_cpu::{Escena, Pintor, Pixmap}; /// El orden de los canales del framebuffer que recibe [`Cascara::volcar`]. /// /// Se re-exporta porque **es parte del contrato**: quien consigue la memoria /// —el kernel de wawa, un dumb buffer DRM— tiene que poder nombrarlo sin /// depender de `llimphi-cpu` por su cuenta. pub use llimphi_cpu::FormatoPixel; use llimphi_layout::LayoutTree; /// El extremo nativo del cable **ya no vive acá**. /// /// Se mudó a `llimphi-reproductor` (24-ago-2026) porque hay dos anfitriones que /// reproducen —este kernel y shuma/pata en Linux— y dos copias serían dos /// verdades sobre lo mismo: la que se rompa en silencio iba a ser la que nadie /// mira. No hizo falta partirlo en un trait: está escrito contra /// `llimphi_text::Lienzo`, que ya es el alias por backend. /// /// Se re-exporta con el mismo nombre para que nada de acá adentro —ni de afuera— /// tenga que enterarse. pub use llimphi_reproductor as reproductor; pub use llimphi_reproductor::{medir_texto, reproducir, EstadisticasReproduccion}; pub use llimphi_compositor::Instant; pub use llimphi_cpu::FormatoPixel as Formato; /// El IR de escena, reexportado: quien tenga la cáscara tiene el cable. pub use llimphi_wire_escena as wire; // El vocabulario mínimo para escribir una vista, reexportado acá para que el // kernel no tenga que declarar cuatro crates de Llimphi por su cuenta. Es la // misma cortesía que hace `llimphi-ui` del lado Linux. pub use llimphi_compositor::View; pub use llimphi_cpu::peniko::Color; pub use llimphi_layout::taffy; pub use llimphi_layout::Style; // Lo mínimo para GRABAR una escena sin montar una vista — el camino de W4. Se // reexporta por la misma razón que lo de arriba: que el kernel no tenga que // declarar `llimphi-cpu` ni arriesgarse a traer otra versión de peniko y // terminar con dos tipos `Color` que el compilador reporta como incompatibles. // `RoundedRect` está acá aunque parezca de más: es la forma que el compositor // emite SIEMPRE para el relleno de un nodo (`node_rrect`, radio 0 incluido), y // por lo tanto la única manera de grabar a mano una escena que se rasterice por // el mismo camino que una vista de verdad — `fill_path`, no `fill_rect`. pub use llimphi_cpu::kurbo::{Affine as Afin, Circle as Circulo, Rect, RoundedRect}; pub use llimphi_cpu::peniko::Fill; // Y el vocabulario de imágenes, por la MISMA razón y con un motivo extra: una // imagen construida con otra versión de peniko no falla al compilar en el sitio // obvio, falla como «expected ImageBrush, found ImageBrush». Además es el camino // que ejercita el atlas de la GPU, que en el backend GL tiene su propia trampa // (ver `ajustes_para` en `llimphi-hybrid`). pub use llimphi_cpu::peniko::{ Blob, ImageAlphaType, ImageBrush, ImageData, ImageFormat, ImageSampler, }; // El motor de texto, para que el kernel pueda sostener UN typesetter compartido // y contestarle las medidas a las apps enjauladas (`sys_medir_texto`). Se // reexporta el crate entero además del tipo: medir usa `Alignment`, `TextSpan` y // `measurement`, y hacerle declarar `llimphi-text` por su cuenta al kernel sería // invitar a que traiga otra versión de peniko. pub use llimphi_text; pub use llimphi_text::Typesetter; /// La cáscara **sin typesetter**: rasteriza escenas que llegan por el cable /// usando un shaper que le prestan. /// /// Existe por una razón concreta, y no es estética. [`Cascara`] tiene su propio /// `Typesetter`, que está bien cuando ella es la única que pinta. Pero desde W4.4 /// el kernel tiene UN typesetter del sistema —el que contesta `sys_medir_texto`— /// y si la rasterización usara otro habría **dos cachés de shaping separadas**: /// la app mediría contra una y el kernel pintaría contra la otra. Con las mismas /// fuentes daría lo mismo, pero se pagaría el arranque en frío dos veces y, peor, /// quedaría una divergencia latente esperando a que las dos se desincronicen. /// /// Así que el shaper entra por argumento. Es la misma disciplina que ya siguen /// `Instant` y el framebuffer en esta cáscara: lo compartido no se posee. pub struct Rasterizador { pintor: Pintor, escena: Escena, lienzo: Pixmap, ancho: u16, alto: u16, } impl Rasterizador { /// Un rasterizador para un lienzo de `ancho` x `alto` píxeles. pub fn new(ancho: u16, alto: u16) -> Self { let pintor = Pintor::new(ancho, alto); let lienzo = pintor.lienzo_nuevo(); Self { pintor, escena: Escena::new(), lienzo, ancho, alto, } } /// Ancho del lienzo en píxeles. pub fn ancho(&self) -> u16 { self.ancho } /// Alto del lienzo en píxeles. pub fn alto(&self) -> u16 { self.alto } /// Compone un `View` con un typesetter PRESTADO y lo rasteriza. /// /// Es el `Cascara::fotograma` para quien ya tiene un typesetter del sistema /// y no quiere dos cachés de shaping (ver el encabezado del tipo). A /// diferencia de aquél, éste sí pasa por `compute_with_measure`: el cromo /// por fotograma (launcher, marcos) tiene texto de ancho variable — la /// query del operador, nombres de apps — y medirlo con el mismo typesetter /// que después pinta es lo que mantiene medida y pintado en la misma /// entrada de caché. pub fn componer( &mut self, vista: View, ts: &mut llimphi_text::Typesetter, ) -> Mounted { let mut arbol = LayoutTree::new(); let montado = llimphi_compositor::mount(&mut arbol, vista); let tmap = &montado.text_measures; let computado = arbol .compute_with_measure( montado.root, (self.ancho as f32, self.alto as f32), |nid, k, a| match tmap.get(&nid) { Some(tm) => llimphi_compositor::measure_text_node(ts, tm, k, a), None => taffy::Size { width: 0.0, height: 0.0, }, }, ) .unwrap_or_default(); self.escena.reset(); llimphi_compositor::paint(&mut self.escena, &montado, &computado, ts, None, None); self.pintor.pintar(&self.escena, &mut self.lienzo); montado } /// Ejecuta una escena que llegó por el cable, shapeando con `ts`. pub fn ejecutar( &mut self, wire: &llimphi_wire_escena::EscenaWire, ts: &mut llimphi_text::Typesetter, ) -> EstadisticasReproduccion { self.escena.reset(); let st = reproductor::reproducir(wire, &mut self.escena, ts); self.pintor.pintar(&self.escena, &mut self.lienzo); st } /// Vuelca el último fotograma a un framebuffer con su stride. pub fn volcar(&self, destino: &mut [u8], stride_bytes: usize, formato: FormatoPixel) -> usize { llimphi_cpu::volcar(&self.lienzo, destino, stride_bytes, formato) } /// El pixmap rasterizado. pub fn lienzo(&self) -> &Pixmap { &self.lienzo } } /// La cáscara: todo el estado que un fotograma necesita, vivo entre fotogramas. /// /// Se sostiene y se reusa a propósito. El `Typesetter` guarda el caché de /// shaping —que W4.1 midió como la diferencia entre 53 fps y 3,4—, el `Pintor` /// reusa sus búferes de rasterizado, y la `Escena` conserva la capacidad ya /// reservada. Recrear esto por fotograma sería tirar todo ese trabajo y, en un /// kernel con `linked_list_allocator`, castigar además al asignador. pub struct Cascara { pintor: Pintor, escena: Escena, typesetter: llimphi_text::Typesetter, lienzo: Pixmap, /// El último árbol montado, para que el hit-test del input sepa contra qué /// probar. Se guarda como `ComputedLayout` porque es lo único que el /// hit-test necesita junto al `Mounted`, que es genérico en `Msg` y por eso /// no puede vivir acá (ver [`Cascara::fotograma`]). computado: Option, ancho: u16, alto: u16, } impl Cascara { /// Una cáscara para una pantalla de `ancho` x `alto` píxeles. /// /// Construir el `Typesetter` registra las fuentes embebidas (Inter, DejaVu, /// Liberation Mono): es lo caro del arranque y se paga una vez. pub fn new(ancho: u16, alto: u16) -> Self { let pintor = Pintor::new(ancho, alto); let lienzo = pintor.lienzo_nuevo(); Self { pintor, escena: Escena::new(), typesetter: llimphi_text::Typesetter::new(), lienzo, computado: None, ancho, alto, } } /// Ancho de la pantalla en píxeles. pub fn ancho(&self) -> u16 { self.ancho } /// Alto de la pantalla en píxeles. pub fn alto(&self) -> u16 { self.alto } /// El `Typesetter` compartido, por si el kernel quiere medir texto sin /// pintar (anchos de menú, columnas de una tabla). pub fn typesetter(&mut self) -> &mut llimphi_text::Typesetter { &mut self.typesetter } /// Corre un fotograma completo: monta la vista, calcula el layout, pinta y /// deja el resultado en el lienzo interno. /// /// Devuelve el `Mounted` para que quien llama pueda hacer hit-test contra /// ESTE fotograma. Se devuelve en vez de guardarse porque es genérico en /// `Msg` y la cáscara no lo es: hacerla genérica obligaría al kernel a tener /// una cáscara por tipo de mensaje, que es exactamente la clase de /// monomorfización que no queremos en un binario bare-metal. pub fn fotograma(&mut self, vista: View) -> Mounted { let montado = self.componer(vista); self.pintor.pintar(&self.escena, &mut self.lienzo); montado } /// La primera mitad de [`Cascara::fotograma`]: monta, calcula el layout y /// **pinta en la `Escena`**, sin rasterizar. /// /// Existe porque la `Escena` es un IR, y un IR con un solo consumidor no es /// un IR. El kernel de wawa la rasteriza en CPU (`fotograma`), pero /// `llimphi-hybrid` la rasteriza en GPU sin compute — el único camino a /// píxeles en el Mali del teléfono, según /// `02_ruway/llimphi/android/DIAGNOSTICO.md`. Los dos quieren exactamente /// esta mitad y ninguno quiere la otra: pedir `fotograma` para después /// ignorar el lienzo sería rasterizar dos veces por fotograma. /// /// Devuelve el `Mounted` por el mismo motivo que `fotograma` — ver ahí. pub fn componer(&mut self, vista: View) -> Mounted { let mut arbol = LayoutTree::new(); let montado = llimphi_compositor::mount(&mut arbol, vista); let computado = arbol .compute(montado.root, (self.ancho as f32, self.alto as f32)) .unwrap_or_default(); self.escena.reset(); llimphi_compositor::paint( &mut self.escena, &montado, &computado, &mut self.typesetter, None, None, ); self.computado = Some(computado); montado } /// La escena compuesta por el último [`Cascara::componer`] o /// [`Cascara::fotograma`]. Lo que consume un rasterizador de afuera. pub fn escena(&self) -> &Escena { &self.escena } /// El layout del último fotograma, contra el que probar los clicks. pub fn computado(&self) -> Option<&ComputedLayout> { self.computado.as_ref() } /// Corre un fotograma que llegó por el cable, en vez de uno compuesto acá. /// /// Es el camino de W4 y el gemelo exacto de [`Cascara::fotograma`]: aquél /// recibe un `View` y lo monta; éste recibe una escena ya compuesta por /// una app enjaulada y sólo la ejecuta. Del `pintar` para abajo son el mismo /// código, que es justamente lo que el IR compra — el kernel no necesita dos /// rasterizadores porque no hay dos clases de dibujo. /// /// El `Typesetter` es el de la cáscara, no uno nuevo: el texto llega sin /// shapear y se resuelve acá, así que el caché tiene que sobrevivir entre /// fotogramas o cada uno sería «frío» (3,4 fps en vez de 53, según W4.1). /// /// **No hay hit-test para este camino, y es correcto.** El árbol de nodos se /// quedó del otro lado de la frontera; quien sabe qué hay bajo el puntero es /// la app, no el kernel. El input viaja de vuelta como coordenada y la app /// lo resuelve con su propio `Mounted`. pub fn fotograma_wire( &mut self, wire: &llimphi_wire_escena::EscenaWire, ) -> EstadisticasReproduccion { self.escena.reset(); let st = reproductor::reproducir(wire, &mut self.escena, &mut self.typesetter); self.pintor.pintar(&self.escena, &mut self.lienzo); // El árbol de este fotograma no existe de este lado: invalidar el // anterior evita que un click se resuelva contra un layout que ya no // corresponde a lo que hay en pantalla. self.computado = None; st } /// Vuelca el último fotograma al framebuffer del kernel. /// /// `stride_bytes` es la distancia de una fila a la siguiente **en bytes**, /// que casi nunca es `ancho * 4`: el firmware alinea las filas. Devuelve /// cuántas filas se copiaron. pub fn volcar(&self, destino: &mut [u8], stride_bytes: usize, formato: FormatoPixel) -> usize { llimphi_cpu::volcar(&self.lienzo, destino, stride_bytes, formato) } /// El lienzo rasterizado, por si el kernel quiere componerlo él mismo (por /// ejemplo, encima de otra capa o dentro de una ventana). pub fn lienzo(&self) -> &Pixmap { &self.lienzo } /// El `Msg` del nodo clickeable bajo `(x, y)`, si lo hay. /// /// `montado` tiene que ser el del último [`Cascara::fotograma`]: el /// hit-test se resuelve contra el layout de ese fotograma, no contra uno /// nuevo. Pedirlo por argumento hace imposible el error de probar un click /// contra un árbol viejo. pub fn click(&self, montado: &Mounted, x: f32, y: f32) -> Option { let computado = self.computado.as_ref()?; let idx = llimphi_compositor::hit_test_click(montado, computado, x, y)?; montado.nodes.get(idx).and_then(|n| n.on_click.clone()) } } #[cfg(test)] mod pruebas { use super::*; use alloc::vec; use llimphi_cpu::peniko::Color; use llimphi_layout::taffy::prelude::*; use llimphi_layout::Style; #[derive(Clone, Debug, PartialEq)] enum Msg { Tocado, } /// Un `Style` de caja de tamaño fijo — el equivalente del `w_px/h_px` que /// uno esperaría, escrito con el vocabulario real de taffy. /// Un `Style` que llena el viewport. Hace falta explicitarlo: el default de /// taffy es `auto`, o sea que la raíz se dimensiona POR CONTENIDO. Sin esto /// el fondo sólo cubriría la caja del hijo — que es correcto, pero no lo que /// uno espera de un fondo de pantalla. fn pantalla_completa() -> Style { Style { size: Size { width: percent(1.0), height: percent(1.0), }, ..Default::default() } } fn caja(w: f32, h: f32) -> Style { Style { size: Size { width: length(w), height: length(h), }, ..Default::default() } } /// El ciclo entero, de `View` a bytes de framebuffer. /// /// Es la prueba de que la cáscara cierra: si el mount, el layout, el paint /// o el volcado fallaran, el framebuffer quedaría en negro y esto lo dice. #[test] fn el_ciclo_completo_llega_al_framebuffer() { let mut cascara = Cascara::new(64, 48); let vista: View = View::new(pantalla_completa()) .fill(Color::from_rgba8(20, 20, 30, 255)) .children(vec![View::new(caja(20.0, 10.0)) .fill(Color::from_rgba8(255, 0, 0, 255)) .on_click(Msg::Tocado)]); let _montado = cascara.fotograma(vista); // Framebuffer con stride mayor que el ancho, como el de un GOP real. const STRIDE: usize = 80 * 4; let mut fb = vec![0u8; STRIDE * 48]; let filas = cascara.volcar(&mut fb, STRIDE, FormatoPixel::Bgra8); assert_eq!(filas, 48, "se vuelcan todas las filas"); let px = |x: usize, y: usize| { let i = y * STRIDE + x * 4; (fb[i], fb[i + 1], fb[i + 2]) }; // El hijo rojo, donde el layout lo puso (esquina superior izquierda). assert_eq!(px(5, 5), (0, 0, 255), "el hijo rojo, en BGRA"); // Y el fondo índigo fuera de él: si el ciclo se cortara en cualquier // punto, esto seguiría en cero. assert_eq!(px(40, 30), (30, 20, 20), "BGRA del fondo índigo"); } /// Un click sobre el hijo tiene que devolver su `Msg`; fuera, nada. #[test] fn el_hit_test_encuentra_el_nodo_clickeable() { let mut cascara = Cascara::new(64, 48); let vista: View = View::new(Style::default()).children(vec![ View::new(caja(20.0, 10.0)).on_click(Msg::Tocado), ]); let montado = cascara.fotograma(vista); assert_eq!( cascara.click(&montado, 5.0, 5.0), Some(Msg::Tocado), "adentro del hijo" ); assert_eq!(cascara.click(&montado, 50.0, 40.0), None, "afuera"); } /// El texto también llega — es lo que W2 y W3.a habilitaron. #[test] fn el_texto_se_pinta_en_el_framebuffer() { let mut cascara = Cascara::new(120, 40); let vista: View = View::new(pantalla_completa()) .fill(Color::BLACK) .children(vec![View::new(Style::default()).text( "Hola wawa", 18.0, Color::WHITE, )]); let _ = cascara.fotograma(vista); const STRIDE: usize = 120 * 4; let mut fb = vec![0u8; STRIDE * 40]; cascara.volcar(&mut fb, STRIDE, FormatoPixel::Bgra8); // Contar píxeles claros: el texto es blanco sobre negro, así que // cualquier cosa por encima del fondo es un glifo. let claros = fb.chunks_exact(4).filter(|p| p[0] > 60).count(); assert!( claros > 50, "el texto tiene que encender píxeles claros, encendió {claros}" ); } // ------------------------------------------------------------------------- // W4 :: el camino por el cable // ------------------------------------------------------------------------- /// De una escena que cruzó la frontera a píxeles, sin `View` de por medio. /// /// Es la prueba de que W4 cierra: la app —que acá se simula, porque el /// encoder desde el compositor todavía no existe— emitió una escena, la /// serializó, y de este lado se deserializó, se shapeó nativo y se /// rasterizó. Si algún eslabón fallara el framebuffer quedaría negro. #[test] fn una_escena_que_cruzo_la_frontera_llega_a_pixeles() { use llimphi_cpu::kurbo::{Affine, Rect}; use llimphi_wire_escena::{EscenaWire, EstiloTextoWire, Texto}; // --- lado app (jaula): compone y serializa --------------------------- let mut wire = EscenaWire::new(); wire.fill( llimphi_cpu::peniko::Fill::NonZero, Affine::IDENTITY, Color::BLACK, None, &Rect::new(0.0, 0.0, 160.0, 40.0), ); wire.texto(Texto { cadena: alloc::string::String::from("Hola wawa"), estilo: EstiloTextoWire { tamano_px: 18.0, ..Default::default() }, color: [255, 255, 255, 255], ancho_max: None, xf: llimphi_wire_escena::UbicacionWire::Punto([4.0, 4.0]), hint: true, }); let bytes = postcard::to_allocvec(&wire).expect("serializar"); // --- la frontera ------------------------------------------------------ let recibida: EscenaWire = postcard::from_bytes(&bytes).expect("deserializar"); // --- lado kernel: shapea y rasteriza ---------------------------------- let mut cascara = Cascara::new(160, 40); let st = cascara.fotograma_wire(&recibida); assert_eq!(st.ejecutadas, 2); assert_eq!(st.textos, 1, "el texto se shapeó de ESTE lado"); assert_eq!(st.salteadas, 0); assert_eq!(st.imagenes_sin_blob, 0); const STRIDE: usize = 160 * 4; let mut fb = vec![0u8; STRIDE * 40]; cascara.volcar(&mut fb, STRIDE, FormatoPixel::Bgra8); let claros = fb.chunks_exact(4).filter(|p| p[0] > 60).count(); assert!( claros > 50, "el texto que cruzó el cable tiene que encender píxeles, encendió {claros}" ); // El cable pesó lo que tenía que pesar: una caja y nueve letras. assert!(bytes.len() < 200, "la escena pesó {} B", bytes.len()); } /// Un índice de estilo corrupto no puede voltear el kernel. /// /// La escena viene de una app en una jaula. Que el IR sea un formato propio /// no lo hace confiable: lo hace *nuestro*. Un `unwrap` en el reproductor /// sería un pánico en Ring 0 disparable desde userspace. /// Los bytes que emitió el compositor de verdad, del otro lado de la /// frontera. Los genera `llimphi-jaula/examples/emitir_fixture.rs`. const FIXTURE_JAULA: &[u8] = include_bytes!("../pruebas/fixture_jaula.postcard"); /// **El test que cierra W4**: una escena compuesta por el ENCODER real llega /// a píxeles por el reproductor real. /// /// Los dos extremos no pueden compilarse juntos —`wire` y `cpu` son backends /// excluyentes de `llimphi-text`, con `compile_error!` puesto a propósito—, /// así que la costura del test es la misma que la de producción: bytes. El /// otro test de acá arriba fabrica su escena a mano; éste consume lo que /// `render.rs` emitió de verdad, con su mount, su layout y su paint. /// /// Si alguien cambia el formato del cable de un solo lado, esto lo dice. /// **Los clips anidados de más NO se pushean, y lo que sigue se ve igual.** /// /// `vello` devuelve la escena ENTERA transparente —sin error ni log— cuando /// el stack de clip/blend anidado pasa su límite. El compositor lo capea /// desde julio de 2026, pero la escena de un huésped enjaulado **no pasa /// por el compositor**: llega como órdenes crudas y se reproducen encima de /// la cadena de capas que el anfitrión ya traía. /// /// Medido en metal el 2026-09-24: el explorador enjaulado en modo GRILLA /// —cuyas celdas anidan más que las filas de una lista— pintaba el cuerpo /// del cajón completamente transparente, dejando ver el fondo de /// escritorio, mientras los contadores decían que todo se había ejecutado. /// En modo lista se veía bien. Ése era el «nahual invisible». #[test] fn los_clips_de_mas_se_capean_y_el_dibujo_sigue_saliendo() { use llimphi_cpu::kurbo::{Affine, Rect}; use llimphi_wire_escena::EscenaWire; let hondo = 8usize; let mut wire = EscenaWire::new(); for _ in 0..hondo { wire.push_layer( llimphi_cpu::peniko::Fill::NonZero, llimphi_cpu::peniko::BlendMode::default(), 1.0, Affine::IDENTITY, &Rect::new(0.0, 0.0, 80.0, 40.0), ); } // Adentro del pozo, algo que se TIENE que ver. wire.fill( llimphi_cpu::peniko::Fill::NonZero, Affine::IDENTITY, Color::WHITE, None, &Rect::new(0.0, 0.0, 80.0, 40.0), ); for _ in 0..hondo { wire.pop_layer(); } let bytes = postcard::to_allocvec(&wire).expect("serializar"); let recibida: EscenaWire = postcard::from_bytes(&bytes).expect("deserializar"); let mut cascara = Cascara::new(80, 40); let st = cascara.fotograma_wire(&recibida); assert_eq!( st.clips_capeados, hondo - 3, "sólo los tres primeros clips se pushean; los otros {} se capean", hondo - 3 ); assert_eq!(st.salteadas, 0, "ningún `CerrarCapa` queda huérfano"); // Y lo de adentro SIGUE pintando: el recorte que se perdió es cosmético // (a esa profundidad ya no recorta nada), pero la escena no se va en // blanco — que es exactamente lo que este cap existe para evitar. const STRIDE: usize = 80 * 4; let mut fb = vec![0u8; STRIDE * 40]; cascara.volcar(&mut fb, STRIDE, FormatoPixel::Bgra8); let claros = fb.chunks_exact(4).filter(|p| p[0] > 200).count(); assert!(claros > 1000, "el relleno de adentro se ve: {claros} px claros"); } #[test] fn la_escena_que_emitio_el_compositor_llega_a_pixeles() { use llimphi_wire_escena::EscenaWire; let escena: EscenaWire = postcard::from_bytes(FIXTURE_JAULA).expect("el fixture tiene que deserializar"); // Lo que la jaula reportó al generarlo: 3 órdenes, 2 textos, 2 estilos. assert_eq!(escena.len(), 3); assert_eq!(escena.textos(), 2); assert_eq!(escena.estilos().len(), 2); let mut cascara = Cascara::new(320, 72); let st = cascara.fotograma_wire(&escena); assert_eq!(st.ejecutadas, 3); assert_eq!(st.textos, 2, "los dos textos se shapearon de ESTE lado"); assert_eq!(st.salteadas, 0, "el cable llegó entero"); const STRIDE: usize = 320 * 4; let mut fb = vec![0u8; STRIDE * 72]; cascara.volcar(&mut fb, STRIDE, FormatoPixel::Bgra8); // El fondo del fixture es (22,26,38); cualquier píxel distinto de él y // del negro de afuera salió de un glifo. Contarlos aparte del alfa es la // lección de W3.d: el texto va sobre relleno opaco, así que contar sólo // «encendidos» daría lo mismo con letras o sin ellas. let de_texto = fb .chunks_exact(4) .filter(|p| p[3] > 0 && (p[0] != 38 || p[1] != 26 || p[2] != 22)) .count(); assert!( de_texto > 200, "el texto del compositor tenía que encender píxeles, encendió {de_texto}" ); } // ------------------------------------------------------------------------- // W4 :: la medición, el camino de vuelta // ------------------------------------------------------------------------- /// La petición cruza y vuelve con un número que sirve. /// /// Es el contrato de `sys_medir_texto` ejercido de punta a punta menos el /// marshalling de wasmi: se serializa como lo haría la app, se deserializa /// como lo hace el kernel, se mide con el shaper de verdad, y se empaqueta /// la respuesta en sus doce bytes. #[test] fn la_peticion_de_medida_vuelve_con_un_numero_util() { use llimphi_wire_escena::{EstiloTextoWire, MedidaTexto, PeticionMedida}; let peticion = PeticionMedida { cadena: alloc::string::String::from("Hola wawa"), estilo: EstiloTextoWire { tamano_px: 18.0, ..Default::default() }, ancho_max: None, }; // --- lado app: serializa y manda --- let bytes = postcard::to_allocvec(&peticion).expect("serializar"); // --- lado kernel: deserializa, mide con parley, contesta 12 bytes --- let recibida: PeticionMedida = postcard::from_bytes(&bytes).expect("deserializar"); let mut ts = llimphi_text::Typesetter::new(); let medida = medir_texto( &mut ts, &recibida.cadena, &recibida.estilo, recibida.ancho_max, ); let respuesta = medida.a_bytes(); // --- lado app: desempaqueta --- let vuelta = MedidaTexto::de_bytes(&respuesta); assert_eq!(vuelta, medida, "la respuesta cambió al cruzar"); // Nueve caracteres a 18 px no pueden medir cero ni una barbaridad. El // rango es amplio a propósito: lo que se certifica es que shapeó de // verdad, no una tipografía concreta. assert!( vuelta.width > 30.0 && vuelta.width < 200.0, "ancho fuera de rango: {}", vuelta.width ); assert!(vuelta.full_width >= vuelta.width); assert!( vuelta.height > 10.0 && vuelta.height < 60.0, "alto fuera de rango: {}", vuelta.height ); } /// **El test que justifica el syscall**: medir por el contrato da lo MISMO /// que medir directo. /// /// Si estos dos números divergieran, una app enjaulada layoutearía con una /// medida y el kernel pintaría con otra: el texto saldría corrido respecto /// de su caja, y el motivo sería invisible desde los dos lados. #[test] fn medir_por_el_contrato_da_lo_mismo_que_medir_directo() { use llimphi_wire_escena::EstiloTextoWire; let mut ts = llimphi_text::Typesetter::new(); let estilo = EstiloTextoWire { tamano_px: 15.0, ..Default::default() }; let cadena = "escena emitida por la jaula"; // Por el contrato de W4. let por_contrato = medir_texto(&mut ts, cadena, &estilo, None); // Directo, como lo haría el compositor en Linux. let layout = ts.layout_clamped( cadena, 15.0, None, llimphi_text::Alignment::Start, estilo.alto_linea, false, None, estilo.peso, None, false, false, false, 0.0, 0.0, false, "", "", estilo.ancho_fuente, ); let directo = llimphi_text::measurement(&layout); assert_eq!(por_contrato.width, directo.width, "el ancho difiere"); assert_eq!(por_contrato.height, directo.height, "el alto difiere"); assert_eq!(por_contrato.full_width, layout.full_width()); } /// El estilo con rangos toma el otro camino, y también mide. #[test] fn el_texto_con_rangos_se_mide_por_el_camino_de_richtext() { use llimphi_wire_escena::{EstiloRangoWire, EstiloTextoWire, RangoWire}; let mut ts = llimphi_text::Typesetter::new(); let cadena = "titulo y cuerpo"; let plano = EstiloTextoWire { tamano_px: 14.0, ..Default::default() }; let con_rango = EstiloTextoWire { rangos: vec![RangoWire { inicio: 0, fin: 6, estilo: EstiloRangoWire { tamano_px: Some(28.0), ..Default::default() }, }], ..plano.clone() }; let m_plano = medir_texto(&mut ts, cadena, &plano, None); let m_rango = medir_texto(&mut ts, cadena, &con_rango, None); // Un span al doble de tamaño tiene que ensanchar Y agrandar la línea. Si // el camino de rangos se ignorara, los dos números serían idénticos. assert!( m_rango.width > m_plano.width, "el span de 28 px no ensanchó: {} vs {}", m_rango.width, m_plano.width ); assert!( m_rango.height > m_plano.height, "el span de 28 px no agrandó la línea: {} vs {}", m_rango.height, m_plano.height ); } #[test] fn un_estilo_fuera_de_rango_se_saltea_en_vez_de_entrar_en_panico() { use llimphi_wire_escena::{EscenaWire, OrdenTexto, OrdenWire}; // Una escena con la tabla de estilos VACÍA y un texto que pide el 99. // Es lo que llegaría de un cable roto o de una app hostil: postcard lo // acepta —el tipo no valida rangos— así que el reproductor tiene que. let mut wire = EscenaWire::new(); wire.empujar_crudo(OrdenWire::Texto(OrdenTexto { cadena: alloc::string::String::from("x"), estilo: 99, color: [255, 255, 255, 255], ancho_max: None, xf: llimphi_wire_escena::UbicacionWire::Punto([0.0, 0.0]), hint: false, })); let bytes = postcard::to_allocvec(&wire).unwrap(); let recibida: EscenaWire = postcard::from_bytes(&bytes).unwrap(); let mut cascara = Cascara::new(32, 16); let st = cascara.fotograma_wire(&recibida); assert_eq!(st.salteadas, 1, "se saltea y se cuenta"); assert_eq!(st.textos, 0); } /// F7 — el relleno de la vista raíz tiene que llegar a la primera y a la /// última columna de la pantalla. /// /// En el teléfono (1080 de ancho) las columnas 0-3 y 1076-1079 quedaban /// negras: el `fill` de la raíz, cuyo rect es 0..1080, no las alcanzaba. /// Hoy lo tapa el `color_base` del pintor hybrid, así que el síntoma sólo /// se ve cuando la base es de otro color — pero el agujero está igual. #[test] fn el_relleno_de_la_raiz_llega_a_las_columnas_del_borde() { const ANCHO: u16 = 1080; const ALTO: u16 = 64; let mut cascara = Cascara::new(ANCHO, ALTO); let vista: View = View::new(caja(ANCHO as f32, ALTO as f32)).fill(Color::from_rgba8(24, 26, 31, 255)); let _ = cascara.fotograma(vista); const STRIDE: usize = ANCHO as usize * 4; let mut fb = vec![0u8; STRIDE * ALTO as usize]; cascara.volcar(&mut fb, STRIDE, FormatoPixel::Bgra8); let fila = ALTO as usize / 2; let vacias: alloc::vec::Vec = (0..ANCHO as usize) .filter(|x| { let i = fila * STRIDE + x * 4; fb[i] == 0 && fb[i + 1] == 0 && fb[i + 2] == 0 }) .collect(); assert!(vacias.is_empty(), "columnas sin pintar: {vacias:?}"); } }