gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ main llimphi-wire-escena/src/texto.rs Rust · 261 líneas · 10.7 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

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}