tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1// =============================================================================
2// texto :: la razón de ser de este crate
3// -----------------------------------------------------------------------------
4// Acá está la conclusión de W4.1, convertida en un tipo. La medición decía:
5//
6// 500 widgets, texto vivo | app shapea (IR con glifos) | kernel shapea
7// ------------------------|---------------------------|-----------------
8// total | 45,92 ms → 21,8 fps | 14,06 ms → 71 fps
9//
10// O sea: si la app shapea antes de mandar, el texto se lleva 32,94 ms de los
11// 45,92 —el 72% del fotograma— porque cada llamada a parley paga el 30,3x del
12// intérprete. Si en cambio el IR lleva LA CADENA, la app no toca parley nunca y
13// el mismo trabajo cuesta 1,09 ms nativos. Es 3,3x, y es toda la diferencia
14// entre «el IR de escena se sostiene» y «no vale la pena contra la jaula pura».
15//
16// De ahí sale la forma de `OrdenTexto`: NO lleva glifos, lleva `cadena` +
17// `estilo` + dónde ponerlo. El reproductor shapea con su propio `Typesetter`, y
18// como es el mismo de siempre, también hereda su caché — que es lo que sostiene
19// el régimen «tibio» de la tabla (53-428 fps).
20//
21// El costo aceptado: `parley` y `swash` viven del lado nativo, o sea en Ring 0
22// cuando el lado nativo es el kernel. Es el mismo trato que ya se hizo con
23// `vello_cpu` en W1 y por la misma razón — que el trabajo caro caiga del lado
24// barato—, sólo que ahora se paga dos veces.
25//
26// Los campos del estilo no son una selección de gusto: son EXACTAMENTE los que
27// `llimphi_compositor::TextMeasure` declara como los que afectan el shaping y
28// el corte de línea. Si el wire llevara menos, el texto se mediría distinto de
29// como se pinta; si llevara más (el color entra por otro lado, en el pincel),
30// estaría duplicando estado.
31// =============================================================================
32
33use alloc::string::String;
34use alloc::vec::Vec;
35
36use serde::{Deserialize, Serialize};
37
38use crate::forma::UbicacionWire;
39use crate::pincel::Rgba;
40
41/// Alineación horizontal dentro de la caja. Espeja `llimphi_text::Alignment`.
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
43pub enum AlineacionWire {
44 #[default]
45 Inicio,
46 Centro,
47 Fin,
48 Justificado,
49}
50
51/// Overrides de estilo para un rango de bytes. Espeja `llimphi_text::TextSpanStyle`.
52///
53/// Viaja completo y no reducido porque el rich text es justo el caso donde el
54/// shaping se vuelve caro —cambiar de familia a mitad de párrafo fuerza a parley
55/// a resolver otra cara— y por lo tanto el caso que MÁS conviene que caiga del
56/// lado nativo. Recortarlo acá sería empujar el trabajo caro de vuelta a la
57/// jaula justo donde duele.
58#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
59pub struct EstiloRangoWire {
60 pub tamano_px: Option<f32>,
61 pub peso: Option<f32>,
62 pub italica: Option<bool>,
63 pub familia: Option<String>,
64 pub color: Option<Rgba>,
65 pub subrayado: Option<bool>,
66 pub tachado: Option<bool>,
67}
68
69/// Un rango de bytes `[inicio, fin)` con overrides. Espeja `llimphi_text::TextSpan`.
70#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
71pub struct RangoWire {
72 pub inicio: u32,
73 pub fin: u32,
74 pub estilo: EstiloRangoWire,
75}
76
77/// Todo lo que hace falta para shapear del otro lado.
78///
79/// Los `u32` en vez de `usize` son deliberados: el wire tiene que significar lo
80/// mismo leído desde un guest de 32 bits (wasm) y desde un kernel de 64.
81#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
82pub struct EstiloTextoWire {
83 pub tamano_px: f32,
84 pub alineacion: AlineacionWire,
85 pub italica: bool,
86 pub familia: Option<String>,
87 pub alto_linea: f32,
88 pub peso: f32,
89 pub max_lineas: Option<u32>,
90 pub elipsis: bool,
91 pub subrayado: bool,
92 pub tachado: bool,
93 pub espaciado_letra: f32,
94 pub espaciado_palabra: f32,
95 pub sin_envolver: bool,
96 pub partir_palabra: bool,
97 pub caracteristicas: String,
98 pub variaciones: String,
99 pub ancho_fuente: f32,
100 /// Vacío = texto uniforme (el camino barato del otro lado).
101 pub rangos: Vec<RangoWire>,
102}
103
104impl Default for EstiloTextoWire {
105 fn default() -> Self {
106 Self {
107 tamano_px: 14.0,
108 alineacion: AlineacionWire::Inicio,
109 italica: false,
110 familia: None,
111 alto_linea: 1.2,
112 peso: 400.0,
113 max_lineas: None,
114 elipsis: false,
115 subrayado: false,
116 tachado: false,
117 espaciado_letra: 0.0,
118 espaciado_palabra: 0.0,
119 sin_envolver: false,
120 partir_palabra: false,
121 caracteristicas: String::new(),
122 variaciones: String::new(),
123 ancho_fuente: 100.0,
124 rangos: Vec::new(),
125 }
126 }
127}
128
129// =============================================================================
130// El contrato de la MEDICIÓN — la otra mitad de la frontera
131// -----------------------------------------------------------------------------
132// El IR resuelve el camino de ida: la app compone y manda. Pero hay un camino
133// de vuelta que el diseño necesita y que es fácil pasar por alto: **taffy no
134// puede dimensionar una etiqueta sin saber cuánto mide**, y medir es shapear.
135//
136// Si la app midiera por su cuenta, parley correría en la jaula y el 30,3x del
137// intérprete volvería justo después de haberlo sacado. Así que medir se delega
138// igual que rasterizar: la app pregunta, el lado nativo contesta.
139//
140// Estos dos tipos son ese contrato. Viven acá —y no en el kernel ni en la
141// cáscara de la app— porque son la frontera misma: los dos lados tienen que
142// entender exactamente lo mismo, y el único lugar donde eso se puede garantizar
143// es el crate que ya define el resto del cable.
144// =============================================================================
145
146/// Lo que la app pregunta: qué cadena, con qué estilo, en qué ancho.
147///
148/// Viaja serializada con postcard porque la cadena y el estilo son de largo
149/// variable. La RESPUESTA, en cambio, son tres `f32` de tamaño fijo y viaja
150/// cruda (ver [`MedidaTexto`]): serializarla obligaría al lado nativo a alocar
151/// para devolver doce bytes.
152#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
153pub struct PeticionMedida {
154 pub cadena: String,
155 pub estilo: EstiloTextoWire,
156 /// Ancho de envoltura. `None` = una sola línea (max-content).
157 pub ancho_max: Option<f32>,
158}
159
160/// Lo que el lado nativo contesta: los tres números que el layout necesita.
161///
162/// Son exactamente los que `measure_text_node` del compositor consume, ni uno
163/// más ni uno menos:
164///
165/// - `width` — ancho de la línea más larga.
166/// - `full_width` — idem, pero contando el espacio final que parley excluye.
167/// Sin él, un texto terminado en espacio se parte en dos líneas al repintarse
168/// y el layout reservó alto para una. `render.rs` lo documenta en detalle.
169/// - `height` — alto del bloque ya envuelto.
170#[derive(Clone, Copy, Debug, Default, PartialEq)]
171#[repr(C)]
172pub struct MedidaTexto {
173 pub width: f32,
174 pub full_width: f32,
175 pub height: f32,
176}
177
178impl MedidaTexto {
179 /// Cuántos bytes ocupa en el cable. Tres `f32` little-endian.
180 pub const BYTES: usize = 12;
181
182 /// Empaqueta la medida para cruzar.
183 ///
184 /// A mano y no con `bytemuck`: son doce bytes y el kernel no necesita una
185 /// dependencia más para escribirlos. Little-endian explícito para que el
186 /// contrato no dependa del endianness del anfitrión — hoy los dos lados son
187 /// x86, pero eso es una coincidencia, no una garantía.
188 pub fn a_bytes(&self) -> [u8; Self::BYTES] {
189 let mut b = [0u8; Self::BYTES];
190 b[0..4].copy_from_slice(&self.width.to_le_bytes());
191 b[4..8].copy_from_slice(&self.full_width.to_le_bytes());
192 b[8..12].copy_from_slice(&self.height.to_le_bytes());
193 b
194 }
195
196 /// Desempaqueta la medida del otro lado.
197 pub fn de_bytes(b: &[u8; Self::BYTES]) -> Self {
198 let leer = |i: usize| f32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]);
199 Self {
200 width: leer(0),
201 full_width: leer(4),
202 height: leer(8),
203 }
204 }
205}
206
207/// Un texto a pintar, tal como lo declara quien graba.
208///
209/// `ancho_max` es lo que en el compositor sale del layout ya resuelto: la app
210/// **sí** corrió taffy (eso es barato aun 30x penalizado — 0,085 ms para 500
211/// nodos), así que sabe en qué ancho envolver. Lo que no hizo es shapear.
212///
213/// No es lo que viaja: [`crate::EscenaWire::texto`] separa el estilo (que se
214/// interna) de la orden (que queda con un índice). Ver [`OrdenTexto`].
215#[derive(Clone, Debug, PartialEq)]
216pub struct Texto {
217 pub cadena: String,
218 pub estilo: EstiloTextoWire,
219 /// Color base. Los rangos pueden pisarlo.
220 pub color: Rgba,
221 /// Ancho de envoltura en px. `None` = una sola línea.
222 pub ancho_max: Option<f32>,
223 /// Dónde va la caja de texto. Ver [`crate::UbicacionWire`] para el porqué
224 /// de las dos variantes.
225 ///
226 /// La esquina, no la línea base: el baseline lo resuelve el shaper.
227 pub xf: UbicacionWire,
228 /// Ajustar contornos a la grilla de píxeles. A 12-14 px es la diferencia
229 /// entre texto nítido y texto borroso.
230 pub hint: bool,
231}
232
233/// La orden de texto tal como viaja: el estilo por índice, no por valor.
234///
235/// -----------------------------------------------------------------------------
236/// Por qué un índice y no el estilo entero
237/// -----------------------------------------------------------------------------
238/// Porque medirlo dijo que había que hacerlo. La primera versión de este crate
239/// metía [`EstiloTextoWire`] completo en cada orden, y el test de peso
240/// (`el_texto_es_una_fraccion_chica_del_cable`) mostró que a 500 etiquetas el
241/// texto se llevaba **31.890 de 64.393 bytes: la mitad del cable**. Y no por
242/// las cadenas —que son ~8 B cada una— sino por repetir diecisiete campos de
243/// estilo quinientas veces.
244///
245/// Era un error de diseño con una forma conocida: en una UI real el estilo del
246/// texto NO varía por etiqueta, varía por rol. Una pantalla entera se pinta con
247/// un puñado de estilos —cuerpo, título, monoespaciado, deshabilitado— y las
248/// quinientas etiquetas se reparten entre ellos. Internarlos convierte esa
249/// repetición en un `u16`.
250///
251/// La tabla vive en la escena, no acá: ver [`crate::EscenaWire::estilos`].
252#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
253pub struct OrdenTexto {
254 pub cadena: String,
255 /// Índice en la tabla de estilos de la escena.
256 pub estilo: u16,
257 pub color: Rgba,
258 pub ancho_max: Option<f32>,
259 pub xf: UbicacionWire,
260 pub hint: bool,
261}