// ============================================================================= // wire :: el encoder — el lado app del cable (W4 de PLAN-LLIMPHI) // ----------------------------------------------------------------------------- // Este módulo es la mitad que faltaba de W4. El crate `llimphi-wire-escena` // define el cable; `llimphi-wawa::reproductor` es el extremo nativo; esto es el // extremo de la app. // // ## Por qué la costura está acá y no en `render.rs` // // El primer instinto era ponerle `#[cfg]` al bloque de texto de `render.rs` — // 110 líneas con tres ramas dentro del archivo más caliente del compositor. // Contar las llamadas dijo otra cosa: en todo `llimphi-compositor` hay // **tres** invocaciones a `typesetter.layout_*`, y todas piden lo mismo // (shapear) para hacer lo mismo (medir y dibujar). O sea que la costura natural // no es el llamador, es el llamado. // // Así que `Typesetter` es OTRO TIPO acá. Mismo nombre, mismas firmas, otro // cuerpo — exactamente el truco que ya funcionó dos veces en este plan: `Escena` // espejando a `vello::Scene` en W1, y los gemelos CPU en W3.b. El resultado es // que **`render.rs` no cambia ni una línea**. // // ## Lo que este Typesetter hace en vez de shapear // // No puede shapear: parley es justamente lo que no debe correr en la jaula (el // 30,3x del intérprete se comería el 72% del fotograma — W4.1). Pero el layout // SÍ necesita medir: taffy no puede dimensionar una etiqueta sin saber cuánto // mide. Ése es el nudo que el plan no había desatado. // // La salida es que **medir se delega, igual que rasterizar**. El typesetter de // la app lleva un *medidor*: una función que pregunta del otro lado de la // frontera. En wawa será un `sys_`; en un test es un `Typesetter` de verdad. La // medida es cacheable nativamente (el caché de shaping ya existe), así que un // acierto no cuesta un shaping. // // ## Sin medidor NO se devuelve cero // // Un cero silencioso colapsaría el layout entero sin un solo error: todas las // cajas de texto medirían nada y la pantalla saldría vacía «funcionando». Sin // medidor se usa una estimación grosera **y se cuenta**, para que // [`Typesetter::medidas_estimadas`] pueda delatarlo en un test o en una traza. // ============================================================================= use alloc::boxed::Box; use alloc::string::{String, ToString}; use alloc::vec::Vec; use llimphi_wire_escena::{ AlineacionWire, EstiloRangoWire, EstiloTextoWire, RangoWire, Texto, UbicacionWire, }; use peniko::Color; use crate::{Alignment, Measurement, TextSpan}; /// El lienzo de este backend: la escena serializable. pub use llimphi_wire_escena::EscenaWire; /// Lo que el medidor tiene que contestar. /// /// Son los tres números que el compositor le pide hoy a parley: `width` (ancho /// de línea real), `full_width` (idem pero contando el espacio final, que parley /// excluye) y `height`. Que sean exactamente tres, y estos tres, no es /// arbitrario — es lo que `measure_text_node` consume, y pedir menos rompería el /// layout de maneras sutiles que `render.rs` ya documenta en detalle. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Medida { pub width: f32, pub full_width: f32, pub height: f32, } /// Lo que el medidor recibe para contestar. pub struct Peticion<'a> { pub cadena: &'a str, pub estilo: &'a EstiloTextoWire, /// Ancho de envoltura. `None` = una sola línea (max-content). pub ancho_max: Option, } /// El typesetter del lado app: no shapea, pregunta y anota. /// /// Mismo nombre y mismas firmas que el de parley, otro cuerpo. Ver el encabezado /// del módulo para el porqué. pub struct Typesetter { medidor: Option) -> Medida>>, estimadas: usize, } impl Default for Typesetter { fn default() -> Self { Self::new() } } impl Typesetter { /// Un typesetter **sin medidor**. /// /// Existe porque la firma de `Typesetter::new()` es parte del contrato que /// este tipo espeja, pero mide por estimación y lo cuenta. Para algo real, /// [`Typesetter::con_medidor`]. pub fn new() -> Self { Self { medidor: None, estimadas: 0, } } /// Un typesetter que pregunta del otro lado de la frontera. /// /// En wawa el cierre envuelve un `sys_`; en un test, un `Typesetter` de /// parley de verdad — que es como se certifica que la app y el kernel miden /// IGUAL, sin lo cual el texto se pintaría corrido respecto del layout. pub fn con_medidor(medidor: F) -> Self where F: FnMut(&Peticion<'_>) -> Medida + 'static, { Self { medidor: Some(Box::new(medidor)), estimadas: 0, } } /// Cuántas medidas salieron de la estimación en vez del medidor real. /// /// Distinto de cero significa que el layout de esas etiquetas es aproximado. /// Es la estadística que un test debe exigir en cero. pub fn medidas_estimadas(&self) -> usize { self.estimadas } fn medir(&mut self, p: &Peticion<'_>) -> Medida { match self.medidor.as_mut() { Some(f) => f(p), None => { self.estimadas += 1; estimar(p) } } } // ------------------------------------------------------------------------- // Las tres firmas que `llimphi-compositor` invoca. Idénticas a las del // typesetter de parley — es lo único que hace falta para que `render.rs` // compile sin cambios. // ------------------------------------------------------------------------- /// Espeja `Typesetter::layout_clamped`. No shapea: mide y guarda el pedido. #[allow(clippy::too_many_arguments)] /// Espeja `crate::Typesetter::layout` — el layout **sin clampear**. /// /// Faltaba, y no era un olvido inocente: sin él, /// `llimphi-widget-text-input` —el campo de una línea que usan el buscador, /// el picker y la paleta— no compilaba contra este backend, así que el editor /// entero no podía cruzar a una jaula por culpa de un campito. /// /// **Delega en [`Self::layout_clamped`] con exactamente los mismos valores /// neutros que usa el backend que pinta** (`layout` → `layout_inner` con /// `overflow_wrap: false`, sin features ni variaciones, ancho 100), más /// `max_lines: None` y `ellipsis: false`. Que los dos lados elijan igual no /// es prolijidad: es lo que hace que una app se mida igual nativa que /// enjaulada, y una diferencia acá no mueve el glifo — mueve la caja de /// línea. #[allow(clippy::too_many_arguments)] pub fn layout( &mut self, text: &str, size_px: f32, max_width: Option, alignment: Alignment, line_height: f32, italic: bool, font_family: Option<&str>, weight: f32, underline: bool, strikethrough: bool, letter_spacing: f32, word_spacing: f32, ) -> TextoMedido { self.layout_clamped( text, size_px, max_width, alignment, line_height, italic, font_family, weight, None, false, underline, strikethrough, letter_spacing, word_spacing, false, "", "", 100.0, ) } pub fn layout_clamped( &mut self, text: &str, size_px: f32, max_width: Option, alignment: Alignment, line_height: f32, italic: bool, font_family: Option<&str>, weight: f32, max_lines: Option, ellipsis: bool, underline: bool, strikethrough: bool, letter_spacing: f32, word_spacing: f32, overflow_wrap: bool, font_features: &str, font_variations: &str, font_width: f32, ) -> TextoMedido { let estilo = EstiloTextoWire { tamano_px: size_px, alineacion: alineacion(alignment), italica: italic, familia: font_family.map(|s| s.to_string()), alto_linea: line_height, peso: weight, max_lineas: max_lines.map(|n| n as u32), elipsis: ellipsis, subrayado: underline, tachado: strikethrough, espaciado_letra: letter_spacing, espaciado_palabra: word_spacing, // Redundante con `ancho_max == None` y se llena igual: el que // reproduce usa `ancho_max`, pero el que INSPECCIONE la escena (un // depurador, una traza) merece leer la intención y no deducirla. sin_envolver: max_width.is_none(), partir_palabra: overflow_wrap, caracteristicas: font_features.to_string(), variaciones: font_variations.to_string(), ancho_fuente: font_width, rangos: Vec::new(), }; self.armar(text, estilo, None, max_width) } /// Espeja `Typesetter::layout_runs` — texto multicolor. /// /// Los `runs` (color por rango de bytes) se vuelcan a los rangos del IR con /// sólo el color puesto: es exactamente lo que expresan, y el shaper del /// otro lado los aplica igual que parley acá. #[allow(clippy::too_many_arguments)] pub fn layout_runs( &mut self, text: &str, size_px: f32, default_color: Color, runs: &[(usize, usize, Color)], alignment: Alignment, line_height: f32, weight: f32, underline: bool, strikethrough: bool, font_family: Option<&str>, ) -> TextoMedido { let rangos = runs .iter() .map(|&(inicio, fin, color)| RangoWire { inicio: inicio as u32, fin: fin as u32, estilo: EstiloRangoWire { color: Some(rgba(color)), ..Default::default() }, }) .collect(); let estilo = EstiloTextoWire { tamano_px: size_px, alineacion: alineacion(alignment), familia: font_family.map(|s| s.to_string()), alto_linea: line_height, peso: weight, subrayado: underline, tachado: strikethrough, // `layout_runs` no envuelve (así está documentado del otro lado): // shapea la línea entera de una y el caller la posiciona. sin_envolver: true, rangos, ..Default::default() }; self.armar(text, estilo, Some(default_color), None) } /// Espeja `Typesetter::layout_spans` — RichText. #[allow(clippy::too_many_arguments)] pub fn layout_spans( &mut self, text: &str, size_px: f32, default_color: Color, weight: f32, line_height: f32, italic: bool, font_family: Option<&str>, underline: bool, strikethrough: bool, spans: &[TextSpan], max_width: Option, alignment: Alignment, ) -> TextoMedido { let rangos = spans .iter() .map(|s| RangoWire { inicio: s.start as u32, fin: s.end as u32, estilo: EstiloRangoWire { tamano_px: s.style.size_px, peso: s.style.weight, italica: s.style.italic, familia: s.style.font_family.clone(), color: s.style.color.map(rgba), subrayado: s.style.underline, tachado: s.style.strikethrough, }, }) .collect(); let estilo = EstiloTextoWire { tamano_px: size_px, alineacion: alineacion(alignment), italica: italic, familia: font_family.map(|s| s.to_string()), alto_linea: line_height, peso: weight, subrayado: underline, tachado: strikethrough, sin_envolver: max_width.is_none(), rangos, ..Default::default() }; self.armar(text, estilo, Some(default_color), max_width) } fn armar( &mut self, text: &str, estilo: EstiloTextoWire, color: Option, ancho_max: Option, ) -> TextoMedido { let medida = self.medir(&Peticion { cadena: text, estilo: &estilo, ancho_max, }); TextoMedido { cadena: text.to_string(), estilo, color, ancho_max, medida, } } } /// Lo que `layout_*` devuelve de este lado: el pedido, sin resolver. /// /// Ocupa el lugar de `parley::Layout` en el flujo del compositor y expone los /// mismos tres consultores que éste usa. Que no haya un solo glifo adentro es el /// punto entero de W4. #[derive(Clone, Debug, PartialEq)] pub struct TextoMedido { cadena: String, estilo: EstiloTextoWire, /// Color base cuando lo trajo el `layout_*` (runs/spans). El camino /// uniforme lo recibe recién al dibujar. color: Option, ancho_max: Option, medida: Medida, } impl TextoMedido { /// Ancho de línea. Espeja `parley::Layout::width`. pub fn width(&self) -> f32 { self.medida.width } /// Ancho contando el espacio final. Espeja `parley::Layout::full_width`. pub fn full_width(&self) -> f32 { self.medida.full_width } /// Alto del bloque. Espeja `parley::Layout::height`. pub fn height(&self) -> f32 { self.medida.height } /// La cadena, sin shapear. pub fn cadena(&self) -> &str { &self.cadena } /// El estilo con el que hay que shapearla del otro lado. pub fn estilo(&self) -> &EstiloTextoWire { &self.estilo } } /// La medida de un layout. Espeja `crate::measurement`. pub fn measurement(l: &TextoMedido) -> Measurement { Measurement { width: l.medida.width, height: l.medida.height, } } /// Ancho de una cadena SIN typesetter. Espeja `crate::text_width`. /// /// Bajo `wire` no hay typesetter global al que preguntarle —el de este backend /// pregunta del otro lado de la frontera, y esta función no tiene por dónde—, /// así que responde con la ESTIMACIÓN de emergencia. Es deliberadamente honesto /// que exista así: los widgets que dimensionan su cromo con `text_width` (el /// ancho de un menú, la pestaña de una tab) obtienen un número aproximado, y el /// texto real se posiciona igual de bien porque el paint no pasa por acá — la /// medida buena la da el kernel vía el medidor de la jaula. Si la aproximación /// molesta en algún widget, el camino es pasarle el `Typesetter` de la jaula /// (`text_width_with`), no inventar un global. pub fn text_width(text: &str, size_px: f32) -> f32 { let estilo = EstiloTextoWire { tamano_px: size_px, sin_envolver: true, ..Default::default() }; estimar(&Peticion { cadena: text, estilo: &estilo, ancho_max: None, }) .width } /// Ancho de una cadena. Espeja `crate::text_width_with`. pub fn text_width_with(ts: &mut Typesetter, text: &str, size_px: f32) -> f32 { let estilo = EstiloTextoWire { tamano_px: size_px, sin_envolver: true, ..Default::default() }; ts.medir(&Peticion { cadena: text, estilo: &estilo, ancho_max: None, }) .width } // ----------------------------------------------------------------------------- // Las funciones de dibujo: acá el pedido se convierte en una orden del cable. // ----------------------------------------------------------------------------- /// Espeja `crate::draw_layout_xf` — texto de color único. pub fn draw_layout_xf( scene: &mut EscenaWire, layout: &TextoMedido, color: Color, transform: peniko::kurbo::Affine, ) { emitir(scene, layout, color, transform); } /// Espeja `crate::draw_layout` — la variante con origen en vez de afín. pub fn draw_layout( scene: &mut EscenaWire, layout: &TextoMedido, color: Color, origin: (f64, f64), ) { emitir( scene, layout, color, peniko::kurbo::Affine::translate(origin), ); } /// Espeja `crate::draw_layout_runs` — multicolor, con origen en vez de afín. /// /// Estaba la variante con afín y faltaba ésta. Es una línea, y por esa línea /// `text-input` no cruzaba. pub fn draw_layout_runs(scene: &mut EscenaWire, layout: &TextoMedido, origin: (f64, f64)) { draw_layout_runs_xf(scene, layout, peniko::kurbo::Affine::translate(origin)); } /// Espeja `crate::draw_layout_runs_xf` — multicolor y RichText. /// /// El color base ya viajó en el `TextoMedido` (se lo pasaron a `layout_runs`/ /// `layout_spans`); los rangos lo pisan donde corresponde. pub fn draw_layout_runs_xf( scene: &mut EscenaWire, layout: &TextoMedido, transform: peniko::kurbo::Affine, ) { let color = layout.color.unwrap_or(Color::BLACK); emitir(scene, layout, color, transform); } fn emitir( scene: &mut EscenaWire, layout: &TextoMedido, color: Color, transform: peniko::kurbo::Affine, ) { scene.texto(Texto { cadena: layout.cadena.clone(), estilo: layout.estilo.clone(), color: rgba(color), ancho_max: layout.ancho_max, xf: UbicacionWire::de(transform), // Fijo en `true` por la misma razón que en el backend CPU: a tamaños de // UI ajustar los contornos a la grilla es la diferencia entre texto // nítido y texto sucio, y no cuesta nada de este lado — lo hace el // rasterizador nativo. hint: true, }); } // ----------------------------------------------------------------------------- fn rgba(c: Color) -> [u8; 4] { let r = c.to_rgba8(); [r.r, r.g, r.b, r.a] } fn alineacion(a: Alignment) -> AlineacionWire { match a { Alignment::Start => AlineacionWire::Inicio, Alignment::Center => AlineacionWire::Centro, Alignment::End => AlineacionWire::Fin, Alignment::Justify => AlineacionWire::Justificado, } } /// La estimación de emergencia, para cuando no hay medidor. /// /// No pretende ser buena: pretende ser **distinta de cero y contada**. Un cero /// colapsaría el layout sin un solo error —todas las cajas de texto medirían /// nada— y la pantalla saldría vacía «funcionando». 0,5 × tamaño es el ancho /// medio de un glifo proporcional a ojo; sirve para que el árbol tenga forma /// mientras alguien se olvidó de enchufar el medidor. fn estimar(p: &Peticion<'_>) -> Medida { let chars = p.cadena.chars().count() as f32; let ancho_glifo = p.estilo.tamano_px * 0.5; let alto_linea = p.estilo.tamano_px * p.estilo.alto_linea; let ancho_natural = chars * ancho_glifo; match p.ancho_max { Some(w) if w > 0.0 && ancho_natural > w => { let lineas = (ancho_natural / w).ceil_estimado(); Medida { width: w, full_width: w, height: alto_linea * lineas, } } _ => Medida { width: ancho_natural, full_width: ancho_natural, height: alto_linea, }, } } /// `ceil` sin `std` y sin `libm`: la estimación no merece una dependencia. trait CeilEstimado { fn ceil_estimado(self) -> f32; } impl CeilEstimado for f32 { fn ceil_estimado(self) -> f32 { let t = self as i64 as f32; if self > t { t + 1.0 } else { t } } } /// Espeja `crate::layout_block` — construye el layout de un [`TextBlock`]. /// /// **Las tres constantes de abajo son las del backend que pinta, copiadas a /// propósito**, y son la parte que hay que cuidar: un `TextBlock` no transporta /// peso, ni decoración, ni espaciado —esos fluyen por el camino directo del /// compositor— así que acá se rellenan con los mismos valores que allá. Si un /// día cambian de un solo lado, el texto de una app enjaulada se acomodaría /// distinto que el de la misma app suelta, y el síntoma sería una caja de línea /// corrida: casi bien, que es lo peor. pub fn layout_block(ts: &mut Typesetter, block: &crate::TextBlock<'_>) -> TextoMedido { ts.layout( block.text, block.size_px, block.max_width, block.alignment, block.line_height, block.italic, block.font_family.as_deref(), // Peso: `TextBlock` no lo transporta (su API queda en normal). 400.0, // Decoración: tampoco viaja por `TextBlock`. false, false, // Espaciado de letra y palabra: ídem. 0.0, 0.0, ) } #[cfg(test)] mod pruebas { use super::*; fn ts_con_medida(w: f32, h: f32) -> Typesetter { Typesetter::con_medidor(move |_p: &Peticion<'_>| Medida { width: w, full_width: w, height: h, }) } /// **`layout` es `layout_clamped` sin clamp, y nada más.** Se fija porque los /// valores neutros tienen que ser los mismos que usa el backend que pinta: si /// acá se colara un `overflow_wrap: true` o un ancho de fuente distinto, una /// app enjaulada se acomodaría distinto que la misma app suelta. #[test] fn layout_es_el_clamped_sin_clampear() { let mut a = ts_con_medida(40.0, 12.0); let mut b = ts_con_medida(40.0, 12.0); let uno = a.layout("hola", 12.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0); let otro = b.layout_clamped( "hola", 12.0, None, Alignment::Start, 1.2, false, None, 400.0, None, false, false, false, 0.0, 0.0, false, "", "", 100.0, ); assert_eq!(uno.estilo(), otro.estilo(), "el estilo tiene que salir idéntico"); assert_eq!(uno.cadena(), otro.cadena()); assert_eq!(uno.width(), otro.width()); assert_eq!(uno.height(), otro.height()); } /// **Brazo de control**: si el clamp SÍ se pide, el estilo cambia. Sin esto, /// un `layout_clamped` que ignorara sus argumentos dejaría verde el test de /// arriba y no probaría nada. #[test] fn con_clamp_el_estilo_deja_de_ser_el_mismo() { let mut a = ts_con_medida(40.0, 12.0); let mut b = ts_con_medida(40.0, 12.0); let sin = a.layout("hola", 12.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0); let con = b.layout_clamped( "hola", 12.0, None, Alignment::Start, 1.2, false, None, 400.0, Some(2), true, false, false, 0.0, 0.0, false, "", "", 100.0, ); assert_ne!(sin.estilo(), con.estilo(), "pedir dos líneas con elipsis NO es lo mismo"); } /// `layout_block` rellena peso, decoración y espaciado con **las constantes /// del backend que pinta**. Es lo único de las tres que se puede desincronizar /// en silencio, así que se fija por número. #[test] fn layout_block_usa_las_constantes_del_lado_que_pinta() { let mut ts = ts_con_medida(30.0, 14.0); let bloque = crate::TextBlock { text: "hola", size_px: 13.0, color: Color::BLACK, origin: (0.0, 0.0), max_width: Some(200.0), alignment: Alignment::Center, line_height: 1.4, italic: true, font_family: None, }; let l = layout_block(&mut ts, &bloque); let e = l.estilo(); assert_eq!(e.peso, 400.0, "un TextBlock no transporta peso: normal"); assert!(!e.subrayado && !e.tachado, "ni decoración"); assert_eq!(e.espaciado_letra, 0.0); assert_eq!(e.espaciado_palabra, 0.0); // Y lo que SÍ transporta llega entero. assert_eq!(e.tamano_px, 13.0); assert!(e.italica); assert_eq!(l.cadena(), "hola"); } /// La variante con origen es la de la afín con una traslación. Se fija /// comparando la orden que sale al cable. #[test] fn draw_con_origen_es_la_afin_trasladada() { let mut ts = ts_con_medida(20.0, 10.0); let l = ts.layout("x", 11.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0); let mut por_origen = EscenaWire::new(); draw_layout_runs(&mut por_origen, &l, (7.0, 3.0)); let mut por_afin = EscenaWire::new(); draw_layout_runs_xf(&mut por_afin, &l, peniko::kurbo::Affine::translate((7.0, 3.0))); assert_eq!(por_origen.ordenes().len(), 1); assert_eq!(por_origen.ordenes(), por_afin.ordenes(), "misma orden, mismo lugar"); } }