// ============================================================================= // texto :: la razón de ser de este crate // ----------------------------------------------------------------------------- // Acá está la conclusión de W4.1, convertida en un tipo. La medición decía: // // 500 widgets, texto vivo | app shapea (IR con glifos) | kernel shapea // ------------------------|---------------------------|----------------- // total | 45,92 ms → 21,8 fps | 14,06 ms → 71 fps // // O sea: si la app shapea antes de mandar, el texto se lleva 32,94 ms de los // 45,92 —el 72% del fotograma— porque cada llamada a parley paga el 30,3x del // intérprete. Si en cambio el IR lleva LA CADENA, la app no toca parley nunca y // el mismo trabajo cuesta 1,09 ms nativos. Es 3,3x, y es toda la diferencia // entre «el IR de escena se sostiene» y «no vale la pena contra la jaula pura». // // De ahí sale la forma de `OrdenTexto`: NO lleva glifos, lleva `cadena` + // `estilo` + dónde ponerlo. El reproductor shapea con su propio `Typesetter`, y // como es el mismo de siempre, también hereda su caché — que es lo que sostiene // el régimen «tibio» de la tabla (53-428 fps). // // El costo aceptado: `parley` y `swash` viven del lado nativo, o sea en Ring 0 // cuando el lado nativo es el kernel. Es el mismo trato que ya se hizo con // `vello_cpu` en W1 y por la misma razón — que el trabajo caro caiga del lado // barato—, sólo que ahora se paga dos veces. // // Los campos del estilo no son una selección de gusto: son EXACTAMENTE los que // `llimphi_compositor::TextMeasure` declara como los que afectan el shaping y // el corte de línea. Si el wire llevara menos, el texto se mediría distinto de // como se pinta; si llevara más (el color entra por otro lado, en el pincel), // estaría duplicando estado. // ============================================================================= use alloc::string::String; use alloc::vec::Vec; use serde::{Deserialize, Serialize}; use crate::forma::UbicacionWire; use crate::pincel::Rgba; /// Alineación horizontal dentro de la caja. Espeja `llimphi_text::Alignment`. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum AlineacionWire { #[default] Inicio, Centro, Fin, Justificado, } /// Overrides de estilo para un rango de bytes. Espeja `llimphi_text::TextSpanStyle`. /// /// Viaja completo y no reducido porque el rich text es justo el caso donde el /// shaping se vuelve caro —cambiar de familia a mitad de párrafo fuerza a parley /// a resolver otra cara— y por lo tanto el caso que MÁS conviene que caiga del /// lado nativo. Recortarlo acá sería empujar el trabajo caro de vuelta a la /// jaula justo donde duele. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct EstiloRangoWire { pub tamano_px: Option, pub peso: Option, pub italica: Option, pub familia: Option, pub color: Option, pub subrayado: Option, pub tachado: Option, } /// Un rango de bytes `[inicio, fin)` con overrides. Espeja `llimphi_text::TextSpan`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RangoWire { pub inicio: u32, pub fin: u32, pub estilo: EstiloRangoWire, } /// Todo lo que hace falta para shapear del otro lado. /// /// Los `u32` en vez de `usize` son deliberados: el wire tiene que significar lo /// mismo leído desde un guest de 32 bits (wasm) y desde un kernel de 64. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct EstiloTextoWire { pub tamano_px: f32, pub alineacion: AlineacionWire, pub italica: bool, pub familia: Option, pub alto_linea: f32, pub peso: f32, pub max_lineas: Option, pub elipsis: bool, pub subrayado: bool, pub tachado: bool, pub espaciado_letra: f32, pub espaciado_palabra: f32, pub sin_envolver: bool, pub partir_palabra: bool, pub caracteristicas: String, pub variaciones: String, pub ancho_fuente: f32, /// Vacío = texto uniforme (el camino barato del otro lado). pub rangos: Vec, } impl Default for EstiloTextoWire { fn default() -> Self { Self { tamano_px: 14.0, alineacion: AlineacionWire::Inicio, italica: false, familia: None, alto_linea: 1.2, peso: 400.0, max_lineas: None, elipsis: false, subrayado: false, tachado: false, espaciado_letra: 0.0, espaciado_palabra: 0.0, sin_envolver: false, partir_palabra: false, caracteristicas: String::new(), variaciones: String::new(), ancho_fuente: 100.0, rangos: Vec::new(), } } } // ============================================================================= // El contrato de la MEDICIÓN — la otra mitad de la frontera // ----------------------------------------------------------------------------- // El IR resuelve el camino de ida: la app compone y manda. Pero hay un camino // de vuelta que el diseño necesita y que es fácil pasar por alto: **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. Así que medir se delega // igual que rasterizar: la app pregunta, el lado nativo contesta. // // Estos dos tipos son ese contrato. Viven acá —y no en el kernel ni en la // cáscara de la app— porque son la frontera misma: los dos lados tienen que // entender exactamente lo mismo, y el único lugar donde eso se puede garantizar // es el crate que ya define el resto del cable. // ============================================================================= /// Lo que la app pregunta: qué cadena, con qué estilo, en qué ancho. /// /// Viaja serializada con postcard porque la cadena y el estilo son de largo /// variable. La RESPUESTA, en cambio, son tres `f32` de tamaño fijo y viaja /// cruda (ver [`MedidaTexto`]): serializarla obligaría al lado nativo a alocar /// para devolver doce bytes. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct PeticionMedida { pub cadena: String, pub estilo: EstiloTextoWire, /// Ancho de envoltura. `None` = una sola línea (max-content). pub ancho_max: Option, } /// Lo que el lado nativo contesta: los tres números que el layout necesita. /// /// Son exactamente los que `measure_text_node` del compositor consume, ni uno /// más ni uno menos: /// /// - `width` — ancho de la línea más larga. /// - `full_width` — idem, pero contando el espacio final que parley excluye. /// Sin él, un texto terminado en espacio se parte en dos líneas al repintarse /// y el layout reservó alto para una. `render.rs` lo documenta en detalle. /// - `height` — alto del bloque ya envuelto. #[derive(Clone, Copy, Debug, Default, PartialEq)] #[repr(C)] pub struct MedidaTexto { pub width: f32, pub full_width: f32, pub height: f32, } impl MedidaTexto { /// Cuántos bytes ocupa en el cable. Tres `f32` little-endian. pub const BYTES: usize = 12; /// Empaqueta la medida para cruzar. /// /// A mano y no con `bytemuck`: son doce bytes y el kernel no necesita una /// dependencia más para escribirlos. Little-endian explícito para que el /// contrato no dependa del endianness del anfitrión — hoy los dos lados son /// x86, pero eso es una coincidencia, no una garantía. pub fn a_bytes(&self) -> [u8; Self::BYTES] { let mut b = [0u8; Self::BYTES]; b[0..4].copy_from_slice(&self.width.to_le_bytes()); b[4..8].copy_from_slice(&self.full_width.to_le_bytes()); b[8..12].copy_from_slice(&self.height.to_le_bytes()); b } /// Desempaqueta la medida del otro lado. pub fn de_bytes(b: &[u8; Self::BYTES]) -> Self { let leer = |i: usize| f32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]); Self { width: leer(0), full_width: leer(4), height: leer(8), } } } /// Un texto a pintar, tal como lo declara quien graba. /// /// `ancho_max` es lo que en el compositor sale del layout ya resuelto: la app /// **sí** corrió taffy (eso es barato aun 30x penalizado — 0,085 ms para 500 /// nodos), así que sabe en qué ancho envolver. Lo que no hizo es shapear. /// /// No es lo que viaja: [`crate::EscenaWire::texto`] separa el estilo (que se /// interna) de la orden (que queda con un índice). Ver [`OrdenTexto`]. #[derive(Clone, Debug, PartialEq)] pub struct Texto { pub cadena: String, pub estilo: EstiloTextoWire, /// Color base. Los rangos pueden pisarlo. pub color: Rgba, /// Ancho de envoltura en px. `None` = una sola línea. pub ancho_max: Option, /// Dónde va la caja de texto. Ver [`crate::UbicacionWire`] para el porqué /// de las dos variantes. /// /// La esquina, no la línea base: el baseline lo resuelve el shaper. pub xf: UbicacionWire, /// Ajustar contornos a la grilla de píxeles. A 12-14 px es la diferencia /// entre texto nítido y texto borroso. pub hint: bool, } /// La orden de texto tal como viaja: el estilo por índice, no por valor. /// /// ----------------------------------------------------------------------------- /// Por qué un índice y no el estilo entero /// ----------------------------------------------------------------------------- /// Porque medirlo dijo que había que hacerlo. La primera versión de este crate /// metía [`EstiloTextoWire`] completo en cada orden, y el test de peso /// (`el_texto_es_una_fraccion_chica_del_cable`) mostró que a 500 etiquetas el /// texto se llevaba **31.890 de 64.393 bytes: la mitad del cable**. Y no por /// las cadenas —que son ~8 B cada una— sino por repetir diecisiete campos de /// estilo quinientas veces. /// /// Era un error de diseño con una forma conocida: en una UI real el estilo del /// texto NO varía por etiqueta, varía por rol. Una pantalla entera se pinta con /// un puñado de estilos —cuerpo, título, monoespaciado, deshabilitado— y las /// quinientas etiquetas se reparten entre ellos. Internarlos convierte esa /// repetición en un `u16`. /// /// La tabla vive en la escena, no acá: ver [`crate::EscenaWire::estilos`]. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct OrdenTexto { pub cadena: String, /// Índice en la tabla de estilos de la escena. pub estilo: u16, pub color: Rgba, pub ancho_max: Option, pub xf: UbicacionWire, pub hint: bool, }