tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Definitions

⎇ main llimphi-text/src/lib.rs Rust · 2235 lines · 93.6 KB History Who touched what raw

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

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}