// ============================================================================= // llimphi-reproductor :: el extremo nativo del cable (W4 de PLAN-LLIMPHI) // ----------------------------------------------------------------------------- // `llimphi-wire-escena` define QUÉ cruza la frontera. Este módulo es quien lo // recibe del otro lado y lo convierte en píxeles. // // El reparto que W4 eligió, visto desde acá: // // en la jaula (app) view + layout + geometría -> EscenaWire // ─────────────────── frontera: bytes postcard ─────────────────── // en el anfitrión shaping + aplanado + raster <- este módulo // // La línea está donde está por la medición de W4.1: lo caro es el 79-93% del // fotograma y cae entero de este lado, mientras que la app sólo arma cajas —que // aguanta el 30,3x del intérprete—. El texto es el caso que más se nota: llega // como CADENA y se shapea acá, con el `Typesetter` del anfitrión, que es el // mismo de siempre y por lo tanto trae su caché puesto. Ése es el detalle que // sostiene el régimen «tibio» de la tabla (53-428 fps): si el reproductor // creara un typesetter por fotograma, cada fotograma sería «frío» y estaríamos // en 3,4 fps. // // ## Por qué está escrito contra el ALIAS de lienzo y no contra un backend // // Hay **dos** anfitriones que reproducen: el kernel de wawa (lienzo de // `llimphi-cpu`) y shuma/pata en Linux (lienzo de `vello`). Dos reproductores // serían dos verdades sobre lo mismo, y la que se rompa en silencio va a ser la // que nadie mira. No hizo falta ningún trait para evitarlo: `llimphi_text::Lienzo` // ya es el alias por backend, y el lienzo de `llimphi-cpu` espeja el de `vello` // método por método (misma `kurbo` y misma `peniko` en el lock, así que los // tipos de los argumentos son literalmente los mismos). Este archivo compila // igual de los dos lados, sin una línea de diferencia. // // Corolario que conviene tener a mano: **este crate no enciende ninguna feature // de backend**. Si lo hiciera, la unificación de cargo la juntaría con la del // anfitrión y dispararía el `compile_error!` de backends excluyentes que // `llimphi-text` tiene justo para delatar eso. // // Lo que este módulo NO hace: confiar. Una escena que llega por el cable viene // de una app en una jaula, así que todo lo que puede estar mal se cuenta y se // sigue —índices de estilo fuera de rango, pinceles de imagen sin blob—. Un // `unwrap` acá es un pánico en Ring 0. // ============================================================================= #![cfg_attr(not(feature = "std"), no_std)] #![forbid(unsafe_code)] extern crate alloc; use alloc::vec::Vec; use kurbo::Stroke; use llimphi_text::{Lienzo, Typesetter}; use peniko::Brush; use llimphi_wire_escena::{ wire_a_afin, wire_a_color, AlineacionWire, EscenaWire, EstiloTextoWire, FormaKurbo, OrdenWire, }; /// Qué pasó al reproducir. Numérico a propósito: en un arranque headless nadie /// mira la pantalla, así que «anduvo» tiene que poder decirse por el serial. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct EstadisticasReproduccion { /// Órdenes que se ejecutaron. pub ejecutadas: usize, /// Órdenes de texto shapeadas de este lado — el punto de W4. pub textos: usize, /// Imágenes cuyo blob no está en el registro. No es un error del cable: es /// que el blob todavía no llegó por su canal aparte. pub imagenes_sin_blob: usize, /// Órdenes que se saltearon por venir mal (índice de estilo fuera de rango, /// pincel irreconstruible). Si esto no es cero, la pantalla difiere de lo /// que la app pidió y alguien tiene que enterarse. pub salteadas: usize, /// Clips que NO se pushearon por pasarse del tope de profundidad. No es un /// error: es el seguro que evita que `vello` devuelva la escena entera /// transparente. Que sea > 0 dice que la app está anidando más de lo que la /// GPU aguanta, y eso se quiere saber. pub clips_capeados: usize, } /// Reproduce una escena que llegó por el cable sobre una `Escena` nativa. /// /// No resetea la escena destino: quien llama decide si el fotograma arranca de /// cero o se compone encima de algo (el cromo del kernel, por ejemplo, pinta su /// barra y recién después deja que la app pinte debajo). pub fn reproducir( wire: &EscenaWire, destino: &mut Lienzo, ts: &mut Typesetter, ) -> EstadisticasReproduccion { reproducir_con_blobs(wire, destino, ts, &|_| None) } /// Igual que [`reproducir`], pero **con el registro de blobs**: `blobs(huella)` /// devuelve los píxeles de una imagen si quien hospeda los tiene. /// /// Existe porque sin él este reproductor **no dibujaba una sola imagen**: las /// órdenes de imagen viajan por huella y acá se contaban en `imagenes_sin_blob` /// y se seguía de largo. En el explorador enjaulado del cajón de pata eso era /// toda miniatura y toda previa **en negro** — el reporte del operador fue /// literal: *«no salen los thumbnails, solo sale negro»*. /// /// El registro es una función y no un mapa para no fijar un tipo de colección en /// un crate que corre también adentro del kernel: quien hospeda guarda los blobs /// como le convenga (ver `llimphi_widget_tablado::Tablado`). pub fn reproducir_con_blobs( wire: &EscenaWire, destino: &mut Lienzo, ts: &mut Typesetter, blobs: &dyn Fn(u64) -> Option, ) -> EstadisticasReproduccion { reproducir_con_tope(wire, destino, ts, blobs, TOPE_CLIPS_SUELTO) } /// Tope de clips cuando la escena se reproduce **sobre un lienzo propio**: el /// kernel bare-metal, un test, un volcado. Ahí la cadena empieza en cero. pub const TOPE_CLIPS_SUELTO: usize = 3; /// Tope de clips cuando la escena se reproduce **adentro de otra app**, que es /// el caso del tablado: el anfitrión ya gastó su presupuesto antes de llamar. /// /// `llimphi-compositor` capea las capas TOTALES en 4 —medido en esta Iris Xe: /// un árbol a profundidad 5 sale 100 % vacío y a 4 pinta— y el árbol del cajón /// de pata ya llega a 3. El tablado suma la suya para recortar, así que al /// huésped le queda **una**. Apilar más es pedirle a vello que devuelva la /// escena entera transparente, que es lo que hacía. pub const TOPE_CLIPS_HOSPEDADO: usize = 1; /// Igual que [`reproducir_con_blobs`], con el tope de clips explícito. /// /// El tope es un **parámetro y no una constante** porque depende de cuánta /// cadena traiga quien llama: suelto se empieza en cero, hospedado adentro del /// cajón de otra app no. Ver [`TOPE_CLIPS_SUELTO`] y [`TOPE_CLIPS_HOSPEDADO`]. pub fn reproducir_con_tope( wire: &EscenaWire, destino: &mut Lienzo, ts: &mut Typesetter, blobs: &dyn Fn(u64) -> Option, tope_clips: usize, ) -> EstadisticasReproduccion { let mut st = EstadisticasReproduccion::default(); // **El tope de clips anidados, y sin él la pantalla se va en blanco.** // // `vello` devuelve la ESCENA ENTERA 100 % transparente —sin error, sin // panic, sin log— cuando el stack de clip/blend anidado pasa su límite // (medido en esta Iris Xe: los árboles que llegan a 4 pintan, el que llega // a 5 sale vacío). `llimphi-compositor` lo capea desde julio de 2026 // (`render::rango::MAX_CLIP_DEPTH`, el bug del «cuerpo fantasma» del drawer // de pata) — pero **la escena del huésped no pasa por ahí**: llega como // órdenes crudas y se reproducen acá, encima de la cadena de capas que el // anfitrión ya traía (barra → cajón → desenrollado → el propio // `push_layer` del tablado). // // Resultado 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 `pintados=586 ordenes=525 salteadas=0` decía que // todo se había ejecutado. En modo lista, con menos anidamiento, se veía // bien: de ahí el «le doy v y aparece» del operador. // // Se capea igual que el compositor, y por la misma razón: un recorte // cosmético a esa profundidad casi nunca recorta nada, y perderlo es // invisible al lado de perder la escena entera. let max_clip_depth = tope_clips; // Una entrada por capa abierta: `true` = se pusheó de verdad y hay que // cerrarla. Sin esto, saltear un `push` desbalancearía los `pop` y el // recorte de lo que viene DESPUÉS saldría mal — que es peor que el clip // que se dejó de hacer. let mut pila_capas: Vec = Vec::new(); let mut prof_clip: usize = 0; // **Un pincel de imagen tampoco se reconstruye sin el registro.** Un relleno // o un trazo con textura caía en el mismo `imagenes_sin_blob` que la orden // de imagen suelta, y por el mismo motivo: `PincelWire::a_peniko` no puede // devolver un `Brush::Image` sin los píxeles. Acá se le dan. let brocha_de = |p: &llimphi_wire_escena::PincelWire| -> Option { if let llimphi_wire_escena::PincelWire::Imagen { huella } = p { return blobs(*huella).map(Brush::Image); } p.a_peniko() }; for orden in wire.ordenes() { match orden { OrdenWire::Rellenar { regla, xf, pincel, xf_pincel, forma, } => { let Some(brocha) = brocha_de(pincel) else { st.imagenes_sin_blob += 1; continue; }; let xf_p = xf_pincel.map(wire_a_afin); match forma.a_kurbo() { FormaKurbo::Rect(r) => { destino.fill(regla.a_peniko(), wire_a_afin(*xf), &brocha, xf_p, &r) } FormaKurbo::Redondeado(rr) => { destino.fill(regla.a_peniko(), wire_a_afin(*xf), &brocha, xf_p, &rr) } FormaKurbo::Camino(c) => { destino.fill(regla.a_peniko(), wire_a_afin(*xf), &brocha, xf_p, &c) } } st.ejecutadas += 1; } OrdenWire::Trazar { ancho, xf, pincel, xf_pincel, forma, } => { let Some(brocha) = brocha_de(pincel) else { st.imagenes_sin_blob += 1; continue; }; let estilo = Stroke::new(*ancho as f64); let xf_p = xf_pincel.map(wire_a_afin); match forma.a_kurbo() { FormaKurbo::Rect(r) => { destino.stroke(&estilo, wire_a_afin(*xf), &brocha, xf_p, &r) } FormaKurbo::Redondeado(rr) => { destino.stroke(&estilo, wire_a_afin(*xf), &brocha, xf_p, &rr) } FormaKurbo::Camino(c) => { destino.stroke(&estilo, wire_a_afin(*xf), &brocha, xf_p, &c) } } st.ejecutadas += 1; } OrdenWire::AbrirCapa { regla, mezcla, alfa, xf, recorte, } => { // Los clips MÁS PROFUNDOS que el tope no se pushean. Ver la // nota del tope arriba. let recortar = prof_clip < max_clip_depth; if recortar { destino.push_layer( regla.a_peniko(), mezcla.a_peniko(), *alfa, wire_a_afin(*xf), &recorte.a_kurbo().a_camino(), ); st.ejecutadas += 1; } else { st.clips_capeados += 1; } pila_capas.push(recortar); prof_clip += 1; } OrdenWire::AbrirMascaraLuminancia { regla, alfa, xf, recorte, } => { // La máscara **no** se capea: son raras, someras y // estructurales — capearlas cambiaría el dibujo, no sólo un // recorte. Misma decisión que el compositor. pila_capas.push(true); destino.push_luminance_mask_layer( regla.a_peniko(), *alfa, wire_a_afin(*xf), &recorte.a_kurbo().a_camino(), ); st.ejecutadas += 1; } OrdenWire::CerrarCapa => { // Se cierra SÓLO lo que se abrió de verdad; y la profundidad de // clip baja igual, la hayamos pusheado o no. match pila_capas.pop() { Some(true) => { destino.pop_layer(); st.ejecutadas += 1; } Some(false) => {} // Un `CerrarCapa` sin su apertura es una escena mal armada: // se cuenta y no se toca el destino, que es de quien // hospeda. None => st.salteadas += 1, } prof_clip = prof_clip.saturating_sub(1); } // El blob no viaja por el IR (ver `llimphi_wire_escena::pincel`), así // que sin registro no hay nada que dibujar. Se cuenta y se sigue: // una imagen faltante es un hueco en la pantalla, no un kernel caído. OrdenWire::Imagen { huella, xf } => match blobs(*huella) { Some(img) => { destino.draw_image(&img, wire_a_afin(*xf)); st.ejecutadas += 1; } // Sigue siendo un estado de verdad y no un error: el blob puede // no haber llegado todavía. Lo que era un defecto es que ésta // fuese la ÚNICA rama. None => st.imagenes_sin_blob += 1, }, OrdenWire::RectDifuso { xf, rect, color, radio, desvio, } => { destino.draw_blurred_rounded_rect( wire_a_afin(*xf), kurbo::Rect::new( rect[0] as f64, rect[1] as f64, rect[2] as f64, rect[3] as f64, ), wire_a_color(*color), *radio as f64, *desvio as f64, ); st.ejecutadas += 1; } OrdenWire::Texto(t) => { let Some(estilo) = wire.estilo(t.estilo) else { st.salteadas += 1; continue; }; shapear_y_pintar(t, estilo, destino, ts); st.ejecutadas += 1; st.textos += 1; } } } st } /// Mide una cadena con el shaper nativo — la respuesta a `sys_medir_texto`. /// /// Es la contrapartida del IR: el camino de ida manda la escena compuesta, y /// éste es el de vuelta que el diseño necesita — **taffy no puede dimensionar /// una etiqueta sin saber cuánto mide**, y medir es shapear. Si la app midiera /// por su cuenta, parley correría en la jaula y el 30,3x del intérprete volvería /// justo después de haberlo sacado por la puerta (W4.1: 21,8 fps contra 71). /// /// Vive acá y no en el kernel a propósito. Uno: es lógica de Llimphi, no de /// sistema — el kernel sólo tiene que prestar el typesetter y marshalar punteros. /// Dos, y más importante: acá **se puede probar con parley de verdad**, y lo que /// hay que probar es justamente que la app y el kernel midan IGUAL. /// /// Corre el mismo camino que el compositor elegiría en Linux: `layout_spans` si /// el estilo trae rangos, `layout_clamped` si no. Que la decisión sea la misma de /// los dos lados es lo que hace que una app se vea igual nativa o enjaulada; si /// acá se eligiera otro camino, el texto se mediría distinto de como se pinta. pub fn medir_texto( ts: &mut Typesetter, cadena: &str, estilo: &EstiloTextoWire, ancho_max: Option, ) -> llimphi_wire_escena::MedidaTexto { use llimphi_wire_escena::MedidaTexto; let alin = alineacion(estilo.alineacion); let familia = estilo.familia.as_deref(); if estilo.rangos.is_empty() { let layout = ts.layout_clamped( cadena, estilo.tamano_px, ancho_max, alin, estilo.alto_linea, estilo.italica, familia, estilo.peso, estilo.max_lineas.map(|n| n as usize), estilo.elipsis, estilo.subrayado, estilo.tachado, estilo.espaciado_letra, estilo.espaciado_palabra, estilo.partir_palabra, &estilo.caracteristicas, &estilo.variaciones, estilo.ancho_fuente, ); let m = llimphi_text::measurement(&layout); MedidaTexto { width: m.width, full_width: layout.full_width(), height: m.height, } } else { let rangos: Vec = estilo .rangos .iter() .map(|r| llimphi_text::TextSpan { start: r.inicio as usize, end: r.fin as usize, style: llimphi_text::TextSpanStyle { size_px: r.estilo.tamano_px, weight: r.estilo.peso, italic: r.estilo.italica, font_family: r.estilo.familia.clone(), // El COLOR no entra: no cambia el shaping, y meterlo haría // que dos etiquetas idénticas de distinto color fallaran el // caché por nada. color: None, underline: r.estilo.subrayado, strikethrough: r.estilo.tachado, }, }) .collect(); let layout = ts.layout_spans( cadena, estilo.tamano_px, peniko::Color::BLACK, estilo.peso, estilo.alto_linea, estilo.italica, familia, estilo.subrayado, estilo.tachado, &rangos, ancho_max, alin, ); MedidaTexto { width: layout.width(), full_width: layout.full_width(), height: layout.height(), } } } /// El corazón de W4: la cadena se shapea ACÁ, nativo. /// /// Se elige entre los dos caminos de `llimphi-text` por la misma regla que usa /// el compositor en Linux: sin rangos, el camino uniforme con caché /// (`layout_clamped`); con rangos, el RichText (`layout_spans`), que no cachea /// porque su clave sería un span-set arbitrario. Que la decisión sea la misma /// de los dos lados no es casualidad — es lo que hace que una app se vea igual /// corriendo nativa en Linux o enjaulada en wawa. fn shapear_y_pintar( t: &llimphi_wire_escena::OrdenTexto, estilo: &EstiloTextoWire, destino: &mut Lienzo, ts: &mut Typesetter, ) { let xf = t.xf.a_kurbo(); let color = wire_a_color(t.color); let familia = estilo.familia.as_deref(); let alineacion = alineacion(estilo.alineacion); if estilo.rangos.is_empty() { let layout = ts.layout_clamped( &t.cadena, estilo.tamano_px, t.ancho_max, alineacion, estilo.alto_linea, estilo.italica, familia, estilo.peso, estilo.max_lineas.map(|n| n as usize), estilo.elipsis, estilo.subrayado, estilo.tachado, estilo.espaciado_letra, estilo.espaciado_palabra, estilo.partir_palabra, &estilo.caracteristicas, &estilo.variaciones, estilo.ancho_fuente, ); llimphi_text::draw_layout_brush_xf(destino, &layout, &Brush::Solid(color), xf); } else { let rangos: Vec = estilo .rangos .iter() .map(|r| llimphi_text::TextSpan { start: r.inicio as usize, end: r.fin as usize, style: llimphi_text::TextSpanStyle { size_px: r.estilo.tamano_px, weight: r.estilo.peso, italic: r.estilo.italica, font_family: r.estilo.familia.clone(), color: r.estilo.color.map(wire_a_color), underline: r.estilo.subrayado, strikethrough: r.estilo.tachado, }, }) .collect(); let layout = ts.layout_spans( &t.cadena, estilo.tamano_px, color, estilo.peso, estilo.alto_linea, estilo.italica, familia, estilo.subrayado, estilo.tachado, &rangos, t.ancho_max, alineacion, ); llimphi_text::draw_layout_runs_xf(destino, &layout, xf); } } fn alineacion(a: AlineacionWire) -> llimphi_text::Alignment { match a { AlineacionWire::Inicio => llimphi_text::Alignment::Start, AlineacionWire::Centro => llimphi_text::Alignment::Center, AlineacionWire::Fin => llimphi_text::Alignment::End, AlineacionWire::Justificado => llimphi_text::Alignment::Justify, } }