Skip to main content

llimphi_text/
lib.rs

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