tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 llimphi-text/src/lib.rs Rust · 1331 siq'ikuna · 55.2 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · 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
8use vello::peniko::{Brush, Color};
9
10pub use parley;
11pub use vello;
12pub use vello::peniko;
13
14/// Estado compartido del motor de texto. Una instancia por proceso es lo
15/// recomendado: `FontContext` cachea la base de fuentes y `LayoutContext`
16/// reutiliza allocaciones entre layouts.
17pub struct Typesetter {
18    font_cx: parley::FontContext,
19    layout_cx: parley::LayoutContext<()>,
20    /// Contexto separado para layouts multicolor (`Brush` por rango). El
21    /// brush genérico de parley no puede ser `()` y `RunBrush` a la vez en
22    /// el mismo `LayoutContext`, así que mantenemos uno por sabor.
23    runs_cx: parley::LayoutContext<RunBrush>,
24    /// Caché de shaping: `[`Self::layout`]` es el único chokepoint por el que
25    /// pasan medición y pintado (vía `layout_clamped`), y se invoca por cada
26    /// nodo de texto en **cada** redraw — dos veces (medir + pintar). Shapear
27    /// con parley (font matching, bidi, clusters, line break) es lo caro; el
28    /// `parley::Layout` resultante es `Clone`. Cacheamos por los parámetros
29    /// que lo determinan y clonamos en el hit: durante scroll/tipeo, el texto
30    /// que no cambió no se re-shapea.
31    cache: ShapeCache,
32    cache_hits: u64,
33    cache_misses: u64,
34}
35
36/// Estadísticas del caché de shaping (evidencia/benchmark). `entries` es el
37/// total vivo entre las dos generaciones.
38#[derive(Debug, Clone, Copy, Default)]
39pub struct CacheStats {
40    pub hits: u64,
41    pub misses: u64,
42    pub entries: usize,
43}
44
45/// Clave de caché: todos los parámetros que determinan un `layout`. Los `f32`
46/// van por `to_bits` para ser `Hash + Eq` exactos (sin problemas de NaN/−0.0:
47/// comparamos los bits crudos, no el valor numérico). `Alignment` se mapea a
48/// un tag `u8` porque su enum no deriva `Hash`.
49#[derive(Clone, PartialEq, Eq, Hash)]
50struct ShapeKey {
51    text: String,
52    size_bits: u32,
53    max_width_bits: Option<u32>,
54    align: u8,
55    line_height_bits: u32,
56    italic: bool,
57    font_family: Option<String>,
58    weight_bits: u32,
59    /// Underline activo. parley emite `Decoration` por run cuando este flag
60    /// está, así que el layout difiere y el caché tiene que separarlos.
61    underline: bool,
62    /// Strikethrough activo. Idem `underline`.
63    strikethrough: bool,
64    /// `letter-spacing` (px extra entre letras). 0 = sin override. Cambia el
65    /// shaping/ancho, así que entra en la clave.
66    letter_bits: u32,
67    /// `word-spacing` (px extra entre palabras). Idem `letter_bits`.
68    word_bits: u32,
69    /// `overflow-wrap: break-word`/`anywhere`: si está, parley puede partir
70    /// dentro de una palabra para que entre en la caja. Cambia el line-break,
71    /// así que separa la entrada del caché.
72    overflow_wrap: bool,
73    /// `font-feature-settings` (+ `font-kerning` plegado como `kern`) en formato
74    /// CSS crudo (`"liga" 1, "kern" 0`). Vacío = default de la fuente. Cambia el
75    /// shaping (glifos/ligaduras), así que entra en la clave del caché.
76    features: String,
77    /// `font-variation-settings` en formato CSS crudo (`"wght" 700, "wdth" 85`).
78    /// Vacío = ejes en su default. Cambia el shaping (instancia de la fuente
79    /// variable), así que entra en la clave.
80    variations: String,
81    /// `font-stretch`/`font-width` como **porcentaje** (100 = normal, <100
82    /// condensada, >100 expandida). Selecciona la face condensada/expandida (o
83    /// el eje `wdth` de una variable) ⇒ cambia el shaping, entra en la clave.
84    font_width_bits: u32,
85}
86
87fn align_tag(a: Alignment) -> u8 {
88    match a {
89        Alignment::Start => 0,
90        Alignment::Center => 1,
91        Alignment::End => 2,
92        Alignment::Justify => 3,
93    }
94}
95
96/// Caché generacional (LRU aproximado, sin dependencias). Dos mapas: `hot`
97/// recibe inserciones y promociones; cuando `hot` llega a `cap`, rota
98/// (`cold = hot`, `hot = ∅`) y la generación vieja se descarta. Un hit en
99/// `cold` se promueve a `hot`, así lo accedido en la última época sobrevive a
100/// la rotación — el texto visible, re-consultado cada frame, queda siempre
101/// caliente; lo transitorio (candidatos de elipsis, tooltips) cae solo. Es el
102/// patrón de los cachés de glyph/shape de swash/cosmic-text: O(1), sin orden
103/// enlazado.
104struct ShapeCache {
105    hot: std::collections::HashMap<ShapeKey, parley::Layout<()>>,
106    cold: std::collections::HashMap<ShapeKey, parley::Layout<()>>,
107    cap: usize,
108}
109
110impl ShapeCache {
111    fn new(cap: usize) -> Self {
112        Self {
113            hot: std::collections::HashMap::new(),
114            cold: std::collections::HashMap::new(),
115            cap,
116        }
117    }
118
119    /// Devuelve un clon del layout cacheado si existe, promoviendo desde
120    /// `cold` a `hot` en el camino.
121    fn get(&mut self, key: &ShapeKey) -> Option<parley::Layout<()>> {
122        if let Some(v) = self.hot.get(key) {
123            return Some(v.clone());
124        }
125        // Hit frío: sácalo de cold y reinsertalo en hot (promoción). Una sola
126        // clonación: el clon queda en hot, el original se devuelve al caller.
127        if let Some(v) = self.cold.remove(key) {
128            self.hot.insert(key.clone(), v.clone());
129            return Some(v);
130        }
131        None
132    }
133
134    fn put(&mut self, key: ShapeKey, layout: parley::Layout<()>) {
135        if self.hot.len() >= self.cap {
136            // Rota la generación: lo no reaccedido desde la última rotación
137            // (quedó sólo en cold) se libera aquí.
138            self.cold = std::mem::take(&mut self.hot);
139        }
140        self.hot.insert(key, layout);
141    }
142
143    fn clear(&mut self) {
144        self.hot.clear();
145        self.cold.clear();
146    }
147
148    fn entries(&self) -> usize {
149        self.hot.len() + self.cold.len()
150    }
151}
152
153/// Capacidad de la generación caliente antes de rotar. 512 layouts cubre con
154/// holgura el texto visible de una UI densa (un editor de ~50 líneas + chrome)
155/// sin retener de más. La memoria real es ~2× (dos generaciones).
156const SHAPE_CACHE_CAP: usize = 512;
157
158impl Default for Typesetter {
159    fn default() -> Self {
160        Self::new()
161    }
162}
163
164/// DejaVu Sans embebida como **fallback universal de símbolos**. El motor
165/// confía en las fuentes del sistema vía fontique, pero muchas instalaciones
166/// (p. ej. solo Liberation/Adwaita) carecen de glyphs para flechas (`→`),
167/// formas geométricas (`● ▶`), dingbats (`✓ ✗ ✎`), avisos (`⚠`) o astro
168/// (`♈ ☉ ☽`) — y entonces parley pinta el "tofu" (□). DejaVu cubre todo ese
169/// rango; la registramos y la enganchamos al fallback del script `Common`
170/// (`Zyyy`), que es donde Unicode clasifica esos símbolos. Así cualquier app
171/// Llimphi deja de mostrar cuadrados sin tocar una línea de su código.
172/// Licencia: Bitstream Vera + Arev (libre, redistribuible).
173const DEJAVU_SANS: &[u8] = include_bytes!("../assets/DejaVuSans.ttf");
174
175/// **Inter** embebida como **fuente de UI por defecto** (SIL OFL 1.1, libre y
176/// redistribuible — ver `assets/Inter-LICENSE.txt`). Inter es una grotesca
177/// neo-humanista diseñada específicamente para interfaces a tamaños chicos:
178/// caja alta de la x, aperturas amplias y espaciado parejo. Es el look 2026
179/// que queremos de fábrica, sin depender de que el sistema tenga una sans
180/// linda instalada (en una instalación pelada el default de fontique podía
181/// caer en Liberation/Adwaita, que envejecen mal). La enganchamos como
182/// primera familia del genérico `sans-serif` (ver [`Typesetter::install_ui_font`]),
183/// que es lo que parley resuelve cuando el bloque no pide `font_family`. El
184/// fallback por-script sigue intacto: símbolos via DejaVu, CJK/árabe/etc. via
185/// las fuentes del sistema.
186const INTER_SANS: &[u8] = include_bytes!("../assets/Inter-Regular.ttf");
187
188/// Fuente monoespaciada embebida (Liberation Mono, SIL OFL — metric-
189/// compatible con Courier). Va embebida para que *cualquier* app Llimphi
190/// pueda pedir ancho fijo (output de terminal, IDE-text, tablas que
191/// columnean) sin depender de que el sistema tenga una mono instalada.
192/// Se referencia por su nombre de familia con [`MONOSPACE`].
193const LIBERATION_MONO: &[u8] = include_bytes!("../assets/LiberationMono.ttf");
194
195/// Bytes de la fuente **monospace embebida** (Liberation Mono TTF). Pública
196/// para que otros crates (p. ej. `llimphi-widget-terminal`, que necesita
197/// rasterizar glifos para su atlas GPU) usen exactamente la misma fuente
198/// que el render normal, sin volver a embeber el archivo.
199pub const MONO_FONT_BYTES: &[u8] = LIBERATION_MONO;
200
201/// Bytes de la fuente **sans de UI embebida** (Inter Regular TTF). Pública para
202/// que otros crates rastericen texto proporcional con la misma fuente que el
203/// render normal (p. ej. `tullpu` para sus capas de texto), sin re-embeber el
204/// archivo. Referenciada por familia con [`UI_SANS`].
205pub const SANS_FONT_BYTES: &[u8] = INTER_SANS;
206
207/// Nombre de familia de la fuente monoespaciada embebida. Pasalo como
208/// `font_family: Some(llimphi_text::MONOSPACE)` en un [`TextBlock`] (o el
209/// `font_family` de `layout`) para render de ancho fijo garantizado.
210pub const MONOSPACE: &str = "Liberation Mono";
211
212/// Nombre de familia de la fuente de UI embebida ([Inter](https://rsms.me/inter/)).
213/// Es el default proporcional cuando un bloque **no** especifica `font_family`
214/// (la enganchamos como primera familia del genérico `sans-serif`). Exponemos
215/// el nombre por si un caller quiere pedirla explícitamente.
216pub const UI_SANS: &str = "Inter";
217
218impl Typesetter {
219    pub fn new() -> Self {
220        let mut font_cx = parley::FontContext::new();
221        Self::install_ui_font(&mut font_cx);
222        Self::install_symbol_fallback(&mut font_cx);
223        Self::install_monospace(&mut font_cx);
224        Self {
225            font_cx,
226            layout_cx: parley::LayoutContext::new(),
227            runs_cx: parley::LayoutContext::new(),
228            cache: ShapeCache::new(SHAPE_CACHE_CAP),
229            cache_hits: 0,
230            cache_misses: 0,
231        }
232    }
233
234    /// Registra **Inter** y la pone como **primera familia del genérico
235    /// `sans-serif`**. Ese genérico es lo que parley resuelve cuando un bloque
236    /// no especifica `font_family` (su default es `FontStack::Source("sans-serif")`),
237    /// así que con esto toda app Llimphi tipografía en Inter de fábrica sin
238    /// tocar una línea de su código, y sin depender de la sans del sistema.
239    /// Usamos `append_*` (no `set_*`) para no borrar las familias que el SO ya
240    /// asociaba al genérico: Inter va primero, el resto queda detrás como
241    /// respaldo. La cobertura de scripts no-latinos / símbolos sigue saliendo
242    /// del fallback por-script (CJK del sistema, símbolos de DejaVu). Si una
243    /// app pide otra familia explícita, gana esa. Best-effort: si el registro
244    /// falla, el texto sigue con la sans del sistema.
245    fn install_ui_font(font_cx: &mut parley::FontContext) {
246        use parley::fontique::{Blob, GenericFamily};
247        let blob = Blob::new(std::sync::Arc::new(INTER_SANS));
248        let registered = font_cx.collection.register_fonts(blob, None);
249        if let Some((family_id, _)) = registered.first() {
250            // Las familias actuales del genérico (las del sistema) van detrás:
251            // Inter primero, luego el respaldo previo.
252            let existing: Vec<_> = font_cx
253                .collection
254                .generic_families(GenericFamily::SansSerif)
255                .collect();
256            font_cx.collection.set_generic_families(
257                GenericFamily::SansSerif,
258                std::iter::once(*family_id).chain(existing),
259            );
260        }
261    }
262
263    /// Registra DejaVu Sans y la apila como último recurso para los símbolos
264    /// del script `Common` (flechas, geométricos, dingbats, astro…). Ver la
265    /// nota de [`DEJAVU_SANS`]. Best-effort: si algo falla, el texto sigue
266    /// funcionando con las fuentes del sistema (solo reaparecería el tofu).
267    fn install_symbol_fallback(font_cx: &mut parley::FontContext) {
268        use parley::fontique::Blob;
269        let blob = Blob::new(std::sync::Arc::new(DEJAVU_SANS));
270        let registered = font_cx.collection.register_fonts(blob, None);
271        if let Some((family_id, _)) = registered.first() {
272            // `Zyyy` (Common) es el script de la inmensa mayoría de los
273            // símbolos que daban tofu; lo apilamos al final del fallback.
274            font_cx
275                .collection
276                .append_fallbacks("Zyyy", std::iter::once(*family_id));
277        }
278    }
279
280    /// Registra la fuente monoespaciada embebida (Liberation Mono) bajo su
281    /// nombre de familia [`MONOSPACE`], para que `FontStack::Source`
282    /// (`font_family: Some(MONOSPACE)`) la resuelva aunque el sistema no
283    /// tenga ninguna mono instalada. Best-effort: si falla, los callers que
284    /// pidan monospace caen al fallback de fontique (mono del sistema, o la
285    /// proporcional si no hay) — el texto sigue, sólo pierde el ancho fijo.
286    fn install_monospace(font_cx: &mut parley::FontContext) {
287        use parley::fontique::Blob;
288        let blob = Blob::new(std::sync::Arc::new(LIBERATION_MONO));
289        font_cx.collection.register_fonts(blob, None);
290    }
291
292    /// Acceso al `FontContext` por si se necesita registrar fuentes extra
293    /// o cambiar la stack de fallback. **Invalida el caché de shaping**: tocar
294    /// el set de fuentes o el fallback puede cambiar el resultado de cualquier
295    /// layout, así que descartamos lo cacheado (operación rara, de setup).
296    pub fn font_context_mut(&mut self) -> &mut parley::FontContext {
297        self.cache.clear();
298        &mut self.font_cx
299    }
300
301    /// Estadísticas del caché de shaping (hits/misses acumulados + entradas
302    /// vivas). Para benchmark/evidencia; no afecta el render.
303    pub fn cache_stats(&self) -> CacheStats {
304        CacheStats {
305            hits: self.cache_hits,
306            misses: self.cache_misses,
307            entries: self.cache.entries(),
308        }
309    }
310
311    /// Construye y resuelve un `parley::Layout`. Aplica `font_size`,
312    /// `line_height` (multiplicador del font_size), `max_width` (line
313    /// break), `alignment` y `weight` (peso de fuente CSS: 400 normal,
314    /// 700 bold). `italic`=true selecciona la variante italic/oblique de
315    /// la fuente activa (vía `parley::FontStyle`). `underline`/`strikethrough`
316    /// activan la decoración global del bloque — parley deja la metadata
317    /// (offset + grosor) en cada `Run` y el pintado (`draw_layout_*`) emite
318    /// el rect correspondiente sobre la línea base.
319    /// API pública 12-arg (sin `overflow-wrap`): la usan showreels, canvas,
320    /// hit-testing de selección, etc. Delega en [`Self::layout_inner`] con
321    /// `overflow_wrap = false` (la palabra larga desborda, comportamiento
322    /// histórico). El quiebre dentro de palabra entra sólo por `layout_clamped`
323    /// (camino del compositor), para no propagar el flag a todos los callers.
324    #[allow(clippy::too_many_arguments)]
325    pub fn layout(
326        &mut self,
327        text: &str,
328        size_px: f32,
329        max_width: Option<f32>,
330        alignment: Alignment,
331        line_height: f32,
332        italic: bool,
333        font_family: Option<&str>,
334        weight: f32,
335        underline: bool,
336        strikethrough: bool,
337        letter_spacing: f32,
338        word_spacing: f32,
339    ) -> parley::Layout<()> {
340        self.layout_inner(
341            text, size_px, max_width, alignment, line_height, italic, font_family, weight,
342            underline, strikethrough, letter_spacing, word_spacing, false, "", "", 100.0,
343        )
344    }
345
346    /// Impl real del shaping con el flag `overflow_wrap` (CSS
347    /// `overflow-wrap: break-word`/`anywhere`). Privado: sólo lo invocan
348    /// [`Self::layout`] (con `false`) y [`Self::layout_clamped`] (con el valor
349    /// del estilo). Así la firma pública 12-arg no cambia y los ~20 callers de
350    /// showreels/canvas siguen compilando sin tocar.
351    #[allow(clippy::too_many_arguments)]
352    fn layout_inner(
353        &mut self,
354        text: &str,
355        size_px: f32,
356        max_width: Option<f32>,
357        alignment: Alignment,
358        line_height: f32,
359        italic: bool,
360        font_family: Option<&str>,
361        weight: f32,
362        underline: bool,
363        strikethrough: bool,
364        letter_spacing: f32,
365        word_spacing: f32,
366        overflow_wrap: bool,
367        font_features: &str,
368        font_variations: &str,
369        font_width: f32,
370    ) -> parley::Layout<()> {
371        // Caché de shaping: clave por todos los parámetros que determinan el
372        // layout. En el hit clonamos el `parley::Layout` (memcpy de vectores,
373        // ~órdenes de magnitud más barato que re-shapear). El `String`/clave
374        // que se aloca para consultar es un costo menor frente al shaping que
375        // evita; mantener la firma `&str` no fuerza alloc en el caller.
376        let key = ShapeKey {
377            text: text.to_string(),
378            size_bits: size_px.to_bits(),
379            max_width_bits: max_width.map(f32::to_bits),
380            align: align_tag(alignment),
381            line_height_bits: line_height.to_bits(),
382            italic,
383            font_family: font_family.map(str::to_string),
384            weight_bits: weight.to_bits(),
385            underline,
386            strikethrough,
387            letter_bits: letter_spacing.to_bits(),
388            word_bits: word_spacing.to_bits(),
389            overflow_wrap,
390            features: font_features.to_string(),
391            variations: font_variations.to_string(),
392            font_width_bits: font_width.to_bits(),
393        };
394        if let Some(hit) = self.cache.get(&key) {
395            self.cache_hits += 1;
396            return hit;
397        }
398        self.cache_misses += 1;
399        let mut builder =
400            self.layout_cx
401                .ranged_builder(&mut self.font_cx, text, 1.0, true);
402        builder.push_default(parley::StyleProperty::FontSize(size_px));
403        builder.push_default(parley::StyleProperty::LineHeight(
404            parley::LineHeight::FontSizeRelative(line_height),
405        ));
406        // SIEMPRE fijamos el peso (antes se saltaba `== 400.0` como "optimización"):
407        // con una fuente VARIABLE, NO setear `FontWeight` deja el peso en el DEFAULT
408        // del archivo (que puede ser bold), no en el 400 pedido. Ese era el "texto
409        // bold feo" al escribir en un campo. Pedir 400 debe dar 400.
410        builder.push_default(parley::StyleProperty::FontWeight(
411            parley::FontWeight::new(weight),
412        ));
413        if italic {
414            builder.push_default(parley::StyleProperty::FontStyle(
415                parley::FontStyle::Italic,
416            ));
417        }
418        if let Some(ff) = font_family {
419            // parley::FontStack::Source acepta CSS-like syntax
420            // (`"Helvetica", sans-serif`).
421            builder.push_default(parley::StyleProperty::FontStack(
422                parley::FontStack::Source(std::borrow::Cow::Borrowed(ff)),
423            ));
424        }
425        if underline {
426            builder.push_default(parley::StyleProperty::Underline(true));
427        }
428        if strikethrough {
429            builder.push_default(parley::StyleProperty::Strikethrough(true));
430        }
431        // `letter-spacing`/`word-spacing` (px extra). 0 = sin override (normal).
432        if letter_spacing != 0.0 {
433            builder.push_default(parley::StyleProperty::LetterSpacing(letter_spacing));
434        }
435        if word_spacing != 0.0 {
436            builder.push_default(parley::StyleProperty::WordSpacing(word_spacing));
437        }
438        // `overflow-wrap: break-word`/`anywhere`: habilita la partición dentro
439        // de una palabra cuando no hay otra oportunidad de quiebre en la línea
440        // (un token más ancho que la caja). `Anywhere` cubre ambos valores CSS
441        // — su única diferencia con `BreakWord` es el min-content sizing, sin
442        // efecto visible en el wrap del bloque. Sin el flag (normal) parley deja
443        // desbordar la palabra larga (comportamiento previo).
444        if overflow_wrap {
445            builder.push_default(parley::StyleProperty::OverflowWrap(
446                parley::OverflowWrap::Anywhere,
447            ));
448        }
449        // `font-feature-settings`/`font-variation-settings` (+ `font-kerning`
450        // plegado en la fuente de features): parley/swash parsean el string CSS
451        // crudo (`FontSettings::Source`, gramática `"tag" value, …`). Vacío =
452        // default de la fuente (no-op, no se empuja). El `&str` vive hasta el
453        // `build`, así que el `Cow::Borrowed` es válido (mismo patrón que
454        // `FontStack::Source`).
455        if !font_features.is_empty() {
456            builder.push_default(parley::StyleProperty::FontFeatures(
457                parley::FontSettings::Source(std::borrow::Cow::Borrowed(font_features)),
458            ));
459        }
460        if !font_variations.is_empty() {
461            builder.push_default(parley::StyleProperty::FontVariations(
462                parley::FontSettings::Source(std::borrow::Cow::Borrowed(font_variations)),
463            ));
464        }
465        // `font-stretch`/`font-width` (porcentaje, 100 = normal): selecciona la
466        // face condensada/expandida o el eje `wdth` de una variable. Sólo se
467        // empuja si difiere de normal (no-op en el caso común).
468        if (font_width - 100.0).abs() > 0.01 {
469            builder.push_default(parley::StyleProperty::FontWidth(
470                parley::FontWidth::from_percentage(font_width),
471            ));
472        }
473        let mut layout = builder.build(text);
474        layout.break_all_lines(max_width);
475        layout.align(
476            max_width,
477            alignment.into(),
478            parley::AlignmentOptions::default(),
479        );
480        self.cache.put(key, layout.clone());
481        layout
482    }
483
484    /// Como [`Self::layout`] pero **clampado** a `max_lines` líneas (CSS
485    /// `-webkit-line-clamp` / Flutter `maxLines`). Si el texto envuelto cabe en
486    /// `max_lines` o menos, devuelve el layout completo. Si excede:
487    /// - `ellipsis = true` → la última línea visible termina en `…` (se
488    ///   recortan graphemes del final hasta que el bloque vuelve a caber en
489    ///   `max_lines`).
490    /// - `ellipsis = false` → se corta sin glifo (queda el prefijo que cupo).
491    ///
492    /// `max_lines = None` o `Some(0)` ⇒ sin límite (idéntico a `layout`). El
493    /// clamp sólo recorta cuando hay envoltura, así que requiere un `max_width`
494    /// definido para tener efecto (un label en una caja dimensionada — el caso
495    /// típico). Reusa `layout` internamente: 0 costo extra cuando no trunca.
496    #[allow(clippy::too_many_arguments)]
497    pub fn layout_clamped(
498        &mut self,
499        text: &str,
500        size_px: f32,
501        max_width: Option<f32>,
502        alignment: Alignment,
503        line_height: f32,
504        italic: bool,
505        font_family: Option<&str>,
506        weight: f32,
507        max_lines: Option<usize>,
508        ellipsis: bool,
509        underline: bool,
510        strikethrough: bool,
511        letter_spacing: f32,
512        word_spacing: f32,
513        overflow_wrap: bool,
514        font_features: &str,
515        font_variations: &str,
516        font_width: f32,
517    ) -> parley::Layout<()> {
518        let full = self.layout_inner(
519            text, size_px, max_width, alignment, line_height, italic, font_family, weight,
520            underline, strikethrough, letter_spacing, word_spacing, overflow_wrap, font_features,
521            font_variations, font_width,
522        );
523        let limit = match max_lines {
524            Some(n) if n >= 1 => n,
525            _ => return full,
526        };
527        if full.lines().count() <= limit {
528            return full;
529        }
530        // Byte de fin de la última línea visible (rango sobre `text` original).
531        let mut cutoff = full
532            .lines()
533            .nth(limit - 1)
534            .map(|l| l.text_range().end)
535            .unwrap_or(text.len())
536            .min(text.len());
537        while cutoff > 0 && !text.is_char_boundary(cutoff) {
538            cutoff -= 1;
539        }
540        let base = text[..cutoff].trim_end();
541        if !ellipsis {
542            return self.layout_inner(
543                base, size_px, max_width, alignment, line_height, italic, font_family, weight,
544                underline, strikethrough, letter_spacing, word_spacing, overflow_wrap,
545                font_features, font_variations, font_width,
546            );
547        }
548        // Recorta graphemes del final hasta que `base…` vuelva a caber en
549        // `limit` líneas (apilar el `…` puede empujar una palabra a una línea
550        // extra). Acotado: cada vuelta quita ≥1 char.
551        let mut s = base.to_string();
552        loop {
553            let candidate = format!("{s}…");
554            let lay = self.layout_inner(
555                &candidate, size_px, max_width, alignment, line_height, italic, font_family,
556                weight, underline, strikethrough, letter_spacing, word_spacing, overflow_wrap,
557                font_features, font_variations, font_width,
558            );
559            if s.is_empty() || lay.lines().count() <= limit {
560                return lay;
561            }
562            s.pop();
563            while s.ends_with(char::is_whitespace) {
564                s.pop();
565            }
566        }
567    }
568
569    /// Construye un layout **multicolor** en una sola pasada de shaping:
570    /// `default_color` cubre todo el texto y cada `(start_byte, end_byte,
571    /// color)` lo sobreescribe en su rango (offsets en **bytes**, no chars —
572    /// la convención de parley). Pensado para syntax highlighting: shapear
573    /// la línea entera una vez con un color por token, en vez de un layout
574    /// por token. Sin wrap (`max_width = None`); el caller posiciona la línea.
575    ///
576    /// `font_family` es la misma familia CSS-like que toma [`Self::layout`].
577    /// **No es opcional de verdad:** sin ella este camino shapeaba siempre con
578    /// la sans default, así que cualquier vista que pedía `.mono()` **y** color
579    /// por tramos perdía el ancho fijo en silencio — nada compilaba mal, sólo
580    /// se torcían las tablas de la consola.
581    #[allow(clippy::too_many_arguments)]
582    pub fn layout_runs(
583        &mut self,
584        text: &str,
585        size_px: f32,
586        default_color: Color,
587        runs: &[(usize, usize, Color)],
588        alignment: Alignment,
589        line_height: f32,
590        weight: f32,
591        underline: bool,
592        strikethrough: bool,
593        font_family: Option<&str>,
594    ) -> parley::Layout<RunBrush> {
595        let mut builder = self
596            .runs_cx
597            .ranged_builder(&mut self.font_cx, text, 1.0, true);
598        if let Some(ff) = font_family {
599            builder.push_default(parley::StyleProperty::FontStack(
600                parley::FontStack::Source(std::borrow::Cow::Borrowed(ff)),
601            ));
602        }
603        builder.push_default(parley::StyleProperty::FontSize(size_px));
604        builder.push_default(parley::StyleProperty::LineHeight(
605            parley::LineHeight::FontSizeRelative(line_height),
606        ));
607        // SIEMPRE fijamos el peso (antes se saltaba `== 400.0` como "optimización"):
608        // con una fuente VARIABLE, NO setear `FontWeight` deja el peso en el DEFAULT
609        // del archivo (que puede ser bold), no en el 400 pedido. Ese era el "texto
610        // bold feo" al escribir en un campo. Pedir 400 debe dar 400.
611        builder.push_default(parley::StyleProperty::FontWeight(
612            parley::FontWeight::new(weight),
613        ));
614        builder.push_default(parley::StyleProperty::Brush(RunBrush(default_color)));
615        if underline {
616            builder.push_default(parley::StyleProperty::Underline(true));
617        }
618        if strikethrough {
619            builder.push_default(parley::StyleProperty::Strikethrough(true));
620        }
621        let len = text.len();
622        for &(start, end, color) in runs {
623            if start < end && end <= len {
624                builder.push(parley::StyleProperty::Brush(RunBrush(color)), start..end);
625            }
626        }
627        let mut layout = builder.build(text);
628        layout.break_all_lines(None);
629        layout.align(None, alignment.into(), parley::AlignmentOptions::default());
630        layout
631    }
632
633    /// Construye un layout **RichText**: defaults a nivel bloque + un
634    /// arreglo de [`TextSpan`] que sobreescriben tamaño/peso/italic/familia/
635    /// color/decoración **por rango de bytes**. A diferencia de
636    /// [`Self::layout_runs`] (sólo color, sin wrap), este camino:
637    ///
638    /// - permite `max_width` (envuelve a párrafo);
639    /// - aplica los siete `StyleProperty` por rango;
640    /// - usa el mismo `runs_cx` (`RunBrush`), así puede convivir con el
641    ///   pintado multicolor.
642    ///
643    /// **Sin caché** en v1 (a diferencia de `layout`/`layout_clamped`): el
644    /// RichText típico cambia frame-a-frame (cursor de editor, hover de
645    /// link), y la clave de caché de un span-set arbitrario es pesada.
646    /// Reusa todo el shaping interno de parley, que ya es rápido para
647    /// párrafos de la magnitud de una UI.
648    #[allow(clippy::too_many_arguments)]
649    pub fn layout_spans(
650        &mut self,
651        text: &str,
652        size_px: f32,
653        default_color: Color,
654        weight: f32,
655        line_height: f32,
656        italic: bool,
657        font_family: Option<&str>,
658        underline: bool,
659        strikethrough: bool,
660        spans: &[TextSpan],
661        max_width: Option<f32>,
662        alignment: Alignment,
663    ) -> parley::Layout<RunBrush> {
664        let mut builder = self
665            .runs_cx
666            .ranged_builder(&mut self.font_cx, text, 1.0, true);
667        builder.push_default(parley::StyleProperty::FontSize(size_px));
668        builder.push_default(parley::StyleProperty::LineHeight(
669            parley::LineHeight::FontSizeRelative(line_height),
670        ));
671        // SIEMPRE fijamos el peso (antes se saltaba `== 400.0` como "optimización"):
672        // con una fuente VARIABLE, NO setear `FontWeight` deja el peso en el DEFAULT
673        // del archivo (que puede ser bold), no en el 400 pedido. Ese era el "texto
674        // bold feo" al escribir en un campo. Pedir 400 debe dar 400.
675        builder.push_default(parley::StyleProperty::FontWeight(
676            parley::FontWeight::new(weight),
677        ));
678        if italic {
679            builder.push_default(parley::StyleProperty::FontStyle(
680                parley::FontStyle::Italic,
681            ));
682        }
683        if let Some(ff) = font_family {
684            builder.push_default(parley::StyleProperty::FontStack(
685                parley::FontStack::Source(std::borrow::Cow::Borrowed(ff)),
686            ));
687        }
688        builder.push_default(parley::StyleProperty::Brush(RunBrush(default_color)));
689        if underline {
690            builder.push_default(parley::StyleProperty::Underline(true));
691        }
692        if strikethrough {
693            builder.push_default(parley::StyleProperty::Strikethrough(true));
694        }
695        let len = text.len();
696        for span in spans {
697            if span.start >= span.end || span.end > len {
698                continue;
699            }
700            let range = span.start..span.end;
701            let s = &span.style;
702            if let Some(v) = s.size_px {
703                builder.push(parley::StyleProperty::FontSize(v), range.clone());
704            }
705            if let Some(v) = s.weight {
706                builder.push(
707                    parley::StyleProperty::FontWeight(parley::FontWeight::new(v)),
708                    range.clone(),
709                );
710            }
711            if let Some(v) = s.italic {
712                let style = if v {
713                    parley::FontStyle::Italic
714                } else {
715                    parley::FontStyle::Normal
716                };
717                builder.push(parley::StyleProperty::FontStyle(style), range.clone());
718            }
719            if let Some(ff) = s.font_family.as_deref() {
720                builder.push(
721                    parley::StyleProperty::FontStack(parley::FontStack::Source(
722                        std::borrow::Cow::Owned(ff.to_string()),
723                    )),
724                    range.clone(),
725                );
726            }
727            if let Some(c) = s.color {
728                builder.push(parley::StyleProperty::Brush(RunBrush(c)), range.clone());
729            }
730            if let Some(v) = s.underline {
731                builder.push(parley::StyleProperty::Underline(v), range.clone());
732            }
733            if let Some(v) = s.strikethrough {
734                builder.push(parley::StyleProperty::Strikethrough(v), range.clone());
735            }
736        }
737        let mut layout = builder.build(text);
738        layout.break_all_lines(max_width);
739        layout.align(
740            max_width,
741            alignment.into(),
742            parley::AlignmentOptions::default(),
743        );
744        layout
745    }
746}
747
748/// Brush por-run para texto multicolor. Newtype sobre [`Color`] porque
749/// parley exige que el brush genérico implemente `Default` (que `Color` no
750/// garantiza); aquí proveemos uno explícito (negro opaco) que nunca se ve
751/// en la práctica: todo run lleva su color o el `default_color` del bloque.
752#[derive(Clone, Copy, PartialEq, Debug)]
753pub struct RunBrush(pub Color);
754
755impl Default for RunBrush {
756    fn default() -> Self {
757        RunBrush(Color::from_rgba8(0, 0, 0, 255))
758    }
759}
760
761/// Overrides de estilo aplicables a un **rango de bytes** dentro de un
762/// bloque de texto, para `Typesetter::layout_spans` (RichText). Cada
763/// campo es opcional: `None` hereda del default del bloque. La granularidad
764/// es por bytes (convención de parley), igual que el `runs` multicolor.
765#[derive(Default, Clone, Debug, PartialEq)]
766pub struct TextSpanStyle {
767    /// Tamaño de fuente (CSS `font-size`). El reshape recalcula el alto
768    /// de la línea afectada.
769    pub size_px: Option<f32>,
770    /// Peso de fuente (400 = normal, 700 = bold).
771    pub weight: Option<f32>,
772    /// Italic on/off.
773    pub italic: Option<bool>,
774    /// Family CSS-like ("Helvetica, sans-serif"). Útil para `code` inline
775    /// (forzar monospace en una palabra).
776    pub font_family: Option<String>,
777    /// Color del texto (gana sobre el `default_color` del bloque).
778    pub color: Option<Color>,
779    /// Subrayado on/off.
780    pub underline: Option<bool>,
781    /// Tachado on/off.
782    pub strikethrough: Option<bool>,
783}
784
785/// Un span de RichText: rango de bytes `[start, end)` + overrides de
786/// estilo (`style`). Los rangos pueden superponerse — parley aplica los
787/// `StyleProperty` en orden de inserción, así el caller debería pushar de
788/// menor a mayor especificidad.
789#[derive(Clone, Debug, PartialEq)]
790pub struct TextSpan {
791    pub start: usize,
792    pub end: usize,
793    pub style: TextSpanStyle,
794}
795
796impl TextSpan {
797    pub fn new(start: usize, end: usize, style: TextSpanStyle) -> Self {
798        Self { start, end, style }
799    }
800}
801
802/// Alineación horizontal del bloque dentro de su ancho máximo.
803#[derive(Debug, Clone, Copy)]
804pub enum Alignment {
805    Start,
806    Center,
807    End,
808    Justify,
809}
810
811impl From<Alignment> for parley::Alignment {
812    fn from(a: Alignment) -> Self {
813        match a {
814            Alignment::Start => parley::Alignment::Start,
815            Alignment::Center => parley::Alignment::Center,
816            Alignment::End => parley::Alignment::End,
817            Alignment::Justify => parley::Alignment::Justify,
818        }
819    }
820}
821
822/// Especificación de un bloque de texto a rasterizar.
823pub struct TextBlock<'a> {
824    pub text: &'a str,
825    pub size_px: f32,
826    pub color: Color,
827    /// Esquina superior-izquierda del bloque (no el baseline — parley se
828    /// encarga del baseline internamente).
829    pub origin: (f64, f64),
830    pub max_width: Option<f32>,
831    pub alignment: Alignment,
832    /// Múltiplo del font_size (1.0 = compacto, 1.3 = cómodo).
833    pub line_height: f32,
834    /// `true` → fuerza variante italic/oblique en la fuente activa.
835    pub italic: bool,
836    /// CSS-style `font-family` string. `None` = sans-serif default.
837    pub font_family: Option<String>,
838}
839
840impl<'a> TextBlock<'a> {
841    /// Constructor simple para una línea sin wrap.
842    pub fn simple(text: &'a str, size_px: f32, color: Color, origin: (f64, f64)) -> Self {
843        Self {
844            text,
845            size_px,
846            color,
847            origin,
848            max_width: None,
849            alignment: Alignment::Start,
850            line_height: 1.0,
851            italic: false,
852            font_family: None,
853        }
854    }
855}
856
857/// Medidas resultantes de un layout.
858#[derive(Debug, Clone, Copy)]
859pub struct Measurement {
860    pub width: f32,
861    pub height: f32,
862}
863
864/// Construye el layout (shaping + line break + alineación) listo para medir
865/// y/o pintar. Usa esta API cuando necesitas el alto **antes** de elegir el
866/// origen (p. ej. centrado vertical) y no quieres repetir el shaping en el
867/// `draw`: medís sobre el layout retornado y luego lo pasas a
868/// [`draw_layout`].
869pub fn layout_block(ts: &mut Typesetter, block: &TextBlock<'_>) -> parley::Layout<()> {
870    ts.layout(
871        block.text,
872        block.size_px,
873        block.max_width,
874        block.alignment,
875        block.line_height,
876        block.italic,
877        block.font_family.as_deref(),
878        // `TextBlock` no transporta peso (su API queda en normal); el peso de
879        // fuente fluye por el camino del compositor, que llama a `layout`
880        // directamente con el `weight` del `TextSpec`/`TextMeasure`.
881        400.0,
882        // Decoración tampoco viaja por `TextBlock`: la activa el compositor
883        // por nodo según `TextSpec::{underline,strikethrough}`.
884        false,
885        false,
886        // `letter-spacing`/`word-spacing` tampoco viajan por `TextBlock`; el
887        // compositor los pasa por su camino directo (`layout_clamped`).
888        0.0,
889        0.0,
890    )
891}
892
893/// Devuelve las medidas de un layout ya resuelto. Equivalente conceptual a
894/// `(layout.width(), layout.height())` pero envuelto en [`Measurement`].
895pub fn measurement(layout: &parley::Layout<()>) -> Measurement {
896    Measurement {
897        width: layout.width(),
898        height: layout.height(),
899    }
900}
901
902thread_local! {
903    /// Typesetter dedicado a **medir** texto en el hilo de UI. Instala la MISMA
904    /// fuente UI que usa el compositor, así el ancho medido coincide al píxel con
905    /// el glifo dibujado. El costo de cargar la fuente se paga una vez por hilo.
906    /// Origen: el `MEASURE_TS` privado del `text-input`, promovido aquí para que
907    /// cualquier widget mida glifos reales en `update`/`view` sin `&mut Typesetter`.
908    static MEASURE_TS: std::cell::RefCell<Typesetter> =
909        std::cell::RefCell::new(Typesetter::new());
910}
911
912/// Ancho en px de `text` renderizado en **una sola línea** a `size_px`, medido
913/// con la fuente UI real (shaping proporcional, kerning, ligaduras). Es la
914/// alternativa correcta al anti-patrón `chars().count() * constante`, que finge
915/// una fuente monoespaciada sobre una proporcional y desalinea todo cálculo de
916/// ancho (chips, títulos de menú, botones, tooltips…). Reusa un `Typesetter`
917/// thread-local, así se puede llamar desde `update`/`view` sin prestar uno.
918///
919/// `text` vacío → `0.0`. Para medir un **prefijo** por columnas usar
920/// `text.chars().take(n).collect::<String>()` como argumento.
921pub fn text_width(text: &str, size_px: f32) -> f32 {
922    if text.is_empty() {
923        return 0.0;
924    }
925    MEASURE_TS.with(|ts| {
926        let mut ts = ts.borrow_mut();
927        let lp = ts.layout(
928            text,
929            size_px,
930            None,
931            Alignment::Start,
932            1.2,
933            false,
934            None,
935            400.0,
936            false,
937            false,
938            0.0,
939            0.0,
940        );
941        measurement(&lp).width
942    })
943}
944
945/// Pinta un layout ya resuelto en `scene` con `color` y un offset `origin`
946/// (esquina superior-izquierda del bloque). No alloca: los glifos van
947/// directo del iterador de parley al builder de vello.
948pub fn draw_layout(
949    scene: &mut vello::Scene,
950    layout: &parley::Layout<()>,
951    color: Color,
952    origin: (f64, f64),
953) {
954    draw_layout_xf(scene, layout, color, vello::kurbo::Affine::translate(origin));
955}
956
957/// Igual que [`draw_layout`] pero con una **afín completa** en vez de sólo un
958/// desplazamiento: permite pintar texto girado/escalado (p. ej. dentro de un
959/// marco rotado en una presentación espacial). El origen del layout (0,0) es el
960/// que mapea `transform`; las posiciones de glifo se aplican en ese espacio.
961pub fn draw_layout_xf(
962    scene: &mut vello::Scene,
963    layout: &parley::Layout<()>,
964    color: Color,
965    transform: vello::kurbo::Affine,
966) {
967    draw_layout_brush_xf(scene, layout, &Brush::Solid(color), transform);
968}
969
970/// Igual que [`draw_layout_xf`] pero con un [`Brush`] arbitrario en vez de un
971/// color sólido: permite rellenar los glifos con un gradiente o una imagen
972/// (p. ej. CSS `background-clip: text`). El brush se interpreta en el espacio
973/// **local** del layout (origen 0,0) y `transform` lo lleva al lugar final —
974/// así un gradiente construido en coords (0,0)-(w,h) queda alineado con los
975/// glifos. Para texto normal usa [`draw_layout_xf`] (solid = máxima compat).
976pub fn draw_layout_brush_xf(
977    scene: &mut vello::Scene,
978    layout: &parley::Layout<()>,
979    brush: &Brush,
980    transform: vello::kurbo::Affine,
981) {
982    for line in layout.lines() {
983        for item in line.items() {
984            if let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item {
985                let run = glyph_run.run();
986                let font = run.font().clone();
987                let font_size = run.font_size();
988                scene
989                    .draw_glyphs(&font)
990                    .font_size(font_size)
991                    .brush(brush)
992                    .transform(transform)
993                    .draw(
994                        peniko::Fill::NonZero,
995                        glyph_run.positioned_glyphs().map(|g| vello::Glyph {
996                            id: g.id as u32,
997                            x: g.x,
998                            y: g.y,
999                        }),
1000                    );
1001                paint_decoration(scene, &glyph_run, brush, transform);
1002            }
1003        }
1004    }
1005}
1006
1007/// Pinta las decoraciones (`underline`/`strikethrough`) del run si las trae
1008/// del shaping. El offset que devuelve parley sigue la convención OpenType
1009/// (positivo = sobre la línea base en font-space, eje Y arriba); en
1010/// coordenadas de pantalla (Y abajo) el rect va a `baseline - offset`. El
1011/// `transform` es el mismo que se usa para los glifos, así la decoración
1012/// hereda el scroll/rotación/zoom del subárbol.
1013fn paint_decoration<B: parley::Brush>(
1014    scene: &mut vello::Scene,
1015    glyph_run: &parley::GlyphRun<'_, B>,
1016    brush: &Brush,
1017    transform: vello::kurbo::Affine,
1018) {
1019    let style = glyph_run.style();
1020    let run = glyph_run.run();
1021    let metrics = run.metrics();
1022    let x = glyph_run.offset() as f64;
1023    let baseline = glyph_run.baseline() as f64;
1024    let advance = glyph_run.advance() as f64;
1025    if let Some(dec) = &style.underline {
1026        let offset = dec.offset.unwrap_or(metrics.underline_offset) as f64;
1027        let size = dec.size.unwrap_or(metrics.underline_size) as f64;
1028        let y0 = baseline - offset;
1029        let rect = vello::kurbo::Rect::new(x, y0, x + advance, y0 + size);
1030        scene.fill(peniko::Fill::NonZero, transform, brush, None, &rect);
1031    }
1032    if let Some(dec) = &style.strikethrough {
1033        let offset = dec.offset.unwrap_or(metrics.strikethrough_offset) as f64;
1034        let size = dec.size.unwrap_or(metrics.strikethrough_size) as f64;
1035        let y0 = baseline - offset;
1036        let rect = vello::kurbo::Rect::new(x, y0, x + advance, y0 + size);
1037        scene.fill(peniko::Fill::NonZero, transform, brush, None, &rect);
1038    }
1039}
1040
1041/// Pinta un layout **multicolor** ([`Typesetter::layout_runs`]): cada
1042/// `glyph_run` usa el color de su propio brush ([`RunBrush`]) en vez de un
1043/// color uniforme. `origin` es la esquina superior-izquierda del bloque.
1044pub fn draw_layout_runs(
1045    scene: &mut vello::Scene,
1046    layout: &parley::Layout<RunBrush>,
1047    origin: (f64, f64),
1048) {
1049    draw_layout_runs_xf(scene, layout, vello::kurbo::Affine::translate(origin));
1050}
1051
1052/// Igual que [`draw_layout_runs`] pero con una **afín completa** en vez de sólo
1053/// un desplazamiento — el equivalente multicolor de [`draw_layout_xf`]. Lo
1054/// necesita el compositor para que el texto multicolor herede la
1055/// transformación acumulada del subárbol (scroll/rotación del padre): sin esto,
1056/// el texto con `runs` se pintaba en coords de layout crudas, **ignorando** el
1057/// transform, y se desalineaba del resto (p. ej. el cuerpo coloreado del shell
1058/// no seguía el scroll del panel). El origen del layout (0,0) lo mapea
1059/// `transform`; las posiciones de glifo se aplican en ese espacio.
1060pub fn draw_layout_runs_xf(
1061    scene: &mut vello::Scene,
1062    layout: &parley::Layout<RunBrush>,
1063    transform: vello::kurbo::Affine,
1064) {
1065    for line in layout.lines() {
1066        for item in line.items() {
1067            if let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item {
1068                let brush = Brush::Solid(glyph_run.style().brush.0);
1069                let run = glyph_run.run();
1070                let font = run.font().clone();
1071                let font_size = run.font_size();
1072                scene
1073                    .draw_glyphs(&font)
1074                    .font_size(font_size)
1075                    .brush(&brush)
1076                    .transform(transform)
1077                    .draw(
1078                        peniko::Fill::NonZero,
1079                        glyph_run.positioned_glyphs().map(|g| vello::Glyph {
1080                            id: g.id as u32,
1081                            x: g.x,
1082                            y: g.y,
1083                        }),
1084                    );
1085                paint_decoration(scene, &glyph_run, &brush, transform);
1086            }
1087        }
1088    }
1089}
1090
1091/// Mide sin pintar. Atajo de [`layout_block`] + [`measurement`] para
1092/// llamadores que sólo necesitan el bounding box.
1093pub fn measure(ts: &mut Typesetter, block: &TextBlock<'_>) -> Measurement {
1094    measurement(&layout_block(ts, block))
1095}
1096
1097/// Rasteriza el bloque en `scene` haciendo shaping una sola vez. Equivale a
1098/// `layout_block` + `draw_layout` con `block.origin`.
1099pub fn draw_block(scene: &mut vello::Scene, ts: &mut Typesetter, block: &TextBlock<'_>) {
1100    let layout = layout_block(ts, block);
1101    draw_layout(scene, &layout, block.color, block.origin);
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106    use super::*;
1107
1108    /// Texto que envuelve a muchas líneas en un ancho angosto.
1109    const LARGO: &str =
1110        "palabras varias que envuelven en bastantes renglones cuando el ancho \
1111         disponible es realmente angosto y no caben de un solo tirón";
1112
1113    fn n_lineas(ts: &mut Typesetter, max_lines: Option<usize>, ellipsis: bool) -> usize {
1114        ts.layout_clamped(
1115            LARGO,
1116            14.0,
1117            Some(120.0),
1118            Alignment::Start,
1119            1.2,
1120            false,
1121            None,
1122            400.0,
1123            max_lines,
1124            ellipsis,
1125            false,
1126            false,
1127            0.0,
1128            0.0,
1129            false,
1130            "",
1131            "",
1132            100.0,
1133        )
1134        .lines()
1135        .count()
1136    }
1137
1138    #[test]
1139    fn text_width_es_proporcional_no_monoespaciado() {
1140        // El anti-patrón `chars*const` daría igual ancho a "iii" y "WWW"
1141        // (3 chars c/u). La medición real con fuente proporcional debe
1142        // distinguirlos: 'W' es mucho más ancho que 'i'.
1143        let angosto = text_width("iii", 13.0);
1144        let ancho = text_width("WWW", 13.0);
1145        assert!(angosto > 0.0 && ancho > 0.0);
1146        assert!(
1147            ancho > angosto * 1.5,
1148            "WWW ({ancho}) debe ser bastante más ancho que iii ({angosto})"
1149        );
1150    }
1151
1152    #[test]
1153    fn text_width_vacio_es_cero_y_crece_monotono() {
1154        assert_eq!(text_width("", 13.0), 0.0);
1155        let a = text_width("a", 13.0);
1156        let ab = text_width("ab", 13.0);
1157        let abc = text_width("abc", 13.0);
1158        assert!(a > 0.0 && ab > a && abc > ab, "el ancho crece al agregar chars");
1159    }
1160
1161    #[test]
1162    fn clamp_limita_el_numero_de_lineas() {
1163        let mut ts = Typesetter::new();
1164        let libre = n_lineas(&mut ts, None, false);
1165        assert!(libre > 2, "el fixture debe envolver a >2 líneas (dio {libre})");
1166        // Con clamp, nunca más que el límite — con o sin ellipsis.
1167        assert_eq!(n_lineas(&mut ts, Some(1), false), 1);
1168        assert_eq!(n_lineas(&mut ts, Some(1), true), 1);
1169        assert!(n_lineas(&mut ts, Some(2), true) <= 2);
1170        // max_lines None ⇒ sin límite (idéntico a layout).
1171        assert_eq!(n_lineas(&mut ts, None, true), libre);
1172    }
1173
1174    #[test]
1175    fn letter_y_word_spacing_ensanchan_la_medida() {
1176        // letter-spacing y word-spacing agregan px al ancho del shaping; 0 es
1177        // el baseline (normal). Prueba directa del feature (Fase 7.1252).
1178        let mut ts = Typesetter::new();
1179        let w = |ts: &mut Typesetter, ls: f32, ws: f32| {
1180            measurement(&ts.layout(
1181                "hola mundo cruel", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false,
1182                false, ls, ws,
1183            ))
1184            .width
1185        };
1186        let base = w(&mut ts, 0.0, 0.0);
1187        let con_letter = w(&mut ts, 4.0, 0.0);
1188        let con_word = w(&mut ts, 0.0, 10.0);
1189        assert!(con_letter > base, "letter-spacing ensancha ({con_letter} > {base})");
1190        assert!(con_word > base, "word-spacing ensancha ({con_word} > {base})");
1191    }
1192
1193    #[test]
1194    fn clamp_no_trunca_si_ya_cabe() {
1195        let mut ts = Typesetter::new();
1196        // "Hola" cabe en una línea: pedir 3 no debe inventar truncado.
1197        let lay = ts.layout_clamped(
1198            "Hola", 14.0, Some(200.0), Alignment::Start, 1.2, false, None, 400.0, Some(3), true,
1199            false, false, 0.0, 0.0, false, "", "", 100.0,
1200        );
1201        assert_eq!(lay.lines().count(), 1);
1202    }
1203
1204    /// El caché no debe cambiar el resultado: misma medida con o sin hit, y la
1205    /// segunda llamada idéntica tiene que pegar en el caché (hit), no re-shapear.
1206    #[test]
1207    fn cache_es_transparente_y_pega() {
1208        let mut ts = Typesetter::new();
1209        let m1 = {
1210            let l = ts.layout(LARGO, 14.0, Some(120.0), Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
1211            (l.width(), l.height(), l.lines().count())
1212        };
1213        let s1 = ts.cache_stats();
1214        assert_eq!(s1.misses, 1, "primera vez = miss");
1215        assert_eq!(s1.hits, 0);
1216        // Misma llamada exacta: debe ser hit y dar la misma geometría.
1217        let m2 = {
1218            let l = ts.layout(LARGO, 14.0, Some(120.0), Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
1219            (l.width(), l.height(), l.lines().count())
1220        };
1221        let s2 = ts.cache_stats();
1222        assert_eq!(s2.hits, 1, "segunda vez idéntica = hit");
1223        assert_eq!(s2.misses, 1, "no hubo nuevo miss");
1224        assert_eq!(m1, m2, "el layout cacheado es idéntico al fresco");
1225        // Cambiar un parámetro (ancho) es una clave distinta: miss nuevo.
1226        let _ = ts.layout(LARGO, 14.0, Some(80.0), Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
1227        assert_eq!(ts.cache_stats().misses, 2, "otro ancho = otra clave");
1228    }
1229
1230    /// `font_context_mut` invalida el caché (cambiar fuentes puede alterar el
1231    /// shaping): la siguiente llamada idéntica vuelve a ser miss.
1232    #[test]
1233    fn font_context_mut_invalida_el_cache() {
1234        let mut ts = Typesetter::new();
1235        let _ = ts.layout("hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
1236        assert_eq!(ts.cache_stats().entries, 1);
1237        let _ = ts.font_context_mut();
1238        assert_eq!(ts.cache_stats().entries, 0, "el caché quedó vacío");
1239        let _ = ts.layout("hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
1240        assert_eq!(ts.cache_stats().misses, 2, "post-invalidación = miss");
1241    }
1242
1243    /// Decoración (underline / strikethrough): el flag de entrada debe
1244    /// llegar al `parley::Layout` como `style.underline`/`style.strikethrough`
1245    /// presentes en cada run, y el caché debe distinguir su clave (mismo
1246    /// texto con vs sin decoración = entradas separadas).
1247    #[test]
1248    fn underline_y_strikethrough_se_propagan_al_layout() {
1249        let mut ts = Typesetter::new();
1250        let with_dec = ts.layout(
1251            "Hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, true, true, 0.0, 0.0,
1252        );
1253        // Caminamos los runs del layout y verificamos que cada GlyphRun trae
1254        // ambas decoraciones marcadas (no usamos `is_some` directo porque
1255        // `Layout::lines/items` exige iterar para llegar al Style).
1256        let mut visto_u = false;
1257        let mut visto_s = false;
1258        for line in with_dec.lines() {
1259            for item in line.items() {
1260                if let parley::PositionedLayoutItem::GlyphRun(gr) = item {
1261                    if gr.style().underline.is_some() {
1262                        visto_u = true;
1263                    }
1264                    if gr.style().strikethrough.is_some() {
1265                        visto_s = true;
1266                    }
1267                }
1268            }
1269        }
1270        assert!(visto_u, "underline=true ⇒ Decoration en al menos un run");
1271        assert!(visto_s, "strikethrough=true ⇒ Decoration en al menos un run");
1272
1273        // Sin decoración el layout no las trae.
1274        let plain = ts.layout(
1275            "Hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0,
1276        );
1277        for line in plain.lines() {
1278            for item in line.items() {
1279                if let parley::PositionedLayoutItem::GlyphRun(gr) = item {
1280                    assert!(gr.style().underline.is_none(), "sin underline=true ⇒ None");
1281                    assert!(gr.style().strikethrough.is_none(), "sin strikethrough=true ⇒ None");
1282                }
1283            }
1284        }
1285
1286        // Caché: dos misses (uno por cada variante), no se pisan.
1287        let s = ts.cache_stats();
1288        assert!(s.misses >= 2, "claves distintas por decoración ⇒ misses separados");
1289    }
1290
1291    /// Mecánica generacional: al pasar `cap`, `hot` rota a `cold`; un ítem
1292    /// reaccedido se promueve y sobrevive a la siguiente rotación.
1293    #[test]
1294    fn cache_generacional_promueve_y_rota() {
1295        let mut c = ShapeCache::new(2);
1296        let mk = |s: &str| ShapeKey {
1297            text: s.to_string(),
1298            size_bits: 0,
1299            max_width_bits: None,
1300            align: 0,
1301            line_height_bits: 0,
1302            italic: false,
1303            font_family: None,
1304            weight_bits: 0,
1305            underline: false,
1306            strikethrough: false,
1307            letter_bits: 0,
1308            word_bits: 0,
1309            overflow_wrap: false,
1310            features: String::new(),
1311            variations: String::new(),
1312            font_width_bits: 100.0_f32.to_bits(),
1313        };
1314        // Layouts vacíos como valores (sólo nos importa la presencia de claves).
1315        let dummy = parley::Layout::<()>::default;
1316        c.put(mk("a"), dummy());
1317        c.put(mk("b"), dummy());
1318        // "a" sigue caliente; lo accedemos para que se quede al rotar.
1319        assert!(c.get(&mk("a")).is_some());
1320        // Tercer insert: hot llegó a cap(2) → rota (a,b→cold), c entra a hot.
1321        c.put(mk("c"), dummy());
1322        // "a" estaba en cold; get lo encuentra y lo promueve a hot.
1323        assert!(c.get(&mk("a")).is_some(), "ítem reaccedido sobrevive la rotación");
1324        // "b" no se reaccedió: cae en la siguiente rotación.
1325        c.put(mk("d"), dummy()); // hot = {c, a-promovido}? -> al llegar a cap rota
1326        // Tras suficientes rotaciones sin tocar "b", desaparece.
1327        c.put(mk("e"), dummy());
1328        c.put(mk("f"), dummy());
1329        assert!(c.get(&mk("b")).is_none(), "ítem nunca reaccedido se libera");
1330    }
1331}