tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1//! llimphi-text — Texto sobre vello vía parley.
2//!
3//! parley hace shaping completo (bidi, ligatures, kerning), line break y
4//! alineación; fontique resuelve fuentes del sistema con fallback CJK/emoji.
5//! Aquí lo envolvemos en una API mínima centrada en el caso común: un
6//! bloque de texto con color uniforme, ancho máximo opcional y alineación.
7
8//!
9//! ## Los dos perfiles (W2 de `03_ukupacha/wawa/PLAN-LLIMPHI.md`)
10//!
11//! El default es el de siempre: `std` + dibujo con vello + fuentes del sistema.
12//! El perfil bare-metal (`--no-default-features --features libm`) deja fuera a
13//! `vello` —que es std-only— y a `fontique/system`, y se queda con el shaping y
14//! las tres fuentes que este crate ya embebe y registra a mano. Es lo que
15//! permite que el kernel de wawa shapee texto, que es lo que W4.1 midió como
16//! condición para que el IR de escena se sostenga.
17
18// `no_std` cuando se pide el perfil bare-metal. Bajo `cargo test` y con `std`
19// —el default— se usa la stdlib sin cambios.
20#![cfg_attr(not(any(feature = "std", test)), no_std)]
21
22extern crate alloc;
23
24// Los cuatro son del camino de shaping (el `Typesetter` de parley y el caché de
25// layouts). Bajo `wire` ese camino no se compila —la app no shapea— así que los
26// imports quedarían colgados.
27#[cfg(feature = "shaping")]
28use alloc::borrow::Cow;
29// `Box` lo trae el preludio de std pero NO el de core: sin este import el crate
30// compila en el host y se cae recién en bare-metal, que es donde nadie mira.
31#[cfg(feature = "shaping")]
32use alloc::boxed::Box;
33#[cfg(feature = "shaping")]
34use alloc::format;
35use alloc::string::String;
36// `to_string` sólo lo llama el camino de shaping.
37#[cfg(feature = "shaping")]
38use alloc::string::ToString;
39#[cfg(feature = "shaping")]
40use alloc::vec::Vec;
41
42use peniko::Color;
43// `Brush` sólo lo tocan las funciones que pintan — que existen con CUALQUIERA de
44// los dos backends. Sin ninguno (perfil «sólo medir») el import quedaría colgado.
45#[cfg(any(feature = "vello", feature = "cpu"))]
46use peniko::Brush;
47
48#[cfg(feature = "shaping")]
49pub use parley;
50pub use peniko;
51#[cfg(feature = "vello")]
52pub use vello;
53
54// =============================================================================
55// El lienzo :: quién recibe los glifos (W3.b de PLAN-LLIMPHI.md)
56// -----------------------------------------------------------------------------
57// Las funciones de dibujo de este crate no nombran a `vello::Scene` sino a
58// `Lienzo`, que es un alias conmutado en compilación. Los dos tipos exponen los
59// MISMOS métodos con las MISMAS firmas —`llimphi_cpu::Escena` se diseñó
60// espejando a `vello::Scene`, no envolviéndola, justamente para esto—, así que
61// quien llama no cambia una línea según el backend. Es lo que permite que
62// `llimphi-compositor` cruce a wawa sin tocar sus 4.000 líneas de `render.rs`.
63// =============================================================================
64
65/// El lienzo de destino: `vello::Scene` en Linux, `llimphi_cpu::Escena` en wawa.
66#[cfg(all(feature = "vello", not(feature = "cpu")))]
67pub type Lienzo = vello::Scene;
68
69/// El lienzo de destino: `llimphi_cpu::Escena` — el camino sin GPU.
70#[cfg(all(feature = "cpu", not(feature = "vello"), not(feature = "wire")))]
71pub type Lienzo = llimphi_cpu::Escena;
72
73/// El lienzo de destino: la escena SERIALIZABLE — el camino de W4.
74///
75/// El tercer backend no rasteriza nada: graba las órdenes para que las ejecute
76/// otro proceso del otro lado de la frontera WASM. Es el que usa una app
77/// enjaulada, y el que hace que `render.rs` sirva sin cambios para los tres.
78///
79/// Trae consigo una diferencia que los otros dos no tienen: **acá tampoco se
80/// shapea**. Ver `crate::wire` para el porqué (es la decisión que W4.1 midió) y
81/// para cómo se resuelve el problema que eso abre — que taffy necesita medir el
82/// texto para dimensionarlo.
83#[cfg(feature = "wire")]
84pub type Lienzo = llimphi_wire_escena::EscenaWire;
85
86/// El encoder: el lado app del cable. Ver el encabezado del módulo.
87#[cfg(feature = "wire")]
88pub mod wire;
89
90#[cfg(feature = "wire")]
91pub use wire::{
92 draw_block, draw_layout, draw_layout_brush_xf, draw_layout_runs, draw_layout_runs_xf, draw_layout_xf, layout_block,
93 measure, measurement, text_width, text_width_with, Medida, Peticion, TextoMedido, Typesetter,
94};
95
96// El tercer backend es tan excluyente como los otros dos, y por el mismo motivo:
97// `Lienzo` no puede ser dos tipos. Se avisa aparte de la guarda vello/cpu porque
98// el remedio es distinto — `wire` no viene en ningún default, así que si está
99// encendida es porque alguien la pidió, probablemente sin apagar el default.
100#[cfg(all(feature = "wire", any(feature = "vello", feature = "cpu")))]
101compile_error!(
102 "llimphi-text: `wire` es un backend EXCLUYENTE y convive con `vello` o `cpu`. \
103 El camino del IR de escena se pide con \
104 `--no-default-features --features wire` (más `std` o `libm` según el target)."
105);
106
107// Los dos backends a la vez no se pueden: comparten nombres de función y el
108// alias tendría que ser dos tipos. Un `compile_error!` acá convierte lo que si
109// no sería un choque de nombres ilegible —o peor, un backend elegido en
110// silencio por unificación de features— en un mensaje que dice qué hacer.
111//
112// En la práctica no se cruzan: el workspace de Linux enciende `vello` por
113// default y nadie pide `cpu`; el kernel de wawa vive en un workspace APARTE
114// (`03_ukupacha/wawa` está excluido) y compila con
115// `--no-default-features --features libm,cpu`. Los dos lock files son distintos,
116// así que no hay unificación que los junte.
117#[cfg(all(feature = "vello", feature = "cpu"))]
118compile_error!(
119 "llimphi-text: las features `vello` y `cpu` son backends EXCLUYENTES y están \
120 las dos encendidas. `vello` viene en el `default`, así que para el camino \
121 CPU hay que pedir `--no-default-features --features cpu` (más `std` o \
122 `libm` según el target)."
123);
124
125// Sin ningún backend el crate sigue siendo útil —shapear y medir no necesitan
126// lienzo—, así que esto NO es un error: simplemente no existen las funciones de
127// dibujo. Es el perfil que usa quien sólo mide texto.
128
129
130/// Estado compartido del motor de texto. Una instancia por proceso es lo
131/// recomendado: `FontContext` cachea la base de fuentes y `LayoutContext`
132/// reutiliza allocaciones entre layouts.
133#[cfg(feature = "shaping")]
134pub struct Typesetter {
135 font_cx: parley::FontContext,
136 /// La stack a usar cuando un bloque pide [`MONOSPACE`]. Ver
137 /// [`Typesetter::stack_monospace`]: lleva la Nerd Font del sistema detrás
138 /// de la mono embebida, porque los glifos de zona privada (powerline,
139 /// iconos) no los cubre ninguna fuente estándar.
140 mono_stack: Box<str>,
141 layout_cx: parley::LayoutContext<()>,
142 /// Contexto separado para layouts multicolor (`Brush` por rango). El
143 /// brush genérico de parley no puede ser `()` y `RunBrush` a la vez en
144 /// el mismo `LayoutContext`, así que mantenemos uno por sabor.
145 runs_cx: parley::LayoutContext<RunBrush>,
146 /// Caché de shaping: `[`Self::layout`]` es el único chokepoint por el que
147 /// pasan medición y pintado (vía `layout_clamped`), y se invoca por cada
148 /// nodo de texto en **cada** redraw — dos veces (medir + pintar). Shapear
149 /// con parley (font matching, bidi, clusters, line break) es lo caro; el
150 /// `parley::Layout` resultante es `Clone`. Cacheamos por los parámetros
151 /// que lo determinan y clonamos en el hit: durante scroll/tipeo, el texto
152 /// que no cambió no se re-shapea.
153 cache: ShapeCache,
154 cache_hits: u64,
155 cache_misses: u64,
156}
157
158/// Estadísticas del caché de shaping (evidencia/benchmark). `entries` es el
159/// total vivo entre las dos generaciones.
160#[derive(Debug, Clone, Copy, Default)]
161pub struct CacheStats {
162 pub hits: u64,
163 pub misses: u64,
164 pub entries: usize,
165}
166
167/// Clave de caché: todos los parámetros que determinan un `layout`. Los `f32`
168/// van por `to_bits` para ser `Hash + Eq` exactos (sin problemas de NaN/−0.0:
169/// comparamos los bits crudos, no el valor numérico). `Alignment` se mapea a
170/// un tag `u8` porque su enum no deriva `Hash`.
171#[derive(Clone, PartialEq, Eq, Hash)]
172#[cfg(feature = "shaping")]
173struct ShapeKey {
174 text: String,
175 size_bits: u32,
176 max_width_bits: Option<u32>,
177 align: u8,
178 line_height_bits: u32,
179 italic: bool,
180 font_family: Option<String>,
181 weight_bits: u32,
182 /// Underline activo. parley emite `Decoration` por run cuando este flag
183 /// está, así que el layout difiere y el caché tiene que separarlos.
184 underline: bool,
185 /// Strikethrough activo. Idem `underline`.
186 strikethrough: bool,
187 /// `letter-spacing` (px extra entre letras). 0 = sin override. Cambia el
188 /// shaping/ancho, así que entra en la clave.
189 letter_bits: u32,
190 /// `word-spacing` (px extra entre palabras). Idem `letter_bits`.
191 word_bits: u32,
192 /// `overflow-wrap: break-word`/`anywhere`: si está, parley puede partir
193 /// dentro de una palabra para que entre en la caja. Cambia el line-break,
194 /// así que separa la entrada del caché.
195 overflow_wrap: bool,
196 /// `font-feature-settings` (+ `font-kerning` plegado como `kern`) en formato
197 /// CSS crudo (`"liga" 1, "kern" 0`). Vacío = default de la fuente. Cambia el
198 /// shaping (glifos/ligaduras), así que entra en la clave del caché.
199 features: String,
200 /// `font-variation-settings` en formato CSS crudo (`"wght" 700, "wdth" 85`).
201 /// Vacío = ejes en su default. Cambia el shaping (instancia de la fuente
202 /// variable), así que entra en la clave.
203 variations: String,
204 /// `font-stretch`/`font-width` como **porcentaje** (100 = normal, <100
205 /// condensada, >100 expandida). Selecciona la face condensada/expandida (o
206 /// el eje `wdth` de una variable) ⇒ cambia el shaping, entra en la clave.
207 font_width_bits: u32,
208}
209
210#[cfg(feature = "shaping")]
211fn align_tag(a: Alignment) -> u8 {
212 match a {
213 Alignment::Start => 0,
214 Alignment::Center => 1,
215 Alignment::End => 2,
216 Alignment::Justify => 3,
217 }
218}
219
220/// Caché generacional (LRU aproximado, sin dependencias). Dos mapas: `hot`
221/// recibe inserciones y promociones; cuando `hot` llega a `cap`, rota
222/// (`cold = hot`, `hot = ∅`) y la generación vieja se descarta. Un hit en
223/// `cold` se promueve a `hot`, así lo accedido en la última época sobrevive a
224/// la rotación — el texto visible, re-consultado cada frame, queda siempre
225/// caliente; lo transitorio (candidatos de elipsis, tooltips) cae solo. Es el
226/// patrón de los cachés de glyph/shape de swash/cosmic-text: O(1), sin orden
227/// enlazado.
228#[cfg(feature = "shaping")]
229struct ShapeCache {
230 hot: hashbrown::HashMap<ShapeKey, parley::Layout<()>>,
231 cold: hashbrown::HashMap<ShapeKey, parley::Layout<()>>,
232 cap: usize,
233}
234
235#[cfg(feature = "shaping")]
236impl ShapeCache {
237 fn new(cap: usize) -> Self {
238 Self {
239 hot: hashbrown::HashMap::new(),
240 cold: hashbrown::HashMap::new(),
241 cap,
242 }
243 }
244
245 /// Devuelve un clon del layout cacheado si existe, promoviendo desde
246 /// `cold` a `hot` en el camino.
247 fn get(&mut self, key: &ShapeKey) -> Option<parley::Layout<()>> {
248 if let Some(v) = self.hot.get(key) {
249 return Some(v.clone());
250 }
251 // Hit frío: sácalo de cold y reinsertalo en hot (promoción). Una sola
252 // clonación: el clon queda en hot, el original se devuelve al caller.
253 if let Some(v) = self.cold.remove(key) {
254 self.hot.insert(key.clone(), v.clone());
255 return Some(v);
256 }
257 None
258 }
259
260 fn put(&mut self, key: ShapeKey, layout: parley::Layout<()>) {
261 if self.hot.len() >= self.cap {
262 // Rota la generación: lo no reaccedido desde la última rotación
263 // (quedó sólo en cold) se libera aquí.
264 self.cold = core::mem::take(&mut self.hot);
265 }
266 self.hot.insert(key, layout);
267 }
268
269 fn clear(&mut self) {
270 self.hot.clear();
271 self.cold.clear();
272 }
273
274 fn entries(&self) -> usize {
275 self.hot.len() + self.cold.len()
276 }
277}
278
279/// Capacidad de la generación caliente antes de rotar. 512 layouts cubre con
280/// holgura el texto visible de una UI densa (un editor de ~50 líneas + chrome)
281/// sin retener de más. La memoria real es ~2× (dos generaciones).
282#[cfg(feature = "shaping")]
283const SHAPE_CACHE_CAP: usize = 512;
284
285#[cfg(feature = "shaping")]
286#[cfg(feature = "shaping")]
287mod emoji;
288
289/// Rangos de bytes de `text` que Unicode manda pintar **como emoji**.
290///
291/// El motivo, medido antes de escribir una línea (§8): con `TextBlock::simple`
292/// a 32 px sobre fondo negro, `😀` resolvía a **DejaVu Sans del sistema** y
293/// `❤️` a **Inter** — cero píxeles cromáticos en los cuatro casos probados. O
294/// sea que ninguna app de la suite pintaba un emoji a color, y no porque el
295/// rasterizador no supiera: vello 0.9 dibuja COLR y CBDT solo, y la
296/// `Noto Color Emoji` del sistema la resuelve fontconfig con el alias `emoji`.
297///
298/// **La causa es el ORDEN de la pila de fuentes.** parley detecta el cluster
299/// emoji y agrega la familia genérica `Emoji`, pero la agrega **al final**
300/// (`parley-0.6.0/src/shape/mod.rs:487`), detrás de la pila normal. Cualquier
301/// fuente de texto que cubra el codepoint gana: DejaVu Sans tiene contornos
302/// monocromos para el bloque Emoticons, e Inter tiene `❤` como dingbat. La
303/// carita salía, en blanco y negro, y nadie podía notar que no era la que
304/// correspondía.
305///
306/// El arreglo es empujar la familia `Emoji` **como estilo por rango** sobre
307/// los clusters de presentación emoji, que en parley pisa la pila del bloque.
308/// La clasificación no se inventa acá: sale del **mismo** analizador que usa
309/// parley para decidir `is_emoji` (`swash::text::cluster`), así que no puede
310/// divergir de lo que el shaper hace después.
311///
312/// **Pero la clasificación de swash sola no alcanza, y esto se midió antes de
313/// creerlo:** agrupa por Extended_Pictographic, que marca `⚠ ▶ ✔ © ‼ ☀ ⤴`.
314/// Mandarlos a la fuente de emoji convertiría los símbolos del chrome de la UI
315/// en caritas de colores — una regresión visible, y en la dirección contraria
316/// a la que este arreglo viene a corregir. Por eso el modo `Default` se cruza
317/// con la tabla de `Emoji_Presentation` de [`emoji`]: sale a color de fábrica
318/// sólo lo que Unicode dice. `Color` (VS16, `U+FE0F`) pasa siempre —ahí el
319/// pedido es explícito— y `Text` (VS15, `U+FE0E`) queda afuera por lo mismo,
320/// al revés.
321///
322/// Rangos contiguos se funden: un `👩👩👧` es un cluster, pero dos emoji
323/// seguidos son dos, y empujar dos estilos pegados es trabajo de más.
324#[cfg(feature = "shaping")]
325fn rangos_emoji(text: &str) -> Vec<core::ops::Range<usize>> {
326 use parley::swash::text::cluster::{Boundary, CharCluster, CharInfo, Emoji, Parser, Token};
327 use parley::swash::text::{Codepoint, Script};
328
329 let mut parser = Parser::new(
330 // El script sólo elige el agrupador de clusters complejos (índicas,
331 // birmano). Ningún emoji vive en un script complejo, así que el simple
332 // los agrupa bien; y un cluster mal agrupado de otro script no puede
333 // inventar un emoji, que es lo único que se lee de acá.
334 Script::Latin,
335 text.char_indices().map(|(i, ch)| Token {
336 ch,
337 offset: i as u32,
338 len: ch.len_utf8() as u8,
339 info: CharInfo::new(ch.properties(), Boundary::None),
340 data: 0,
341 }),
342 );
343 let mut cluster = CharCluster::new();
344 let mut rangos: Vec<core::ops::Range<usize>> = Vec::new();
345 while parser.next(&mut cluster) {
346 let r = cluster.range();
347 let color = match cluster.info().emoji() {
348 Emoji::Color => true,
349 // Se mira el cluster ENTERO, no su primer carácter: una bandera
350 // son dos indicadores regionales y ninguno de los dos es «la base».
351 // `get` y no `[..]`: el rango sale del parser y debería caer en
352 // borde de carácter siempre, pero esto lo corre CADA layout de
353 // CADA app — un panic acá se lleva puesta la suite entera, y un
354 // emoji monocromo no.
355 Emoji::Default => text
356 .get(r.start as usize..r.end as usize)
357 .is_some_and(|s| s.chars().any(emoji::presentacion_emoji)),
358 Emoji::None | Emoji::Text => false,
359 };
360 if !color {
361 continue;
362 }
363 let (ini, fin) = (r.start as usize, r.end as usize);
364 match rangos.last_mut() {
365 Some(ultimo) if ultimo.end == ini => ultimo.end = fin,
366 _ => rangos.push(ini..fin),
367 }
368 }
369 rangos
370}
371
372/// Empuja la familia genérica `Emoji` sobre los rangos de [`rangos_emoji`].
373///
374/// Va **último** en cada armado de layout, después de los estilos por rango
375/// del caller: un emoji adentro de un tramo al que la app le pidió una familia
376/// propia sigue teniendo que salir a color. Si la máquina no tiene fuente de
377/// emoji (el kernel de wawa, un contenedor pelado), la familia genérica no
378/// resuelve a nada y parley cae al fallback de siempre — o sea, exactamente lo
379/// que se veía antes de este cambio.
380#[cfg(feature = "shaping")]
381fn empujar_familia_emoji<B: parley::style::Brush>(
382 builder: &mut parley::RangedBuilder<'_, B>,
383 text: &str,
384) {
385 for r in rangos_emoji(text) {
386 builder.push(
387 parley::StyleProperty::FontStack(parley::FontStack::Single(
388 parley::FontFamily::Generic(parley::GenericFamily::Emoji),
389 )),
390 r,
391 );
392 }
393}
394
395// El gate va acá y no se hereda de nadie: sin él, un build `wire` (que NO
396// shapea) compila este `impl` contra el `Typesetter` de `wire.rs` —que ya trae
397// el suyo— y el crate no enlaza. Es la única línea de la vecindad que se quedó
398// sin `cfg`, y por eso el camino de la jaula estuvo roto sin que main lo viera.
399#[cfg(feature = "shaping")]
400impl Default for Typesetter {
401 fn default() -> Self {
402 Self::new()
403 }
404}
405
406/// DejaVu Sans embebida como **fallback universal de símbolos**. El motor
407/// confía en las fuentes del sistema vía fontique, pero muchas instalaciones
408/// (p. ej. solo Liberation/Adwaita) carecen de glyphs para flechas (`→`),
409/// formas geométricas (`● ▶`), dingbats (`✓ ✗ ✎`), avisos (`⚠`) o astro
410/// (`♈ ☉ ☽`) — y entonces parley pinta el "tofu" (□). DejaVu cubre todo ese
411/// rango; la registramos y la enganchamos al fallback del script `Common`
412/// (`Zyyy`), que es donde Unicode clasifica esos símbolos. Así cualquier app
413/// Llimphi deja de mostrar cuadrados sin tocar una línea de su código.
414/// Licencia: Bitstream Vera + Arev (libre, redistribuible).
415#[cfg(feature = "shaping")]
416const DEJAVU_SANS: &[u8] = include_bytes!("../assets/DejaVuSans.ttf");
417
418/// **Inter** embebida como **fuente de UI por defecto** (SIL OFL 1.1, libre y
419/// redistribuible — ver `assets/Inter-LICENSE.txt`). Inter es una grotesca
420/// neo-humanista diseñada específicamente para interfaces a tamaños chicos:
421/// caja alta de la x, aperturas amplias y espaciado parejo. Es el look 2026
422/// que queremos de fábrica, sin depender de que el sistema tenga una sans
423/// linda instalada (en una instalación pelada el default de fontique podía
424/// caer en Liberation/Adwaita, que envejecen mal). La enganchamos como
425/// primera familia del genérico `sans-serif` (ver [`Typesetter::install_ui_font`]),
426/// que es lo que parley resuelve cuando el bloque no pide `font_family`. El
427/// fallback por-script sigue intacto: símbolos via DejaVu, CJK/árabe/etc. via
428/// las fuentes del sistema.
429const INTER_SANS: &[u8] = include_bytes!("../assets/Inter-Regular.ttf");
430
431/// Fuente monoespaciada embebida (Liberation Mono, SIL OFL — metric-
432/// compatible con Courier). Va embebida para que *cualquier* app Llimphi
433/// pueda pedir ancho fijo (output de terminal, IDE-text, tablas que
434/// columnean) sin depender de que el sistema tenga una mono instalada.
435/// Se referencia por su nombre de familia con [`MONOSPACE`].
436#[cfg(feature = "shaping")]
437const LIBERATION_MONO: &[u8] = include_bytes!("../assets/LiberationMono.ttf");
438
439/// Bytes de la **variante NEGRITA** de la monospace embebida.
440///
441/// Va embebida por la misma razón que la regular: sin ella, pedir `weight: 700`
442/// sobre [`MONOSPACE`] **no cambia un píxel**, porque fontique resuelve la única
443/// cara que hay. Medido el 2026-10-05 con `--example hay_negrita`: «Liberation
444/// Mono» devolvía la MISMA cara para 400 y para 700, mientras que la `monospace`
445/// genérica del sistema devolvía dos distintas.
446///
447/// El síntoma afuera era que **ninguna terminal de la suite podía mostrar
448/// negrita** — el texto de claude salía plano, y el camino entero (celda del
449/// vt100 → `StyledSpan` → `TextSpanStyle` → `parley::FontWeight`) estaba bien.
450///
451/// Y embebida, no del sistema: en wawa bare-metal **sólo existen las embebidas**,
452/// así que una negrita que dependiera de fontconfig no estaría ahí.
453#[cfg(feature = "shaping")]
454const LIBERATION_MONO_BOLD: &[u8] = include_bytes!("../assets/LiberationMono-Bold.ttf");
455
456/// Bytes de la fuente **monospace embebida** (Liberation Mono TTF). Pública
457/// para que otros crates (p. ej. `llimphi-widget-terminal`, que necesita
458/// rasterizar glifos para su atlas GPU) usen exactamente la misma fuente
459/// que el render normal, sin volver a embeber el archivo.
460#[cfg(feature = "shaping")]
461pub const MONO_FONT_BYTES: &[u8] = LIBERATION_MONO;
462
463/// Bytes de la fuente **sans de UI embebida** (Inter Regular TTF). Pública para
464/// que otros crates rastericen texto proporcional con la misma fuente que el
465/// render normal (p. ej. `tullpu` para sus capas de texto), sin re-embeber el
466/// archivo. Referenciada por familia con [`UI_SANS`].
467pub const SANS_FONT_BYTES: &[u8] = INTER_SANS;
468
469/// Nombre de familia de la fuente monoespaciada embebida. Pasalo como
470/// `font_family: Some(llimphi_text::MONOSPACE)` en un [`TextBlock`] (o el
471/// `font_family` de `layout`) para render de ancho fijo garantizado.
472pub const MONOSPACE: &str = "Liberation Mono";
473
474/// Nombre de familia de la fuente de UI embebida ([Inter](https://rsms.me/inter/)).
475/// Es el default proporcional cuando un bloque **no** especifica `font_family`
476/// (la enganchamos como primera familia del genérico `sans-serif`). Exponemos
477/// el nombre por si un caller quiere pedirla explícitamente.
478pub const UI_SANS: &str = "Inter";
479
480#[cfg(feature = "shaping")]
481impl Typesetter {
482 pub fn new() -> Self {
483 let mut font_cx = parley::FontContext::new();
484 Self::install_ui_font(&mut font_cx);
485 Self::install_symbol_fallback(&mut font_cx);
486 Self::install_monospace(&mut font_cx);
487 let mono_stack = Self::stack_monospace(&mut font_cx);
488 Self {
489 font_cx,
490 mono_stack,
491 layout_cx: parley::LayoutContext::new(),
492 runs_cx: parley::LayoutContext::new(),
493 cache: ShapeCache::new(SHAPE_CACHE_CAP),
494 cache_hits: 0,
495 cache_misses: 0,
496 }
497 }
498
499 /// Registra **Inter** y la pone como **primera familia del genérico
500 /// `sans-serif`**. Ese genérico es lo que parley resuelve cuando un bloque
501 /// no especifica `font_family` (su default es `FontStack::Source("sans-serif")`),
502 /// así que con esto toda app Llimphi tipografía en Inter de fábrica sin
503 /// tocar una línea de su código, y sin depender de la sans del sistema.
504 /// Usamos `append_*` (no `set_*`) para no borrar las familias que el SO ya
505 /// asociaba al genérico: Inter va primero, el resto queda detrás como
506 /// respaldo. La cobertura de scripts no-latinos / símbolos sigue saliendo
507 /// del fallback por-script (CJK del sistema, símbolos de DejaVu). Si una
508 /// app pide otra familia explícita, gana esa. Best-effort: si el registro
509 /// falla, el texto sigue con la sans del sistema.
510 fn install_ui_font(font_cx: &mut parley::FontContext) {
511 use parley::fontique::{Blob, GenericFamily};
512 let blob = Blob::new(alloc::sync::Arc::new(INTER_SANS));
513 let registered = font_cx.collection.register_fonts(blob, None);
514 if let Some((family_id, _)) = registered.first() {
515 // Las familias actuales del genérico (las del sistema) van detrás:
516 // Inter primero, luego el respaldo previo.
517 let existing: Vec<_> = font_cx
518 .collection
519 .generic_families(GenericFamily::SansSerif)
520 .collect();
521 font_cx.collection.set_generic_families(
522 GenericFamily::SansSerif,
523 core::iter::once(*family_id).chain(existing),
524 );
525 }
526 }
527
528 /// ¿La máquina tiene una fuente de emoji que fontique pueda resolver?
529 ///
530 /// Es la familia genérica `emoji` (en Linux, el alias de fontconfig; en
531 /// Android/macOS/Windows, la lista fija que trae fontique). Sin ella, un
532 /// emoji cae al fallback de siempre y sale monocromo — que es lo que hacía
533 /// TODA la suite antes de [`rangos_emoji`], así que no es un modo roto,
534 /// es el modo viejo.
535 ///
536 /// Existe para que un test pueda saltearse **diciendo por qué** en vez de
537 /// fallar en una máquina pelada, y para que una app que muestre emoji
538 /// pueda avisar en vez de mostrar caritas grises sin explicación. En el
539 /// kernel de wawa da `false` por construcción: no hay fontconfig.
540 /// **Las familias de fuente que esta máquina tiene**, ordenadas por nombre.
541 ///
542 /// Existe para que una app pueda **ofrecer** una fuente en vez de pedirle al
543 /// usuario que adivine el nombre exacto: una familia mal escrita no da error,
544 /// da la fuente de siempre y el ajuste parece no funcionar. El primer
545 /// consumidor es el reloj de la barra de pata, donde el nombre se elige de
546 /// una lista y no se tipea.
547 ///
548 /// Sale del `collection` de parley —o sea de fontconfig en Linux y de las
549 /// embebidas en el kernel de wawa—, así que en bare-metal devuelve las tres
550 /// que hay y no una lista vacía ni un error.
551 pub fn familias_disponibles(&mut self) -> Vec<String> {
552 let mut v: Vec<String> =
553 self.font_cx.collection.family_names().map(|n| n.to_string()).collect();
554 v.sort_unstable();
555 v.dedup();
556 v
557 }
558
559 /// ¿Existe esta familia? Antes de guardar una elección conviene
560 /// preguntarlo: guardar un nombre que no está deja el ajuste puesto y el
561 /// texto igual que antes, que es el peor de los dos mundos.
562 pub fn hay_familia(&mut self, nombre: &str) -> bool {
563 self.font_cx.collection.family_names().any(|n| n.eq_ignore_ascii_case(nombre))
564 }
565
566 pub fn hay_fuente_de_emoji(&mut self) -> bool {
567 self.font_cx
568 .collection
569 .generic_families(parley::GenericFamily::Emoji)
570 .next()
571 .is_some()
572 }
573
574 /// Registra DejaVu Sans y la apila como último recurso para los símbolos
575 /// del script `Common` (flechas, geométricos, dingbats, astro…). Ver la
576 /// nota de [`DEJAVU_SANS`]. Best-effort: si algo falla, el texto sigue
577 /// funcionando con las fuentes del sistema (solo reaparecería el tofu).
578 fn install_symbol_fallback(font_cx: &mut parley::FontContext) {
579 use parley::fontique::Blob;
580 let blob = Blob::new(alloc::sync::Arc::new(DEJAVU_SANS));
581 let registered = font_cx.collection.register_fonts(blob, None);
582 if let Some((family_id, _)) = registered.first() {
583 // `Zyyy` (Common) es el script de la inmensa mayoría de los
584 // símbolos que daban tofu; lo apilamos al final del fallback.
585 font_cx
586 .collection
587 .append_fallbacks("Zyyy", core::iter::once(*family_id));
588 }
589 }
590
591 /// Registra la fuente monoespaciada embebida (Liberation Mono) bajo su
592 /// nombre de familia [`MONOSPACE`], para que `FontStack::Source`
593 /// (`font_family: Some(MONOSPACE)`) la resuelva aunque el sistema no
594 /// tenga ninguna mono instalada. Best-effort: si falla, los callers que
595 /// pidan monospace caen al fallback de fontique (mono del sistema, o la
596 /// proporcional si no hay) — el texto sigue, sólo pierde el ancho fijo.
597 fn install_monospace(font_cx: &mut parley::FontContext) {
598 use parley::fontique::Blob;
599 let blob = Blob::new(alloc::sync::Arc::new(LIBERATION_MONO));
600 font_cx.collection.register_fonts(blob, None);
601 // **Y su NEGRITA** (CÑ3, 2026-10-05). Se registra bajo la misma familia
602 // —el TTF declara `Liberation Mono` / `Bold`, así que fontique lo apila
603 // como otra cara de la misma— y es lo que hace que `weight: 700` resuelva
604 // algo distinto. Sin esto el peso se pedía y se ignoraba en silencio.
605 let bold = Blob::new(alloc::sync::Arc::new(LIBERATION_MONO_BOLD));
606 font_cx.collection.register_fonts(bold, None);
607 }
608
609 /// La **stack** que se usa cuando un bloque pide [`MONOSPACE`]: la mono
610 /// embebida y, detrás, una **Nerd Font del sistema** si la hay.
611 ///
612 /// Por qué (medido 2026-07-29). Las barras de estado y los prompts modernos
613 /// —zellij, starship, powerlevel10k— están hechos de glifos Nerd Font:
614 /// separadores «powerline» (`U+E0B0`), íconos de rama, de carpeta, de
615 /// lenguaje. Viven en la zona **privada** Unicode, así que ninguna fuente
616 /// estándar los trae.
617 ///
618 /// Lo engañoso es que con la stack por defecto (sans) **sí** resolvían — el
619 /// fallback de fontique encontraba solo una Nerd Font del sistema. Pero el
620 /// grid del terminal pide la familia mono explícita, y por ahí no llegaba.
621 /// Shapeado a 14 px:
622 ///
623 /// ```text
624 /// familia por defecto familia MONOSPACE MONOSPACE + Nerd
625 /// U+E0B0 glifo 1548 glifo 0 ← tofu glifo 1012
626 /// U+F192 glifo 3738 glifo 0 glifo 3738
627 /// → glifo 1709 glifo 2104 glifo 2104
628 /// ```
629 ///
630 /// **No alcanza con `append_fallbacks`.** Se probó: apilar la familia en el
631 /// fallback de `Zzzz` (el script que swash le asigna a la zona privada —
632 /// verificado, índice 156 de la tabla de parley) queda registrado
633 /// —`fallback_families("Zzzz")` la lista— pero el glifo sigue saliendo 0.
634 /// Extender la stack, en cambio, funciona y no depende de la semántica de
635 /// fallback por-script.
636 ///
637 /// Preferimos una variante **Mono**: el grid cuenta con avance fijo y las
638 /// «Propo» son proporcionales. Best-effort — sin ninguna Nerd Font
639 /// instalada devuelve [`MONOSPACE`] a secas y los glifos siguen saliendo
640 /// tofu, que es exactamente lo de antes.
641 fn stack_monospace(font_cx: &mut parley::FontContext) -> Box<str> {
642 let mut candidatas: Vec<&str> = font_cx
643 .collection
644 .family_names()
645 .filter(|n| n.contains("Nerd Font"))
646 .collect();
647 // Orden determinista: primero las Mono y, a igualdad, por nombre — si
648 // no, la elección dependería del orden en que el SO enumere.
649 candidatas.sort_by_key(|n| {
650 let rango = if n.ends_with("Nerd Font Mono") {
651 0
652 } else if n.ends_with("Nerd Font") {
653 1
654 } else {
655 2
656 };
657 (rango, *n)
658 });
659 match candidatas.first() {
660 // Sintaxis CSS-like, que es lo que `FontStack::Source` parsea.
661 Some(nerd) => format!("{MONOSPACE}, {nerd}").into_boxed_str(),
662 None => MONOSPACE.into(),
663 }
664 }
665
666 /// Acceso al `FontContext` por si se necesita registrar fuentes extra
667 /// o cambiar la stack de fallback. **Invalida el caché de shaping**: tocar
668 /// el set de fuentes o el fallback puede cambiar el resultado de cualquier
669 /// layout, así que descartamos lo cacheado (operación rara, de setup).
670 pub fn font_context_mut(&mut self) -> &mut parley::FontContext {
671 self.cache.clear();
672 &mut self.font_cx
673 }
674
675 /// Estadísticas del caché de shaping (hits/misses acumulados + entradas
676 /// vivas). Para benchmark/evidencia; no afecta el render.
677 pub fn cache_stats(&self) -> CacheStats {
678 CacheStats {
679 hits: self.cache_hits,
680 misses: self.cache_misses,
681 entries: self.cache.entries(),
682 }
683 }
684
685 /// Construye y resuelve un `parley::Layout`. Aplica `font_size`,
686 /// `line_height` (multiplicador del font_size), `max_width` (line
687 /// break), `alignment` y `weight` (peso de fuente CSS: 400 normal,
688 /// 700 bold). `italic`=true selecciona la variante italic/oblique de
689 /// la fuente activa (vía `parley::FontStyle`). `underline`/`strikethrough`
690 /// activan la decoración global del bloque — parley deja la metadata
691 /// (offset + grosor) en cada `Run` y el pintado (`draw_layout_*`) emite
692 /// el rect correspondiente sobre la línea base.
693 /// API pública 12-arg (sin `overflow-wrap`): la usan showreels, canvas,
694 /// hit-testing de selección, etc. Delega en [`Self::layout_inner`] con
695 /// `overflow_wrap = false` (la palabra larga desborda, comportamiento
696 /// histórico). El quiebre dentro de palabra entra sólo por `layout_clamped`
697 /// (camino del compositor), para no propagar el flag a todos los callers.
698 #[allow(clippy::too_many_arguments)]
699 pub fn layout(
700 &mut self,
701 text: &str,
702 size_px: f32,
703 max_width: Option<f32>,
704 alignment: Alignment,
705 line_height: f32,
706 italic: bool,
707 font_family: Option<&str>,
708 weight: f32,
709 underline: bool,
710 strikethrough: bool,
711 letter_spacing: f32,
712 word_spacing: f32,
713 ) -> parley::Layout<()> {
714 self.layout_inner(
715 text, size_px, max_width, alignment, line_height, italic, font_family, weight,
716 underline, strikethrough, letter_spacing, word_spacing, false, "", "", 100.0,
717 )
718 }
719
720 /// Impl real del shaping con el flag `overflow_wrap` (CSS
721 /// `overflow-wrap: break-word`/`anywhere`). Privado: sólo lo invocan
722 /// [`Self::layout`] (con `false`) y [`Self::layout_clamped`] (con el valor
723 /// del estilo). Así la firma pública 12-arg no cambia y los ~20 callers de
724 /// showreels/canvas siguen compilando sin tocar.
725 #[allow(clippy::too_many_arguments)]
726 fn layout_inner(
727 &mut self,
728 text: &str,
729 size_px: f32,
730 max_width: Option<f32>,
731 alignment: Alignment,
732 line_height: f32,
733 italic: bool,
734 font_family: Option<&str>,
735 weight: f32,
736 underline: bool,
737 strikethrough: bool,
738 letter_spacing: f32,
739 word_spacing: f32,
740 overflow_wrap: bool,
741 font_features: &str,
742 font_variations: &str,
743 font_width: f32,
744 ) -> parley::Layout<()> {
745 // Caché de shaping: clave por todos los parámetros que determinan el
746 // layout. En el hit clonamos el `parley::Layout` (memcpy de vectores,
747 // ~órdenes de magnitud más barato que re-shapear). El `String`/clave
748 // que se aloca para consultar es un costo menor frente al shaping que
749 // evita; mantener la firma `&str` no fuerza alloc en el caller.
750 let key = ShapeKey {
751 text: text.to_string(),
752 size_bits: size_px.to_bits(),
753 max_width_bits: max_width.map(f32::to_bits),
754 align: align_tag(alignment),
755 line_height_bits: line_height.to_bits(),
756 italic,
757 font_family: font_family.map(str::to_string),
758 weight_bits: weight.to_bits(),
759 underline,
760 strikethrough,
761 letter_bits: letter_spacing.to_bits(),
762 word_bits: word_spacing.to_bits(),
763 overflow_wrap,
764 features: font_features.to_string(),
765 variations: font_variations.to_string(),
766 font_width_bits: font_width.to_bits(),
767 };
768 if let Some(hit) = self.cache.get(&key) {
769 self.cache_hits += 1;
770 return hit;
771 }
772 self.cache_misses += 1;
773 let mut builder =
774 self.layout_cx
775 .ranged_builder(&mut self.font_cx, text, 1.0, true);
776 builder.push_default(parley::StyleProperty::FontSize(size_px));
777 builder.push_default(parley::StyleProperty::LineHeight(
778 parley::LineHeight::FontSizeRelative(line_height),
779 ));
780 // SIEMPRE fijamos el peso (antes se saltaba `== 400.0` como "optimización"):
781 // con una fuente VARIABLE, NO setear `FontWeight` deja el peso en el DEFAULT
782 // del archivo (que puede ser bold), no en el 400 pedido. Ese era el "texto
783 // bold feo" al escribir en un campo. Pedir 400 debe dar 400.
784 builder.push_default(parley::StyleProperty::FontWeight(
785 parley::FontWeight::new(weight),
786 ));
787 if italic {
788 builder.push_default(parley::StyleProperty::FontStyle(
789 parley::FontStyle::Italic,
790 ));
791 }
792 if let Some(ff) = font_family {
793 // parley::FontStack::Source acepta CSS-like syntax
794 // (`"Helvetica", sans-serif`). Pedir la mono trae además la Nerd
795 // Font del sistema detrás: ver `stack_monospace`.
796 let pedida: &str = if ff == MONOSPACE { &self.mono_stack } else { ff };
797 builder.push_default(parley::StyleProperty::FontStack(
798 parley::FontStack::Source(Cow::Borrowed(pedida)),
799 ));
800 }
801 if underline {
802 builder.push_default(parley::StyleProperty::Underline(true));
803 }
804 if strikethrough {
805 builder.push_default(parley::StyleProperty::Strikethrough(true));
806 }
807 // `letter-spacing`/`word-spacing` (px extra). 0 = sin override (normal).
808 if letter_spacing != 0.0 {
809 builder.push_default(parley::StyleProperty::LetterSpacing(letter_spacing));
810 }
811 if word_spacing != 0.0 {
812 builder.push_default(parley::StyleProperty::WordSpacing(word_spacing));
813 }
814 // `overflow-wrap: break-word`/`anywhere`: habilita la partición dentro
815 // de una palabra cuando no hay otra oportunidad de quiebre en la línea
816 // (un token más ancho que la caja). `Anywhere` cubre ambos valores CSS
817 // — su única diferencia con `BreakWord` es el min-content sizing, sin
818 // efecto visible en el wrap del bloque. Sin el flag (normal) parley deja
819 // desbordar la palabra larga (comportamiento previo).
820 if overflow_wrap {
821 builder.push_default(parley::StyleProperty::OverflowWrap(
822 parley::OverflowWrap::Anywhere,
823 ));
824 }
825 // `font-feature-settings`/`font-variation-settings` (+ `font-kerning`
826 // plegado en la fuente de features): parley/swash parsean el string CSS
827 // crudo (`FontSettings::Source`, gramática `"tag" value, …`). Vacío =
828 // default de la fuente (no-op, no se empuja). El `&str` vive hasta el
829 // `build`, así que el `Cow::Borrowed` es válido (mismo patrón que
830 // `FontStack::Source`).
831 if !font_features.is_empty() {
832 builder.push_default(parley::StyleProperty::FontFeatures(
833 parley::FontSettings::Source(Cow::Borrowed(font_features)),
834 ));
835 }
836 if !font_variations.is_empty() {
837 builder.push_default(parley::StyleProperty::FontVariations(
838 parley::FontSettings::Source(Cow::Borrowed(font_variations)),
839 ));
840 }
841 // `font-stretch`/`font-width` (porcentaje, 100 = normal): selecciona la
842 // face condensada/expandida o el eje `wdth` de una variable. Sólo se
843 // empuja si difiere de normal (no-op en el caso común).
844 if (font_width - 100.0).abs() > 0.01 {
845 builder.push_default(parley::StyleProperty::FontWidth(
846 parley::FontWidth::from_percentage(font_width),
847 ));
848 }
849 // Último, para que pise cualquier familia que el caller haya pedido
850 // por rango: un emoji sale a color aunque caiga adentro de un tramo
851 // con fuente propia. Ver `rangos_emoji`.
852 empujar_familia_emoji(&mut builder, text);
853 let mut layout = builder.build(text);
854 layout.break_all_lines(max_width);
855 layout.align(
856 max_width,
857 alignment.into(),
858 parley::AlignmentOptions::default(),
859 );
860 self.cache.put(key, layout.clone());
861 layout
862 }
863
864 /// Como [`Self::layout`] pero **clampado** a `max_lines` líneas (CSS
865 /// `-webkit-line-clamp` / Flutter `maxLines`). Si el texto envuelto cabe en
866 /// `max_lines` o menos, devuelve el layout completo. Si excede:
867 /// - `ellipsis = true` → la última línea visible termina en `…` (se
868 /// recortan graphemes del final hasta que el bloque vuelve a caber en
869 /// `max_lines`).
870 /// - `ellipsis = false` → se corta sin glifo (queda el prefijo que cupo).
871 ///
872 /// `max_lines = None` o `Some(0)` ⇒ sin límite (idéntico a `layout`). El
873 /// clamp sólo recorta cuando hay envoltura, así que requiere un `max_width`
874 /// definido para tener efecto (un label en una caja dimensionada — el caso
875 /// típico). Reusa `layout` internamente: 0 costo extra cuando no trunca.
876 #[allow(clippy::too_many_arguments)]
877 pub fn layout_clamped(
878 &mut self,
879 text: &str,
880 size_px: f32,
881 max_width: Option<f32>,
882 alignment: Alignment,
883 line_height: f32,
884 italic: bool,
885 font_family: Option<&str>,
886 weight: f32,
887 max_lines: Option<usize>,
888 ellipsis: bool,
889 underline: bool,
890 strikethrough: bool,
891 letter_spacing: f32,
892 word_spacing: f32,
893 overflow_wrap: bool,
894 font_features: &str,
895 font_variations: &str,
896 font_width: f32,
897 ) -> parley::Layout<()> {
898 let full = self.layout_inner(
899 text, size_px, max_width, alignment, line_height, italic, font_family, weight,
900 underline, strikethrough, letter_spacing, word_spacing, overflow_wrap, font_features,
901 font_variations, font_width,
902 );
903 let limit = match max_lines {
904 Some(n) if n >= 1 => n,
905 _ => return full,
906 };
907 if full.lines().count() <= limit {
908 return full;
909 }
910 // Byte de fin de la última línea visible (rango sobre `text` original).
911 let mut cutoff = full
912 .lines()
913 .nth(limit - 1)
914 .map(|l| l.text_range().end)
915 .unwrap_or(text.len())
916 .min(text.len());
917 while cutoff > 0 && !text.is_char_boundary(cutoff) {
918 cutoff -= 1;
919 }
920 let base = text[..cutoff].trim_end();
921 if !ellipsis {
922 return self.layout_inner(
923 base, size_px, max_width, alignment, line_height, italic, font_family, weight,
924 underline, strikethrough, letter_spacing, word_spacing, overflow_wrap,
925 font_features, font_variations, font_width,
926 );
927 }
928 // Recorta graphemes del final hasta que `base…` vuelva a caber en
929 // `limit` líneas (apilar el `…` puede empujar una palabra a una línea
930 // extra). Acotado: cada vuelta quita ≥1 char.
931 let mut s = base.to_string();
932 loop {
933 let candidate = format!("{s}…");
934 let lay = self.layout_inner(
935 &candidate, size_px, max_width, alignment, line_height, italic, font_family,
936 weight, underline, strikethrough, letter_spacing, word_spacing, overflow_wrap,
937 font_features, font_variations, font_width,
938 );
939 if s.is_empty() || lay.lines().count() <= limit {
940 return lay;
941 }
942 s.pop();
943 while s.ends_with(char::is_whitespace) {
944 s.pop();
945 }
946 }
947 }
948
949 /// Construye un layout **multicolor** en una sola pasada de shaping:
950 /// `default_color` cubre todo el texto y cada `(start_byte, end_byte,
951 /// color)` lo sobreescribe en su rango (offsets en **bytes**, no chars —
952 /// la convención de parley). Pensado para syntax highlighting: shapear
953 /// la línea entera una vez con un color por token, en vez de un layout
954 /// por token. Sin wrap (`max_width = None`); el caller posiciona la línea.
955 ///
956 /// `font_family` es la misma familia CSS-like que toma [`Self::layout`].
957 /// **No es opcional de verdad:** sin ella este camino shapeaba siempre con
958 /// la sans default, así que cualquier vista que pedía `.mono()` **y** color
959 /// por tramos perdía el ancho fijo en silencio — nada compilaba mal, sólo
960 /// se torcían las tablas de la consola.
961 #[allow(clippy::too_many_arguments)]
962 pub fn layout_runs(
963 &mut self,
964 text: &str,
965 size_px: f32,
966 default_color: Color,
967 runs: &[(usize, usize, Color)],
968 alignment: Alignment,
969 line_height: f32,
970 weight: f32,
971 underline: bool,
972 strikethrough: bool,
973 font_family: Option<&str>,
974 ) -> parley::Layout<RunBrush> {
975 let mut builder = self
976 .runs_cx
977 .ranged_builder(&mut self.font_cx, text, 1.0, true);
978 if let Some(ff) = font_family {
979 // Pedir la mono trae la Nerd Font del sistema detrás: ver
980 // `stack_monospace`.
981 let pedida: &str = if ff == MONOSPACE { &self.mono_stack } else { ff };
982 builder.push_default(parley::StyleProperty::FontStack(
983 parley::FontStack::Source(Cow::Borrowed(pedida)),
984 ));
985 }
986 builder.push_default(parley::StyleProperty::FontSize(size_px));
987 builder.push_default(parley::StyleProperty::LineHeight(
988 parley::LineHeight::FontSizeRelative(line_height),
989 ));
990 // SIEMPRE fijamos el peso (antes se saltaba `== 400.0` como "optimización"):
991 // con una fuente VARIABLE, NO setear `FontWeight` deja el peso en el DEFAULT
992 // del archivo (que puede ser bold), no en el 400 pedido. Ese era el "texto
993 // bold feo" al escribir en un campo. Pedir 400 debe dar 400.
994 builder.push_default(parley::StyleProperty::FontWeight(
995 parley::FontWeight::new(weight),
996 ));
997 builder.push_default(parley::StyleProperty::Brush(RunBrush(default_color)));
998 if underline {
999 builder.push_default(parley::StyleProperty::Underline(true));
1000 }
1001 if strikethrough {
1002 builder.push_default(parley::StyleProperty::Strikethrough(true));
1003 }
1004 let len = text.len();
1005 for &(start, end, color) in runs {
1006 if start < end && end <= len {
1007 builder.push(parley::StyleProperty::Brush(RunBrush(color)), start..end);
1008 }
1009 }
1010 // Último, para que pise cualquier familia que el caller haya pedido
1011 // por rango: un emoji sale a color aunque caiga adentro de un tramo
1012 // con fuente propia. Ver `rangos_emoji`.
1013 empujar_familia_emoji(&mut builder, text);
1014 let mut layout = builder.build(text);
1015 layout.break_all_lines(None);
1016 layout.align(None, alignment.into(), parley::AlignmentOptions::default());
1017 layout
1018 }
1019
1020 /// Construye un layout **RichText**: defaults a nivel bloque + un
1021 /// arreglo de [`TextSpan`] que sobreescriben tamaño/peso/italic/familia/
1022 /// color/decoración **por rango de bytes**. A diferencia de
1023 /// [`Self::layout_runs`] (sólo color, sin wrap), este camino:
1024 ///
1025 /// - permite `max_width` (envuelve a párrafo);
1026 /// - aplica los siete `StyleProperty` por rango;
1027 /// - usa el mismo `runs_cx` (`RunBrush`), así puede convivir con el
1028 /// pintado multicolor.
1029 ///
1030 /// **Sin caché** en v1 (a diferencia de `layout`/`layout_clamped`): el
1031 /// RichText típico cambia frame-a-frame (cursor de editor, hover de
1032 /// link), y la clave de caché de un span-set arbitrario es pesada.
1033 /// Reusa todo el shaping interno de parley, que ya es rápido para
1034 /// párrafos de la magnitud de una UI.
1035 #[allow(clippy::too_many_arguments)]
1036 pub fn layout_spans(
1037 &mut self,
1038 text: &str,
1039 size_px: f32,
1040 default_color: Color,
1041 weight: f32,
1042 line_height: f32,
1043 italic: bool,
1044 font_family: Option<&str>,
1045 underline: bool,
1046 strikethrough: bool,
1047 spans: &[TextSpan],
1048 max_width: Option<f32>,
1049 alignment: Alignment,
1050 ) -> parley::Layout<RunBrush> {
1051 let mut builder = self
1052 .runs_cx
1053 .ranged_builder(&mut self.font_cx, text, 1.0, true);
1054 builder.push_default(parley::StyleProperty::FontSize(size_px));
1055 builder.push_default(parley::StyleProperty::LineHeight(
1056 parley::LineHeight::FontSizeRelative(line_height),
1057 ));
1058 // SIEMPRE fijamos el peso (antes se saltaba `== 400.0` como "optimización"):
1059 // con una fuente VARIABLE, NO setear `FontWeight` deja el peso en el DEFAULT
1060 // del archivo (que puede ser bold), no en el 400 pedido. Ese era el "texto
1061 // bold feo" al escribir en un campo. Pedir 400 debe dar 400.
1062 builder.push_default(parley::StyleProperty::FontWeight(
1063 parley::FontWeight::new(weight),
1064 ));
1065 if italic {
1066 builder.push_default(parley::StyleProperty::FontStyle(
1067 parley::FontStyle::Italic,
1068 ));
1069 }
1070 if let Some(ff) = font_family {
1071 // Pedir la mono trae la Nerd Font del sistema detrás: ver
1072 // `stack_monospace`.
1073 let pedida: &str = if ff == MONOSPACE { &self.mono_stack } else { ff };
1074 builder.push_default(parley::StyleProperty::FontStack(
1075 parley::FontStack::Source(Cow::Borrowed(pedida)),
1076 ));
1077 }
1078 builder.push_default(parley::StyleProperty::Brush(RunBrush(default_color)));
1079 if underline {
1080 builder.push_default(parley::StyleProperty::Underline(true));
1081 }
1082 if strikethrough {
1083 builder.push_default(parley::StyleProperty::Strikethrough(true));
1084 }
1085 let len = text.len();
1086 for span in spans {
1087 if span.start >= span.end || span.end > len {
1088 continue;
1089 }
1090 let range = span.start..span.end;
1091 let s = &span.style;
1092 if let Some(v) = s.size_px {
1093 builder.push(parley::StyleProperty::FontSize(v), range.clone());
1094 }
1095 if let Some(v) = s.weight {
1096 builder.push(
1097 parley::StyleProperty::FontWeight(parley::FontWeight::new(v)),
1098 range.clone(),
1099 );
1100 }
1101 if let Some(v) = s.italic {
1102 let style = if v {
1103 parley::FontStyle::Italic
1104 } else {
1105 parley::FontStyle::Normal
1106 };
1107 builder.push(parley::StyleProperty::FontStyle(style), range.clone());
1108 }
1109 if let Some(ff) = s.font_family.as_deref() {
1110 builder.push(
1111 parley::StyleProperty::FontStack(parley::FontStack::Source(
1112 Cow::Owned(ff.to_string()),
1113 )),
1114 range.clone(),
1115 );
1116 }
1117 if let Some(c) = s.color {
1118 builder.push(parley::StyleProperty::Brush(RunBrush(c)), range.clone());
1119 }
1120 if let Some(v) = s.underline {
1121 builder.push(parley::StyleProperty::Underline(v), range.clone());
1122 }
1123 if let Some(v) = s.strikethrough {
1124 builder.push(parley::StyleProperty::Strikethrough(v), range.clone());
1125 }
1126 }
1127 // Último, para que pise cualquier familia que el caller haya pedido
1128 // por rango: un emoji sale a color aunque caiga adentro de un tramo
1129 // con fuente propia. Ver `rangos_emoji`.
1130 empujar_familia_emoji(&mut builder, text);
1131 let mut layout = builder.build(text);
1132 layout.break_all_lines(max_width);
1133 layout.align(
1134 max_width,
1135 alignment.into(),
1136 parley::AlignmentOptions::default(),
1137 );
1138 layout
1139 }
1140}
1141
1142/// Brush por-run para texto multicolor. Newtype sobre [`Color`] porque
1143/// parley exige que el brush genérico implemente `Default` (que `Color` no
1144/// garantiza); aquí proveemos uno explícito (negro opaco) que nunca se ve
1145/// en la práctica: todo run lleva su color o el `default_color` del bloque.
1146#[derive(Clone, Copy, PartialEq, Debug)]
1147pub struct RunBrush(pub Color);
1148
1149impl Default for RunBrush {
1150 fn default() -> Self {
1151 RunBrush(Color::from_rgba8(0, 0, 0, 255))
1152 }
1153}
1154
1155/// Overrides de estilo aplicables a un **rango de bytes** dentro de un
1156/// bloque de texto, para `Typesetter::layout_spans` (RichText). Cada
1157/// campo es opcional: `None` hereda del default del bloque. La granularidad
1158/// es por bytes (convención de parley), igual que el `runs` multicolor.
1159#[derive(Default, Clone, Debug, PartialEq)]
1160pub struct TextSpanStyle {
1161 /// Tamaño de fuente (CSS `font-size`). El reshape recalcula el alto
1162 /// de la línea afectada.
1163 pub size_px: Option<f32>,
1164 /// Peso de fuente (400 = normal, 700 = bold).
1165 pub weight: Option<f32>,
1166 /// Italic on/off.
1167 pub italic: Option<bool>,
1168 /// Family CSS-like ("Helvetica, sans-serif"). Útil para `code` inline
1169 /// (forzar monospace en una palabra).
1170 pub font_family: Option<String>,
1171 /// Color del texto (gana sobre el `default_color` del bloque).
1172 pub color: Option<Color>,
1173 /// Subrayado on/off.
1174 pub underline: Option<bool>,
1175 /// Tachado on/off.
1176 pub strikethrough: Option<bool>,
1177}
1178
1179/// Un span de RichText: rango de bytes `[start, end)` + overrides de
1180/// estilo (`style`). Los rangos pueden superponerse — parley aplica los
1181/// `StyleProperty` en orden de inserción, así el caller debería pushar de
1182/// menor a mayor especificidad.
1183#[derive(Clone, Debug, PartialEq)]
1184pub struct TextSpan {
1185 pub start: usize,
1186 pub end: usize,
1187 pub style: TextSpanStyle,
1188}
1189
1190impl TextSpan {
1191 pub fn new(start: usize, end: usize, style: TextSpanStyle) -> Self {
1192 Self { start, end, style }
1193 }
1194}
1195
1196/// Alineación horizontal del bloque dentro de su ancho máximo.
1197#[derive(Debug, Clone, Copy)]
1198pub enum Alignment {
1199 Start,
1200 Center,
1201 End,
1202 Justify,
1203}
1204
1205#[cfg(feature = "shaping")]
1206impl From<Alignment> for parley::Alignment {
1207 fn from(a: Alignment) -> Self {
1208 match a {
1209 Alignment::Start => parley::Alignment::Start,
1210 Alignment::Center => parley::Alignment::Center,
1211 Alignment::End => parley::Alignment::End,
1212 Alignment::Justify => parley::Alignment::Justify,
1213 }
1214 }
1215}
1216
1217/// Especificación de un bloque de texto a rasterizar.
1218pub struct TextBlock<'a> {
1219 pub text: &'a str,
1220 pub size_px: f32,
1221 pub color: Color,
1222 /// Esquina superior-izquierda del bloque (no el baseline — parley se
1223 /// encarga del baseline internamente).
1224 pub origin: (f64, f64),
1225 pub max_width: Option<f32>,
1226 pub alignment: Alignment,
1227 /// Múltiplo del font_size (1.0 = compacto, 1.3 = cómodo).
1228 pub line_height: f32,
1229 /// `true` → fuerza variante italic/oblique en la fuente activa.
1230 pub italic: bool,
1231 /// CSS-style `font-family` string. `None` = sans-serif default.
1232 pub font_family: Option<String>,
1233}
1234
1235impl<'a> TextBlock<'a> {
1236 /// Constructor simple para una línea sin wrap.
1237 pub fn simple(text: &'a str, size_px: f32, color: Color, origin: (f64, f64)) -> Self {
1238 Self {
1239 text,
1240 size_px,
1241 color,
1242 origin,
1243 max_width: None,
1244 alignment: Alignment::Start,
1245 line_height: 1.0,
1246 italic: false,
1247 font_family: None,
1248 }
1249 }
1250}
1251
1252/// Medidas resultantes de un layout.
1253#[derive(Debug, Clone, Copy)]
1254pub struct Measurement {
1255 pub width: f32,
1256 pub height: f32,
1257}
1258
1259/// Construye el layout (shaping + line break + alineación) listo para medir
1260/// y/o pintar. Usa esta API cuando necesitas el alto **antes** de elegir el
1261/// origen (p. ej. centrado vertical) y no quieres repetir el shaping en el
1262/// `draw`: medís sobre el layout retornado y luego lo pasas a
1263/// [`draw_layout`].
1264#[cfg(feature = "shaping")]
1265pub fn layout_block(ts: &mut Typesetter, block: &TextBlock<'_>) -> parley::Layout<()> {
1266 ts.layout(
1267 block.text,
1268 block.size_px,
1269 block.max_width,
1270 block.alignment,
1271 block.line_height,
1272 block.italic,
1273 block.font_family.as_deref(),
1274 // `TextBlock` no transporta peso (su API queda en normal); el peso de
1275 // fuente fluye por el camino del compositor, que llama a `layout`
1276 // directamente con el `weight` del `TextSpec`/`TextMeasure`.
1277 400.0,
1278 // Decoración tampoco viaja por `TextBlock`: la activa el compositor
1279 // por nodo según `TextSpec::{underline,strikethrough}`.
1280 false,
1281 false,
1282 // `letter-spacing`/`word-spacing` tampoco viajan por `TextBlock`; el
1283 // compositor los pasa por su camino directo (`layout_clamped`).
1284 0.0,
1285 0.0,
1286 )
1287}
1288
1289/// Devuelve las medidas de un layout ya resuelto. Equivalente conceptual a
1290/// `(layout.width(), layout.height())` pero envuelto en [`Measurement`].
1291#[cfg(feature = "shaping")]
1292pub fn measurement(layout: &parley::Layout<()>) -> Measurement {
1293 Measurement {
1294 width: layout.width(),
1295 height: layout.height(),
1296 }
1297}
1298
1299/// Ancho en px de `text` en una sola línea, midiendo con un `Typesetter` que
1300/// presta quien llama.
1301///
1302/// Es el gemelo explícito de [`text_width`], y existe porque aquél se apoya en
1303/// un `thread_local!` — que necesita `std` y, más de fondo, hilos. En wawa no
1304/// hay ninguna de las dos cosas: el reactor es cooperativo de un solo núcleo.
1305/// Acá el `Typesetter` viaja por argumento, que es la forma que funciona en los
1306/// dos mundos.
1307#[cfg(feature = "shaping")]
1308pub fn text_width_with(ts: &mut Typesetter, text: &str, size_px: f32) -> f32 {
1309 if text.is_empty() {
1310 return 0.0;
1311 }
1312 let lp = ts.layout(
1313 text,
1314 size_px,
1315 None,
1316 Alignment::Start,
1317 1.2,
1318 false,
1319 None,
1320 400.0,
1321 false,
1322 false,
1323 0.0,
1324 0.0,
1325 );
1326 measurement(&lp).width
1327}
1328
1329// Los DOS gates, como su único consumidor (`text_width`): el `thread_local`
1330// necesita `std`, pero el `Typesetter` que guarda adentro vive tras `shaping`
1331// (es parley). Con `std` y sin `shaping` esto referenciaba un tipo inexistente
1332// — perfil real: quien sólo exporta SVG (`tawasuyu-apps-desktop`), que no pinta
1333// ni mide.
1334#[cfg(all(feature = "std", feature = "shaping"))]
1335thread_local! {
1336 /// Typesetter dedicado a **medir** texto en el hilo de UI. Instala la MISMA
1337 /// fuente UI que usa el compositor, así el ancho medido coincide al píxel con
1338 /// el glifo dibujado. El costo de cargar la fuente se paga una vez por hilo.
1339 /// Origen: el `MEASURE_TS` privado del `text-input`, promovido aquí para que
1340 /// cualquier widget mida glifos reales en `update`/`view` sin `&mut Typesetter`.
1341 static MEASURE_TS: core::cell::RefCell<Typesetter> =
1342 core::cell::RefCell::new(Typesetter::new());
1343}
1344
1345/// Ancho en px de `text` renderizado en **una sola línea** a `size_px`, medido
1346/// con la fuente UI real (shaping proporcional, kerning, ligaduras). Es la
1347/// alternativa correcta al anti-patrón `chars().count() * constante`, que finge
1348/// una fuente monoespaciada sobre una proporcional y desalinea todo cálculo de
1349/// ancho (chips, títulos de menú, botones, tooltips…). Reusa un `Typesetter`
1350/// thread-local, así se puede llamar desde `update`/`view` sin prestar uno.
1351///
1352/// `text` vacío → `0.0`. Para medir un **prefijo** por columnas usar
1353/// `text.chars().take(n).collect::<String>()` como argumento.
1354#[cfg(feature = "std")]
1355#[cfg(feature = "shaping")]
1356pub fn text_width(text: &str, size_px: f32) -> f32 {
1357 if text.is_empty() {
1358 return 0.0;
1359 }
1360 MEASURE_TS.with(|ts| {
1361 let mut ts = ts.borrow_mut();
1362 let lp = ts.layout(
1363 text,
1364 size_px,
1365 None,
1366 Alignment::Start,
1367 1.2,
1368 false,
1369 None,
1370 400.0,
1371 false,
1372 false,
1373 0.0,
1374 0.0,
1375 );
1376 measurement(&lp).width
1377 })
1378}
1379
1380/// Pinta un layout ya resuelto en `scene` con `color` y un offset `origin`
1381/// (esquina superior-izquierda del bloque). No alloca: los glifos van
1382/// directo del iterador de parley al builder de vello.
1383#[cfg(feature = "shaping")]
1384pub fn draw_layout(
1385 scene: &mut Lienzo,
1386 layout: &parley::Layout<()>,
1387 color: Color,
1388 origin: (f64, f64),
1389) {
1390 draw_layout_xf(scene, layout, color, peniko::kurbo::Affine::translate(origin));
1391}
1392
1393/// Igual que [`draw_layout`] pero con una **afín completa** en vez de sólo un
1394/// desplazamiento: permite pintar texto girado/escalado (p. ej. dentro de un
1395/// marco rotado en una presentación espacial). El origen del layout (0,0) es el
1396/// que mapea `transform`; las posiciones de glifo se aplican en ese espacio.
1397#[cfg(feature = "shaping")]
1398pub fn draw_layout_xf(
1399 scene: &mut Lienzo,
1400 layout: &parley::Layout<()>,
1401 color: Color,
1402 transform: peniko::kurbo::Affine,
1403) {
1404 draw_layout_brush_xf(scene, layout, &Brush::Solid(color), transform);
1405}
1406
1407/// Igual que [`draw_layout_xf`] pero con un [`Brush`] arbitrario en vez de un
1408/// color sólido: permite rellenar los glifos con un gradiente o una imagen
1409/// (p. ej. CSS `background-clip: text`). El brush se interpreta en el espacio
1410/// **local** del layout (origen 0,0) y `transform` lo lleva al lugar final —
1411/// así un gradiente construido en coords (0,0)-(w,h) queda alineado con los
1412/// glifos. Para texto normal usa [`draw_layout_xf`] (solid = máxima compat).
1413#[cfg(feature = "vello")]
1414pub fn draw_layout_brush_xf(
1415 scene: &mut Lienzo,
1416 layout: &parley::Layout<()>,
1417 brush: &Brush,
1418 transform: peniko::kurbo::Affine,
1419) {
1420 for line in layout.lines() {
1421 for item in line.items() {
1422 if let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item {
1423 let run = glyph_run.run();
1424 let font = run.font().clone();
1425 let font_size = run.font_size();
1426 scene
1427 .draw_glyphs(&font)
1428 .font_size(font_size)
1429 .brush(brush)
1430 .transform(transform)
1431 .draw(
1432 peniko::Fill::NonZero,
1433 glyph_run.positioned_glyphs().map(|g| vello::Glyph {
1434 id: g.id as u32,
1435 x: g.x,
1436 y: g.y,
1437 }),
1438 );
1439 paint_decoration(scene, &glyph_run, brush, transform);
1440 }
1441 }
1442 }
1443}
1444
1445/// Pinta las decoraciones (`underline`/`strikethrough`) del run si las trae
1446/// del shaping. El offset que devuelve parley sigue la convención OpenType
1447/// (positivo = sobre la línea base en font-space, eje Y arriba); en
1448/// coordenadas de pantalla (Y abajo) el rect va a `baseline - offset`. El
1449/// `transform` es el mismo que se usa para los glifos, así la decoración
1450/// hereda el scroll/rotación/zoom del subárbol.
1451#[cfg(feature = "vello")]
1452fn paint_decoration<B: parley::Brush>(
1453 scene: &mut Lienzo,
1454 glyph_run: &parley::GlyphRun<'_, B>,
1455 brush: &Brush,
1456 transform: peniko::kurbo::Affine,
1457) {
1458 let style = glyph_run.style();
1459 let run = glyph_run.run();
1460 let metrics = run.metrics();
1461 let x = glyph_run.offset() as f64;
1462 let baseline = glyph_run.baseline() as f64;
1463 let advance = glyph_run.advance() as f64;
1464 if let Some(dec) = &style.underline {
1465 let offset = dec.offset.unwrap_or(metrics.underline_offset) as f64;
1466 let size = dec.size.unwrap_or(metrics.underline_size) as f64;
1467 let y0 = baseline - offset;
1468 let rect = peniko::kurbo::Rect::new(x, y0, x + advance, y0 + size);
1469 scene.fill(peniko::Fill::NonZero, transform, brush, None, &rect);
1470 }
1471 if let Some(dec) = &style.strikethrough {
1472 let offset = dec.offset.unwrap_or(metrics.strikethrough_offset) as f64;
1473 let size = dec.size.unwrap_or(metrics.strikethrough_size) as f64;
1474 let y0 = baseline - offset;
1475 let rect = peniko::kurbo::Rect::new(x, y0, x + advance, y0 + size);
1476 scene.fill(peniko::Fill::NonZero, transform, brush, None, &rect);
1477 }
1478}
1479
1480/// Pinta un layout **multicolor** ([`Typesetter::layout_runs`]): cada
1481/// `glyph_run` usa el color de su propio brush ([`RunBrush`]) en vez de un
1482/// color uniforme. `origin` es la esquina superior-izquierda del bloque.
1483#[cfg(feature = "shaping")]
1484pub fn draw_layout_runs(
1485 scene: &mut Lienzo,
1486 layout: &parley::Layout<RunBrush>,
1487 origin: (f64, f64),
1488) {
1489 draw_layout_runs_xf(scene, layout, peniko::kurbo::Affine::translate(origin));
1490}
1491
1492/// Igual que [`draw_layout_runs`] pero con una **afín completa** en vez de sólo
1493/// un desplazamiento — el equivalente multicolor de [`draw_layout_xf`]. Lo
1494/// necesita el compositor para que el texto multicolor herede la
1495/// transformación acumulada del subárbol (scroll/rotación del padre): sin esto,
1496/// el texto con `runs` se pintaba en coords de layout crudas, **ignorando** el
1497/// transform, y se desalineaba del resto (p. ej. el cuerpo coloreado del shell
1498/// no seguía el scroll del panel). El origen del layout (0,0) lo mapea
1499/// `transform`; las posiciones de glifo se aplican en ese espacio.
1500#[cfg(feature = "vello")]
1501pub fn draw_layout_runs_xf(
1502 scene: &mut Lienzo,
1503 layout: &parley::Layout<RunBrush>,
1504 transform: peniko::kurbo::Affine,
1505) {
1506 for line in layout.lines() {
1507 for item in line.items() {
1508 if let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item {
1509 let brush = Brush::Solid(glyph_run.style().brush.0);
1510 let run = glyph_run.run();
1511 let font = run.font().clone();
1512 let font_size = run.font_size();
1513 scene
1514 .draw_glyphs(&font)
1515 .font_size(font_size)
1516 .brush(&brush)
1517 .transform(transform)
1518 .draw(
1519 peniko::Fill::NonZero,
1520 glyph_run.positioned_glyphs().map(|g| vello::Glyph {
1521 id: g.id as u32,
1522 x: g.x,
1523 y: g.y,
1524 }),
1525 );
1526 paint_decoration(scene, &glyph_run, &brush, transform);
1527 }
1528 }
1529 }
1530}
1531
1532/// Mide sin pintar. Atajo de [`layout_block`] + [`measurement`] para
1533/// llamadores que sólo necesitan el bounding box.
1534#[cfg(feature = "shaping")]
1535pub fn measure(ts: &mut Typesetter, block: &TextBlock<'_>) -> Measurement {
1536 measurement(&layout_block(ts, block))
1537}
1538
1539/// Rasteriza el bloque en `scene` haciendo shaping una sola vez. Equivale a
1540/// `layout_block` + `draw_layout` con `block.origin`.
1541#[cfg(feature = "shaping")]
1542pub fn draw_block(scene: &mut Lienzo, ts: &mut Typesetter, block: &TextBlock<'_>) {
1543 let layout = layout_block(ts, block);
1544 draw_layout(scene, &layout, block.color, block.origin);
1545}
1546
1547#[cfg(feature = "shaping")]
1548#[cfg(test)]
1549mod tests {
1550 use super::*;
1551
1552 /// Texto que envuelve a muchas líneas en un ancho angosto.
1553 const LARGO: &str =
1554 "palabras varias que envuelven en bastantes renglones cuando el ancho \
1555 disponible es realmente angosto y no caben de un solo tirón";
1556
1557 fn n_lineas(ts: &mut Typesetter, max_lines: Option<usize>, ellipsis: bool) -> usize {
1558 ts.layout_clamped(
1559 LARGO,
1560 14.0,
1561 Some(120.0),
1562 Alignment::Start,
1563 1.2,
1564 false,
1565 None,
1566 400.0,
1567 max_lines,
1568 ellipsis,
1569 false,
1570 false,
1571 0.0,
1572 0.0,
1573 false,
1574 "",
1575 "",
1576 100.0,
1577 )
1578 .lines()
1579 .count()
1580 }
1581
1582 #[test]
1583 fn text_width_es_proporcional_no_monoespaciado() {
1584 // El anti-patrón `chars*const` daría igual ancho a "iii" y "WWW"
1585 // (3 chars c/u). La medición real con fuente proporcional debe
1586 // distinguirlos: 'W' es mucho más ancho que 'i'.
1587 let angosto = text_width("iii", 13.0);
1588 let ancho = text_width("WWW", 13.0);
1589 assert!(angosto > 0.0 && ancho > 0.0);
1590 assert!(
1591 ancho > angosto * 1.5,
1592 "WWW ({ancho}) debe ser bastante más ancho que iii ({angosto})"
1593 );
1594 }
1595
1596 #[test]
1597 fn text_width_vacio_es_cero_y_crece_monotono() {
1598 assert_eq!(text_width("", 13.0), 0.0);
1599 let a = text_width("a", 13.0);
1600 let ab = text_width("ab", 13.0);
1601 let abc = text_width("abc", 13.0);
1602 assert!(a > 0.0 && ab > a && abc > ab, "el ancho crece al agregar chars");
1603 }
1604
1605 #[test]
1606 fn clamp_limita_el_numero_de_lineas() {
1607 let mut ts = Typesetter::new();
1608 let libre = n_lineas(&mut ts, None, false);
1609 assert!(libre > 2, "el fixture debe envolver a >2 líneas (dio {libre})");
1610 // Con clamp, nunca más que el límite — con o sin ellipsis.
1611 assert_eq!(n_lineas(&mut ts, Some(1), false), 1);
1612 assert_eq!(n_lineas(&mut ts, Some(1), true), 1);
1613 assert!(n_lineas(&mut ts, Some(2), true) <= 2);
1614 // max_lines None ⇒ sin límite (idéntico a layout).
1615 assert_eq!(n_lineas(&mut ts, None, true), libre);
1616 }
1617
1618 #[test]
1619 fn letter_y_word_spacing_ensanchan_la_medida() {
1620 // letter-spacing y word-spacing agregan px al ancho del shaping; 0 es
1621 // el baseline (normal). Prueba directa del feature (Fase 7.1252).
1622 let mut ts = Typesetter::new();
1623 let w = |ts: &mut Typesetter, ls: f32, ws: f32| {
1624 measurement(&ts.layout(
1625 "hola mundo cruel", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false,
1626 false, ls, ws,
1627 ))
1628 .width
1629 };
1630 let base = w(&mut ts, 0.0, 0.0);
1631 let con_letter = w(&mut ts, 4.0, 0.0);
1632 let con_word = w(&mut ts, 0.0, 10.0);
1633 assert!(con_letter > base, "letter-spacing ensancha ({con_letter} > {base})");
1634 assert!(con_word > base, "word-spacing ensancha ({con_word} > {base})");
1635 }
1636
1637 #[test]
1638 fn clamp_no_trunca_si_ya_cabe() {
1639 let mut ts = Typesetter::new();
1640 // "Hola" cabe en una línea: pedir 3 no debe inventar truncado.
1641 let lay = ts.layout_clamped(
1642 "Hola", 14.0, Some(200.0), Alignment::Start, 1.2, false, None, 400.0, Some(3), true,
1643 false, false, 0.0, 0.0, false, "", "", 100.0,
1644 );
1645 assert_eq!(lay.lines().count(), 1);
1646 }
1647
1648 /// El caché no debe cambiar el resultado: misma medida con o sin hit, y la
1649 /// segunda llamada idéntica tiene que pegar en el caché (hit), no re-shapear.
1650 #[test]
1651 fn cache_es_transparente_y_pega() {
1652 let mut ts = Typesetter::new();
1653 let m1 = {
1654 let l = ts.layout(LARGO, 14.0, Some(120.0), Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
1655 (l.width(), l.height(), l.lines().count())
1656 };
1657 let s1 = ts.cache_stats();
1658 assert_eq!(s1.misses, 1, "primera vez = miss");
1659 assert_eq!(s1.hits, 0);
1660 // Misma llamada exacta: debe ser hit y dar la misma geometría.
1661 let m2 = {
1662 let l = ts.layout(LARGO, 14.0, Some(120.0), Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
1663 (l.width(), l.height(), l.lines().count())
1664 };
1665 let s2 = ts.cache_stats();
1666 assert_eq!(s2.hits, 1, "segunda vez idéntica = hit");
1667 assert_eq!(s2.misses, 1, "no hubo nuevo miss");
1668 assert_eq!(m1, m2, "el layout cacheado es idéntico al fresco");
1669 // Cambiar un parámetro (ancho) es una clave distinta: miss nuevo.
1670 let _ = ts.layout(LARGO, 14.0, Some(80.0), Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
1671 assert_eq!(ts.cache_stats().misses, 2, "otro ancho = otra clave");
1672 }
1673
1674 /// `font_context_mut` invalida el caché (cambiar fuentes puede alterar el
1675 /// shaping): la siguiente llamada idéntica vuelve a ser miss.
1676 #[test]
1677 fn font_context_mut_invalida_el_cache() {
1678 let mut ts = Typesetter::new();
1679 let _ = ts.layout("hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
1680 assert_eq!(ts.cache_stats().entries, 1);
1681 let _ = ts.font_context_mut();
1682 assert_eq!(ts.cache_stats().entries, 0, "el caché quedó vacío");
1683 let _ = ts.layout("hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
1684 assert_eq!(ts.cache_stats().misses, 2, "post-invalidación = miss");
1685 }
1686
1687 /// Decoración (underline / strikethrough): el flag de entrada debe
1688 /// llegar al `parley::Layout` como `style.underline`/`style.strikethrough`
1689 /// presentes en cada run, y el caché debe distinguir su clave (mismo
1690 /// texto con vs sin decoración = entradas separadas).
1691 #[test]
1692 fn underline_y_strikethrough_se_propagan_al_layout() {
1693 let mut ts = Typesetter::new();
1694 let with_dec = ts.layout(
1695 "Hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, true, true, 0.0, 0.0,
1696 );
1697 // Caminamos los runs del layout y verificamos que cada GlyphRun trae
1698 // ambas decoraciones marcadas (no usamos `is_some` directo porque
1699 // `Layout::lines/items` exige iterar para llegar al Style).
1700 let mut visto_u = false;
1701 let mut visto_s = false;
1702 for line in with_dec.lines() {
1703 for item in line.items() {
1704 if let parley::PositionedLayoutItem::GlyphRun(gr) = item {
1705 if gr.style().underline.is_some() {
1706 visto_u = true;
1707 }
1708 if gr.style().strikethrough.is_some() {
1709 visto_s = true;
1710 }
1711 }
1712 }
1713 }
1714 assert!(visto_u, "underline=true ⇒ Decoration en al menos un run");
1715 assert!(visto_s, "strikethrough=true ⇒ Decoration en al menos un run");
1716
1717 // Sin decoración el layout no las trae.
1718 let plain = ts.layout(
1719 "Hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0,
1720 );
1721 for line in plain.lines() {
1722 for item in line.items() {
1723 if let parley::PositionedLayoutItem::GlyphRun(gr) = item {
1724 assert!(gr.style().underline.is_none(), "sin underline=true ⇒ None");
1725 assert!(gr.style().strikethrough.is_none(), "sin strikethrough=true ⇒ None");
1726 }
1727 }
1728 }
1729
1730 // Caché: dos misses (uno por cada variante), no se pisan.
1731 let s = ts.cache_stats();
1732 assert!(s.misses >= 2, "claves distintas por decoración ⇒ misses separados");
1733 }
1734
1735 /// Mecánica generacional: al pasar `cap`, `hot` rota a `cold`; un ítem
1736 /// reaccedido se promueve y sobrevive a la siguiente rotación.
1737 #[test]
1738 fn cache_generacional_promueve_y_rota() {
1739 let mut c = ShapeCache::new(2);
1740 let mk = |s: &str| ShapeKey {
1741 text: s.to_string(),
1742 size_bits: 0,
1743 max_width_bits: None,
1744 align: 0,
1745 line_height_bits: 0,
1746 italic: false,
1747 font_family: None,
1748 weight_bits: 0,
1749 underline: false,
1750 strikethrough: false,
1751 letter_bits: 0,
1752 word_bits: 0,
1753 overflow_wrap: false,
1754 features: String::new(),
1755 variations: String::new(),
1756 font_width_bits: 100.0_f32.to_bits(),
1757 };
1758 // Layouts vacíos como valores (sólo nos importa la presencia de claves).
1759 let dummy = parley::Layout::<()>::default;
1760 c.put(mk("a"), dummy());
1761 c.put(mk("b"), dummy());
1762 // "a" sigue caliente; lo accedemos para que se quede al rotar.
1763 assert!(c.get(&mk("a")).is_some());
1764 // Tercer insert: hot llegó a cap(2) → rota (a,b→cold), c entra a hot.
1765 c.put(mk("c"), dummy());
1766 // "a" estaba en cold; get lo encuentra y lo promueve a hot.
1767 assert!(c.get(&mk("a")).is_some(), "ítem reaccedido sobrevive la rotación");
1768 // "b" no se reaccedió: cae en la siguiente rotación.
1769 c.put(mk("d"), dummy()); // hot = {c, a-promovido}? -> al llegar a cap rota
1770 // Tras suficientes rotaciones sin tocar "b", desaparece.
1771 c.put(mk("e"), dummy());
1772 c.put(mk("f"), dummy());
1773 assert!(c.get(&mk("b")).is_none(), "ítem nunca reaccedido se libera");
1774 }
1775
1776 // =========================================================================
1777 // W2 :: que el shaping SIRVA sin las fuentes del sistema
1778 // -------------------------------------------------------------------------
1779 // Compilar sin `fontique/system` no prueba nada: si la resolución de
1780 // familias no encontrara ninguna cara, parley devolvería layouts vacíos y
1781 // el crate «compilaría perfecto» pintando nada. En wawa eso sería una
1782 // pantalla en blanco sin un solo error.
1783 //
1784 // Estos tests corren en LOS DOS perfiles y son los que de verdad certifican
1785 // W2. Para ejercitar el perfil de wawa en el anfitrión:
1786 // cargo test -p llimphi-text --no-default-features --features std
1787 // =========================================================================
1788
1789 /// Sin sistema de fuentes, las tres caras embebidas tienen que alcanzar
1790 /// para shapear texto latino con ancho real.
1791 #[test]
1792 fn shapea_sin_fuentes_del_sistema() {
1793 let mut ts = Typesetter::new();
1794 let lay = ts.layout(
1795 "Guardar como…",
1796 14.0,
1797 None,
1798 Alignment::Start,
1799 1.2,
1800 false,
1801 None,
1802 400.0,
1803 false,
1804 false,
1805 0.0,
1806 0.0,
1807 );
1808 let m = measurement(&lay);
1809 assert!(
1810 m.width > 10.0,
1811 "el texto tiene que medir algo real, midió {}",
1812 m.width
1813 );
1814 assert!(m.height > 5.0, "y tener alto, midió {}", m.height);
1815
1816 // Lo anterior podría pasar con cajas .notdef. Contar glifos de verdad:
1817 let glifos: usize = lay
1818 .lines()
1819 .flat_map(|l| l.items())
1820 .filter_map(|it| match it {
1821 parley::PositionedLayoutItem::GlyphRun(r) => Some(r.positioned_glyphs().count()),
1822 _ => None,
1823 })
1824 .sum();
1825 assert_eq!(
1826 glifos,
1827 "Guardar como…".chars().count(),
1828 "un glifo por carácter (sin ligaduras en este texto)"
1829 );
1830 }
1831
1832 /// El ancho tiene que ser PROPORCIONAL, no monoespaciado: si la resolución
1833 /// de fuentes fallara y todo cayera a una cara de reemplazo, `iii` y `WWW`
1834 /// medirían igual. Es la trampa que este test existe para atrapar.
1835 #[test]
1836 fn el_ancho_es_proporcional_no_monoespaciado() {
1837 let mut ts = Typesetter::new();
1838 let angosto = text_width_with(&mut ts, "iii", 14.0);
1839 let ancho = text_width_with(&mut ts, "WWW", 14.0);
1840 assert!(
1841 ancho > angosto * 1.5,
1842 "«WWW» ({ancho}) debe ser bastante más ancho que «iii» ({angosto})"
1843 );
1844 }
1845
1846 // =========================================================================
1847 // W3 :: del shaping a PÍXELES por el camino sin GPU
1848 // -------------------------------------------------------------------------
1849 // Es la prueba de punta a punta del plan: parley shapea (W2), la `Escena`
1850 // graba los glifos, `vello_cpu` los rasteriza (W1) y se cuentan píxeles.
1851 // Sin esto, «wawa sabe shapear» sería una afirmación sobre estructuras de
1852 // datos, no sobre algo que se vea.
1853 // =========================================================================
1854
1855 /// El texto shapeado tiene que llegar a píxeles encendidos — y sólo donde
1856 /// corresponde.
1857 #[cfg(feature = "cpu")]
1858 #[test]
1859 fn el_texto_llega_a_pixeles_por_el_backend_cpu() {
1860 use llimphi_cpu::kurbo::Affine;
1861 use llimphi_cpu::{Escena, Pintor};
1862
1863 let mut ts = Typesetter::new();
1864 let layout = ts.layout(
1865 "Hola wawa",
1866 24.0,
1867 None,
1868 Alignment::Start,
1869 1.2,
1870 false,
1871 None,
1872 400.0,
1873 false,
1874 false,
1875 0.0,
1876 0.0,
1877 );
1878 let m = measurement(&layout);
1879
1880 let mut escena = Escena::new();
1881 // Mismo nombre que del lado vello: el alias `Lienzo` es lo que conmuta.
1882 draw_layout_xf(
1883 &mut escena,
1884 &layout,
1885 Color::WHITE,
1886 Affine::translate((4.0, 4.0)),
1887 );
1888 assert!(!escena.is_empty(), "el layout tiene que emitir órdenes");
1889
1890 let mut pintor = Pintor::new(200, 60);
1891 let mut lienzo = pintor.lienzo_nuevo();
1892 pintor.pintar(&escena, &mut lienzo);
1893
1894 let encendidos = lienzo.data().iter().filter(|c| c.a > 0).count();
1895 assert!(
1896 encendidos > 100,
1897 "«Hola wawa» a 24px tiene que encender bastantes píxeles, encendió {encendidos}"
1898 );
1899
1900 // Y tiene que haber ANTIALIASING: si algo rasterizara en modo binario,
1901 // no habría píxeles a medio cubrir y el texto se vería dentado.
1902 let parciales = lienzo.data().iter().filter(|c| c.a > 0 && c.a < 255).count();
1903 assert!(
1904 parciales > 20,
1905 "el texto debe tener bordes suaves, hubo {parciales} píxeles parciales"
1906 );
1907
1908 // Los píxeles tienen que caer DENTRO de la caja que el layout prometió,
1909 // con margen de un par de px por el hinting y los bordes suaves. Si el
1910 // transform se aplicara mal, esto atrapa el corrimiento.
1911 let ancho = lienzo.width() as usize;
1912 let derecha_prometida = (4.0 + m.width) as usize + 3;
1913 let abajo_prometido = (4.0 + m.height) as usize + 3;
1914 for (i, px) in lienzo.data().iter().enumerate() {
1915 if px.a == 0 {
1916 continue;
1917 }
1918 let (x, y) = (i % ancho, i / ancho);
1919 assert!(
1920 x >= 3 && x <= derecha_prometida && y >= 3 && y <= abajo_prometido,
1921 "píxel encendido en ({x},{y}), fuera de la caja medida \
1922 ({}x{} desde (4,4))",
1923 m.width,
1924 m.height
1925 );
1926 }
1927 }
1928
1929 /// Los ids de glifo con que shapea `s` pidiendo la familia `familia`.
1930 /// `0` es `.notdef` — el tofu.
1931 #[cfg(feature = "system")]
1932 fn glifos_de(ts: &mut Typesetter, s: &str, familia: Option<&str>) -> Vec<u32> {
1933 // Por el chokepoint PÚBLICO, no armando el builder a mano: la stack de
1934 // la mono se resuelve ahí adentro, y un helper propio se saltaría justo
1935 // lo que hay que certificar.
1936 let layout = ts.layout(
1937 s,
1938 14.0,
1939 None,
1940 Alignment::Start,
1941 1.2,
1942 false,
1943 familia,
1944 400.0,
1945 false,
1946 false,
1947 0.0,
1948 0.0,
1949 );
1950 let mut ids = Vec::new();
1951 for line in layout.lines() {
1952 for item in line.items() {
1953 if let parley::PositionedLayoutItem::GlyphRun(gr) = item {
1954 ids.extend(gr.positioned_glyphs().map(|g| g.id));
1955 }
1956 }
1957 }
1958 ids
1959 }
1960
1961 /// Los glifos **Nerd Font** (zona privada) tienen que resolver cuando el
1962 /// bloque pide la mono — que es como pide su texto el grid del terminal.
1963 ///
1964 /// El caso real: la barra de zellij trae 25 separadores «powerline»
1965 /// (`U+E0B0`) y una veintena de íconos `U+F1xx`. Sin la Nerd Font en la
1966 /// stack salen todos `.notdef` y la barra es una fila de tofu.
1967 ///
1968 /// Se salta si la máquina no tiene ninguna Nerd Font instalada: el arreglo
1969 /// es best-effort contra las fuentes del sistema, y en esa máquina no hay
1970 /// nada que certificar.
1971 #[test]
1972 #[cfg(feature = "system")]
1973 fn los_glifos_nerd_resuelven_pidiendo_la_mono() {
1974 let mut ts = Typesetter::new();
1975 if !ts.mono_stack.contains("Nerd Font") {
1976 eprintln!("sin Nerd Font instalada — test omitido");
1977 return;
1978 }
1979 for s in ["\u{e0b0}", "\u{f192}", "\u{e712}"] {
1980 let ids = glifos_de(&mut ts, s, Some(MONOSPACE));
1981 assert!(
1982 !ids.is_empty() && !ids.contains(&0),
1983 "«U+{:04X}» shapeó a .notdef con la mono ({ids:?}) — saldría tofu",
1984 s.chars().next().unwrap() as u32
1985 );
1986 }
1987 // Y lo de siempre no se rompe: latino por la mono, símbolos por DejaVu.
1988 // Y lo que ya andaba por la mono no se rompe.
1989 for s in ["A", "→", "●", "⚠"] {
1990 let ids = glifos_de(&mut ts, s, Some(MONOSPACE));
1991 assert!(!ids.contains(&0), "«{s}» se rompió: {ids:?}");
1992 }
1993 // HUECO CONOCIDO, anterior a esto y sin arreglar: `✓` (U+2713) shapea a
1994 // .notdef pidiendo la mono. Medido con la stack nueva Y con
1995 // `Liberation Mono` a secas — los dos dan `[0]`, así que no lo trajo la
1996 // Nerd Font. Ni la mono embebida ni la cadena de `Zyyy` lo cubren por
1997 // ese camino; por la sans sí resuelve, que es por qué
1998 // `los_simbolos_del_chrome_tienen_glifo` no lo ve. Queda como aviso
1999 // explícito en vez de como sorpresa: en el grid del terminal el check
2000 // sale tofu.
2001 assert_eq!(
2002 glifos_de(&mut ts, "✓", Some(MONOSPACE)),
2003 vec![0],
2004 "si esto empieza a fallar, alguien tapó el hueco del check — \
2005 borrá esta aserción y metelo en la lista de arriba"
2006 );
2007 }
2008
2009 /// Las cuatro fases de la luna tienen que resolver **pidiendo la sans**,
2010 /// que es como pide su texto un panel de lectura.
2011 ///
2012 /// No es una precaución genérica: **Inter no tiene las medias lunas**
2013 /// (`U+25D0`/`U+25D1`; sí tiene `●` y `○`), así que esas dos salen sí o sí
2014 /// del fallback de `Zyyy` a DejaVu. Y este repo ya se quemó con eso — los
2015 /// glifos planetarios de cosmos se dibujan como geometría a mano
2016 /// (`cosmos_render::glyphs`) porque con unicode «el usuario veía sólo
2017 /// Venus y Marte» y el resto caía en `.notdef` **invisible**, que es la
2018 /// peor forma de fallar: no hay cuadrado que delate el hueco.
2019 ///
2020 /// Por ancho no alcanza —una caja de tofu también mide—: se miran los ids.
2021 #[test]
2022 #[cfg(feature = "system")]
2023 fn las_fases_de_la_luna_tienen_glifo() {
2024 let mut ts = Typesetter::new();
2025 for s in ["●", "◐", "○", "◑"] {
2026 let ids = glifos_de(&mut ts, s, None);
2027 assert!(
2028 !ids.is_empty() && !ids.contains(&0),
2029 "«U+{:04X}» shapeó a .notdef por la sans ({ids:?}) — saldría tofu",
2030 s.chars().next().unwrap() as u32
2031 );
2032 }
2033 // Brazo de control: si el shapeo diera glifos para cualquier cosa, el
2034 // test de arriba pasaría sin certificar nada. Un punto de código sin
2035 // asignar tiene que dar `.notdef`.
2036 assert!(
2037 glifos_de(&mut ts, "\u{10FFFD}", None).contains(&0),
2038 "un punto de código sin asignar NO dio .notdef — el instrumento \
2039 no distingue «hay glifo» de «no hay», y las fases de arriba no \
2040 están certificadas por nada"
2041 );
2042 }
2043
2044 /// El fallback de símbolos embebido (DejaVu) tiene que cubrir lo que el
2045 /// chrome usa de verdad: flechas, checks, avisos. Sin esto, wawa pintaría
2046 /// tofu en los menús.
2047 #[test]
2048 fn los_simbolos_del_chrome_tienen_glifo() {
2049 let mut ts = Typesetter::new();
2050 for s in ["→", "✓", "⚠", "●"] {
2051 let w = text_width_with(&mut ts, s, 14.0);
2052 assert!(w > 1.0, "«{s}» no shapeó a nada (ancho {w})");
2053 }
2054 }
2055}
2056
2057// =============================================================================
2058// El camino CPU :: los mismos nombres, otro pincel (W3.b)
2059// -----------------------------------------------------------------------------
2060// Gemelos exactos de `draw_layout_brush_xf`, `draw_layout_runs_xf` y
2061// `paint_decoration`, contra el backend sin GPU. Mismas firmas: parley resuelve
2062// el layout igual de los dos lados —mismo `Layout`, mismos glifos, mismas
2063// métricas— y lo único que cambia es a quién se le entregan.
2064//
2065// `hint` va fijo en `true`: a tamaños de UI (12-14 px) ajustar los contornos a
2066// la grilla es la diferencia entre texto nítido y texto sucio, y no cuesta del
2067// lado app —lo hace el rasterizador nativo—. No se expone como parámetro
2068// porque la firma tiene que coincidir con la de vello, que no lo tiene.
2069// =============================================================================
2070
2071/// Pinta un layout con un [`Brush`] arbitrario en el backend CPU.
2072#[cfg(feature = "cpu")]
2073pub fn draw_layout_brush_xf(
2074 scene: &mut Lienzo,
2075 layout: &parley::Layout<()>,
2076 brush: &Brush,
2077 transform: peniko::kurbo::Affine,
2078) {
2079 for line in layout.lines() {
2080 for item in line.items() {
2081 let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item else {
2082 continue;
2083 };
2084 let run = glyph_run.run();
2085 let font = run.font().clone();
2086 scene.draw_glyphs(
2087 &font,
2088 run.font_size(),
2089 brush,
2090 transform,
2091 true,
2092 glyph_run.positioned_glyphs().map(|g| llimphi_cpu::Glifo {
2093 id: g.id as u32,
2094 x: g.x,
2095 y: g.y,
2096 }),
2097 );
2098 paint_decoration(scene, &glyph_run, brush, transform);
2099 }
2100 }
2101}
2102
2103/// Pinta un layout multicolor (un brush por run) en el backend CPU.
2104#[cfg(feature = "cpu")]
2105pub fn draw_layout_runs_xf(
2106 scene: &mut Lienzo,
2107 layout: &parley::Layout<RunBrush>,
2108 transform: peniko::kurbo::Affine,
2109) {
2110 for line in layout.lines() {
2111 for item in line.items() {
2112 let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item else {
2113 continue;
2114 };
2115 let brush = Brush::Solid(glyph_run.style().brush.0);
2116 let run = glyph_run.run();
2117 let font = run.font().clone();
2118 scene.draw_glyphs(
2119 &font,
2120 run.font_size(),
2121 &brush,
2122 transform,
2123 true,
2124 glyph_run.positioned_glyphs().map(|g| llimphi_cpu::Glifo {
2125 id: g.id as u32,
2126 x: g.x,
2127 y: g.y,
2128 }),
2129 );
2130 paint_decoration(scene, &glyph_run, &brush, transform);
2131 }
2132 }
2133}
2134
2135/// Las decoraciones (`underline`/`strikethrough`) del run, en el backend CPU.
2136///
2137/// Misma convención que el gemelo de vello: parley devuelve el offset en
2138/// font-space (Y arriba, positivo = sobre la línea base), y en pantalla (Y
2139/// abajo) el rect va a `baseline - offset`.
2140#[cfg(feature = "cpu")]
2141fn paint_decoration<B: parley::Brush>(
2142 scene: &mut Lienzo,
2143 glyph_run: &parley::GlyphRun<'_, B>,
2144 brush: &Brush,
2145 transform: peniko::kurbo::Affine,
2146) {
2147 let style = glyph_run.style();
2148 let metrics = glyph_run.run().metrics();
2149 let x = glyph_run.offset() as f64;
2150 let baseline = glyph_run.baseline() as f64;
2151 let advance = glyph_run.advance() as f64;
2152 for (dec, off_def, size_def) in [
2153 (
2154 &style.underline,
2155 metrics.underline_offset,
2156 metrics.underline_size,
2157 ),
2158 (
2159 &style.strikethrough,
2160 metrics.strikethrough_offset,
2161 metrics.strikethrough_size,
2162 ),
2163 ] {
2164 let Some(d) = dec else { continue };
2165 let offset = d.offset.unwrap_or(off_def) as f64;
2166 let size = d.size.unwrap_or(size_def) as f64;
2167 let y0 = baseline - offset;
2168 scene.fill(
2169 peniko::Fill::NonZero,
2170 transform,
2171 brush,
2172 None,
2173 &peniko::kurbo::Rect::new(x, y0, x + advance, y0 + size),
2174 );
2175 }
2176}
2177
2178#[cfg(test)]
2179mod tests_negrita_real {
2180 /// Las caras que resuelve el shaping para un texto, como `id#índice`.
2181 fn caras(ts: &mut super::Typesetter, familia: Option<&str>, peso: f32) -> Vec<String> {
2182 let l = ts.layout(
2183 "Negrita",
2184 13.0,
2185 None,
2186 super::Alignment::Start,
2187 18.0,
2188 false,
2189 familia,
2190 peso,
2191 false,
2192 false,
2193 0.0,
2194 0.0,
2195 );
2196 let mut out = Vec::new();
2197 for linea in l.lines() {
2198 for item in linea.items() {
2199 if let parley::PositionedLayoutItem::GlyphRun(g) = item {
2200 let f = g.run().font();
2201 out.push(format!("{:?}#{}", f.data.id(), f.index));
2202 }
2203 }
2204 }
2205 out
2206 }
2207
2208 /// **`MONOSPACE` tiene variante negrita; la familia por defecto NO.**
2209 ///
2210 /// CÑ3 (2026-10-05): el texto de claude se veía plano. El camino entero
2211 /// estaba bien —la celda del vt100 llega a `TextSpanStyle { weight: 700 }` y
2212 /// de ahí a `parley::FontWeight`— y no se veía porque los spans de la consola
2213 /// pedían la familia **por defecto**, que en esta máquina resuelve la misma
2214 /// cara para 400 y 700.
2215 ///
2216 /// Los dos lados en el mismo test a propósito: sin el brazo de control
2217 /// —la familia que NO tiene negrita— esto pasaría igual si el shaping
2218 /// devolviera cualquier cosa.
2219 #[test]
2220 fn monospace_resuelve_una_cara_distinta_para_la_negrita() {
2221 let mut ts = super::Typesetter::new();
2222 let normal = caras(&mut ts, Some(super::MONOSPACE), 400.0);
2223 let negra = caras(&mut ts, Some(super::MONOSPACE), 700.0);
2224 assert!(
2225 !normal.is_empty(),
2226 "sin caras resueltas el test no mide nada (¿sin fuentes en esta máquina?)"
2227 );
2228 assert_ne!(
2229 normal, negra,
2230 "«{}» tiene que resolver otra cara con peso 700: si resuelve la misma, \
2231 pedir negrita no cambia un píxel y el texto sale plano",
2232 super::MONOSPACE
2233 );
2234 }
2235}