//! llimphi-text — Texto sobre vello vía parley. //! //! parley hace shaping completo (bidi, ligatures, kerning), line break y //! alineación; fontique resuelve fuentes del sistema con fallback CJK/emoji. //! Aquí lo envolvemos en una API mínima centrada en el caso común: un //! bloque de texto con color uniforme, ancho máximo opcional y alineación. //! //! ## Los dos perfiles (W2 de `03_ukupacha/wawa/PLAN-LLIMPHI.md`) //! //! El default es el de siempre: `std` + dibujo con vello + fuentes del sistema. //! El perfil bare-metal (`--no-default-features --features libm`) deja fuera a //! `vello` —que es std-only— y a `fontique/system`, y se queda con el shaping y //! las tres fuentes que este crate ya embebe y registra a mano. Es lo que //! permite que el kernel de wawa shapee texto, que es lo que W4.1 midió como //! condición para que el IR de escena se sostenga. // `no_std` cuando se pide el perfil bare-metal. Bajo `cargo test` y con `std` // —el default— se usa la stdlib sin cambios. #![cfg_attr(not(any(feature = "std", test)), no_std)] extern crate alloc; // Los cuatro son del camino de shaping (el `Typesetter` de parley y el caché de // layouts). Bajo `wire` ese camino no se compila —la app no shapea— así que los // imports quedarían colgados. #[cfg(feature = "shaping")] use alloc::borrow::Cow; // `Box` lo trae el preludio de std pero NO el de core: sin este import el crate // compila en el host y se cae recién en bare-metal, que es donde nadie mira. #[cfg(feature = "shaping")] use alloc::boxed::Box; #[cfg(feature = "shaping")] use alloc::format; use alloc::string::String; // `to_string` sólo lo llama el camino de shaping. #[cfg(feature = "shaping")] use alloc::string::ToString; #[cfg(feature = "shaping")] use alloc::vec::Vec; use peniko::Color; // `Brush` sólo lo tocan las funciones que pintan — que existen con CUALQUIERA de // los dos backends. Sin ninguno (perfil «sólo medir») el import quedaría colgado. #[cfg(any(feature = "vello", feature = "cpu"))] use peniko::Brush; #[cfg(feature = "shaping")] pub use parley; pub use peniko; #[cfg(feature = "vello")] pub use vello; // ============================================================================= // El lienzo :: quién recibe los glifos (W3.b de PLAN-LLIMPHI.md) // ----------------------------------------------------------------------------- // Las funciones de dibujo de este crate no nombran a `vello::Scene` sino a // `Lienzo`, que es un alias conmutado en compilación. Los dos tipos exponen los // MISMOS métodos con las MISMAS firmas —`llimphi_cpu::Escena` se diseñó // espejando a `vello::Scene`, no envolviéndola, justamente para esto—, así que // quien llama no cambia una línea según el backend. Es lo que permite que // `llimphi-compositor` cruce a wawa sin tocar sus 4.000 líneas de `render.rs`. // ============================================================================= /// El lienzo de destino: `vello::Scene` en Linux, `llimphi_cpu::Escena` en wawa. #[cfg(all(feature = "vello", not(feature = "cpu")))] pub type Lienzo = vello::Scene; /// El lienzo de destino: `llimphi_cpu::Escena` — el camino sin GPU. #[cfg(all(feature = "cpu", not(feature = "vello"), not(feature = "wire")))] pub type Lienzo = llimphi_cpu::Escena; /// El lienzo de destino: la escena SERIALIZABLE — el camino de W4. /// /// El tercer backend no rasteriza nada: graba las órdenes para que las ejecute /// otro proceso del otro lado de la frontera WASM. Es el que usa una app /// enjaulada, y el que hace que `render.rs` sirva sin cambios para los tres. /// /// Trae consigo una diferencia que los otros dos no tienen: **acá tampoco se /// shapea**. Ver `crate::wire` para el porqué (es la decisión que W4.1 midió) y /// para cómo se resuelve el problema que eso abre — que taffy necesita medir el /// texto para dimensionarlo. #[cfg(feature = "wire")] pub type Lienzo = llimphi_wire_escena::EscenaWire; /// El encoder: el lado app del cable. Ver el encabezado del módulo. #[cfg(feature = "wire")] pub mod wire; #[cfg(feature = "wire")] pub use wire::{ draw_block, draw_layout, draw_layout_brush_xf, draw_layout_runs, draw_layout_runs_xf, draw_layout_xf, layout_block, measure, measurement, text_width, text_width_with, Medida, Peticion, TextoMedido, Typesetter, }; // El tercer backend es tan excluyente como los otros dos, y por el mismo motivo: // `Lienzo` no puede ser dos tipos. Se avisa aparte de la guarda vello/cpu porque // el remedio es distinto — `wire` no viene en ningún default, así que si está // encendida es porque alguien la pidió, probablemente sin apagar el default. #[cfg(all(feature = "wire", any(feature = "vello", feature = "cpu")))] compile_error!( "llimphi-text: `wire` es un backend EXCLUYENTE y convive con `vello` o `cpu`. \ El camino del IR de escena se pide con \ `--no-default-features --features wire` (más `std` o `libm` según el target)." ); // Los dos backends a la vez no se pueden: comparten nombres de función y el // alias tendría que ser dos tipos. Un `compile_error!` acá convierte lo que si // no sería un choque de nombres ilegible —o peor, un backend elegido en // silencio por unificación de features— en un mensaje que dice qué hacer. // // En la práctica no se cruzan: el workspace de Linux enciende `vello` por // default y nadie pide `cpu`; el kernel de wawa vive en un workspace APARTE // (`03_ukupacha/wawa` está excluido) y compila con // `--no-default-features --features libm,cpu`. Los dos lock files son distintos, // así que no hay unificación que los junte. #[cfg(all(feature = "vello", feature = "cpu"))] compile_error!( "llimphi-text: las features `vello` y `cpu` son backends EXCLUYENTES y están \ las dos encendidas. `vello` viene en el `default`, así que para el camino \ CPU hay que pedir `--no-default-features --features cpu` (más `std` o \ `libm` según el target)." ); // Sin ningún backend el crate sigue siendo útil —shapear y medir no necesitan // lienzo—, así que esto NO es un error: simplemente no existen las funciones de // dibujo. Es el perfil que usa quien sólo mide texto. /// Estado compartido del motor de texto. Una instancia por proceso es lo /// recomendado: `FontContext` cachea la base de fuentes y `LayoutContext` /// reutiliza allocaciones entre layouts. #[cfg(feature = "shaping")] pub struct Typesetter { font_cx: parley::FontContext, /// La stack a usar cuando un bloque pide [`MONOSPACE`]. Ver /// [`Typesetter::stack_monospace`]: lleva la Nerd Font del sistema detrás /// de la mono embebida, porque los glifos de zona privada (powerline, /// iconos) no los cubre ninguna fuente estándar. mono_stack: Box, layout_cx: parley::LayoutContext<()>, /// Contexto separado para layouts multicolor (`Brush` por rango). El /// brush genérico de parley no puede ser `()` y `RunBrush` a la vez en /// el mismo `LayoutContext`, así que mantenemos uno por sabor. runs_cx: parley::LayoutContext, /// Caché de shaping: `[`Self::layout`]` es el único chokepoint por el que /// pasan medición y pintado (vía `layout_clamped`), y se invoca por cada /// nodo de texto en **cada** redraw — dos veces (medir + pintar). Shapear /// con parley (font matching, bidi, clusters, line break) es lo caro; el /// `parley::Layout` resultante es `Clone`. Cacheamos por los parámetros /// que lo determinan y clonamos en el hit: durante scroll/tipeo, el texto /// que no cambió no se re-shapea. cache: ShapeCache, cache_hits: u64, cache_misses: u64, } /// Estadísticas del caché de shaping (evidencia/benchmark). `entries` es el /// total vivo entre las dos generaciones. #[derive(Debug, Clone, Copy, Default)] pub struct CacheStats { pub hits: u64, pub misses: u64, pub entries: usize, } /// Clave de caché: todos los parámetros que determinan un `layout`. Los `f32` /// van por `to_bits` para ser `Hash + Eq` exactos (sin problemas de NaN/−0.0: /// comparamos los bits crudos, no el valor numérico). `Alignment` se mapea a /// un tag `u8` porque su enum no deriva `Hash`. #[derive(Clone, PartialEq, Eq, Hash)] #[cfg(feature = "shaping")] struct ShapeKey { text: String, size_bits: u32, max_width_bits: Option, align: u8, line_height_bits: u32, italic: bool, font_family: Option, weight_bits: u32, /// Underline activo. parley emite `Decoration` por run cuando este flag /// está, así que el layout difiere y el caché tiene que separarlos. underline: bool, /// Strikethrough activo. Idem `underline`. strikethrough: bool, /// `letter-spacing` (px extra entre letras). 0 = sin override. Cambia el /// shaping/ancho, así que entra en la clave. letter_bits: u32, /// `word-spacing` (px extra entre palabras). Idem `letter_bits`. word_bits: u32, /// `overflow-wrap: break-word`/`anywhere`: si está, parley puede partir /// dentro de una palabra para que entre en la caja. Cambia el line-break, /// así que separa la entrada del caché. overflow_wrap: bool, /// `font-feature-settings` (+ `font-kerning` plegado como `kern`) en formato /// CSS crudo (`"liga" 1, "kern" 0`). Vacío = default de la fuente. Cambia el /// shaping (glifos/ligaduras), así que entra en la clave del caché. features: String, /// `font-variation-settings` en formato CSS crudo (`"wght" 700, "wdth" 85`). /// Vacío = ejes en su default. Cambia el shaping (instancia de la fuente /// variable), así que entra en la clave. variations: String, /// `font-stretch`/`font-width` como **porcentaje** (100 = normal, <100 /// condensada, >100 expandida). Selecciona la face condensada/expandida (o /// el eje `wdth` de una variable) ⇒ cambia el shaping, entra en la clave. font_width_bits: u32, } #[cfg(feature = "shaping")] fn align_tag(a: Alignment) -> u8 { match a { Alignment::Start => 0, Alignment::Center => 1, Alignment::End => 2, Alignment::Justify => 3, } } /// Caché generacional (LRU aproximado, sin dependencias). Dos mapas: `hot` /// recibe inserciones y promociones; cuando `hot` llega a `cap`, rota /// (`cold = hot`, `hot = ∅`) y la generación vieja se descarta. Un hit en /// `cold` se promueve a `hot`, así lo accedido en la última época sobrevive a /// la rotación — el texto visible, re-consultado cada frame, queda siempre /// caliente; lo transitorio (candidatos de elipsis, tooltips) cae solo. Es el /// patrón de los cachés de glyph/shape de swash/cosmic-text: O(1), sin orden /// enlazado. #[cfg(feature = "shaping")] struct ShapeCache { hot: hashbrown::HashMap>, cold: hashbrown::HashMap>, cap: usize, } #[cfg(feature = "shaping")] impl ShapeCache { fn new(cap: usize) -> Self { Self { hot: hashbrown::HashMap::new(), cold: hashbrown::HashMap::new(), cap, } } /// Devuelve un clon del layout cacheado si existe, promoviendo desde /// `cold` a `hot` en el camino. fn get(&mut self, key: &ShapeKey) -> Option> { if let Some(v) = self.hot.get(key) { return Some(v.clone()); } // Hit frío: sácalo de cold y reinsertalo en hot (promoción). Una sola // clonación: el clon queda en hot, el original se devuelve al caller. if let Some(v) = self.cold.remove(key) { self.hot.insert(key.clone(), v.clone()); return Some(v); } None } fn put(&mut self, key: ShapeKey, layout: parley::Layout<()>) { if self.hot.len() >= self.cap { // Rota la generación: lo no reaccedido desde la última rotación // (quedó sólo en cold) se libera aquí. self.cold = core::mem::take(&mut self.hot); } self.hot.insert(key, layout); } fn clear(&mut self) { self.hot.clear(); self.cold.clear(); } fn entries(&self) -> usize { self.hot.len() + self.cold.len() } } /// Capacidad de la generación caliente antes de rotar. 512 layouts cubre con /// holgura el texto visible de una UI densa (un editor de ~50 líneas + chrome) /// sin retener de más. La memoria real es ~2× (dos generaciones). #[cfg(feature = "shaping")] const SHAPE_CACHE_CAP: usize = 512; #[cfg(feature = "shaping")] #[cfg(feature = "shaping")] mod emoji; /// Rangos de bytes de `text` que Unicode manda pintar **como emoji**. /// /// El motivo, medido antes de escribir una línea (§8): con `TextBlock::simple` /// a 32 px sobre fondo negro, `😀` resolvía a **DejaVu Sans del sistema** y /// `❤️` a **Inter** — cero píxeles cromáticos en los cuatro casos probados. O /// sea que ninguna app de la suite pintaba un emoji a color, y no porque el /// rasterizador no supiera: vello 0.9 dibuja COLR y CBDT solo, y la /// `Noto Color Emoji` del sistema la resuelve fontconfig con el alias `emoji`. /// /// **La causa es el ORDEN de la pila de fuentes.** parley detecta el cluster /// emoji y agrega la familia genérica `Emoji`, pero la agrega **al final** /// (`parley-0.6.0/src/shape/mod.rs:487`), detrás de la pila normal. Cualquier /// fuente de texto que cubra el codepoint gana: DejaVu Sans tiene contornos /// monocromos para el bloque Emoticons, e Inter tiene `❤` como dingbat. La /// carita salía, en blanco y negro, y nadie podía notar que no era la que /// correspondía. /// /// El arreglo es empujar la familia `Emoji` **como estilo por rango** sobre /// los clusters de presentación emoji, que en parley pisa la pila del bloque. /// La clasificación no se inventa acá: sale del **mismo** analizador que usa /// parley para decidir `is_emoji` (`swash::text::cluster`), así que no puede /// divergir de lo que el shaper hace después. /// /// **Pero la clasificación de swash sola no alcanza, y esto se midió antes de /// creerlo:** agrupa por Extended_Pictographic, que marca `⚠ ▶ ✔ © ‼ ☀ ⤴`. /// Mandarlos a la fuente de emoji convertiría los símbolos del chrome de la UI /// en caritas de colores — una regresión visible, y en la dirección contraria /// a la que este arreglo viene a corregir. Por eso el modo `Default` se cruza /// con la tabla de `Emoji_Presentation` de [`emoji`]: sale a color de fábrica /// sólo lo que Unicode dice. `Color` (VS16, `U+FE0F`) pasa siempre —ahí el /// pedido es explícito— y `Text` (VS15, `U+FE0E`) queda afuera por lo mismo, /// al revés. /// /// Rangos contiguos se funden: un `👩‍👩‍👧` es un cluster, pero dos emoji /// seguidos son dos, y empujar dos estilos pegados es trabajo de más. #[cfg(feature = "shaping")] fn rangos_emoji(text: &str) -> Vec> { use parley::swash::text::cluster::{Boundary, CharCluster, CharInfo, Emoji, Parser, Token}; use parley::swash::text::{Codepoint, Script}; let mut parser = Parser::new( // El script sólo elige el agrupador de clusters complejos (índicas, // birmano). Ningún emoji vive en un script complejo, así que el simple // los agrupa bien; y un cluster mal agrupado de otro script no puede // inventar un emoji, que es lo único que se lee de acá. Script::Latin, text.char_indices().map(|(i, ch)| Token { ch, offset: i as u32, len: ch.len_utf8() as u8, info: CharInfo::new(ch.properties(), Boundary::None), data: 0, }), ); let mut cluster = CharCluster::new(); let mut rangos: Vec> = Vec::new(); while parser.next(&mut cluster) { let r = cluster.range(); let color = match cluster.info().emoji() { Emoji::Color => true, // Se mira el cluster ENTERO, no su primer carácter: una bandera // son dos indicadores regionales y ninguno de los dos es «la base». // `get` y no `[..]`: el rango sale del parser y debería caer en // borde de carácter siempre, pero esto lo corre CADA layout de // CADA app — un panic acá se lleva puesta la suite entera, y un // emoji monocromo no. Emoji::Default => text .get(r.start as usize..r.end as usize) .is_some_and(|s| s.chars().any(emoji::presentacion_emoji)), Emoji::None | Emoji::Text => false, }; if !color { continue; } let (ini, fin) = (r.start as usize, r.end as usize); match rangos.last_mut() { Some(ultimo) if ultimo.end == ini => ultimo.end = fin, _ => rangos.push(ini..fin), } } rangos } /// Empuja la familia genérica `Emoji` sobre los rangos de [`rangos_emoji`]. /// /// Va **último** en cada armado de layout, después de los estilos por rango /// del caller: un emoji adentro de un tramo al que la app le pidió una familia /// propia sigue teniendo que salir a color. Si la máquina no tiene fuente de /// emoji (el kernel de wawa, un contenedor pelado), la familia genérica no /// resuelve a nada y parley cae al fallback de siempre — o sea, exactamente lo /// que se veía antes de este cambio. #[cfg(feature = "shaping")] fn empujar_familia_emoji( builder: &mut parley::RangedBuilder<'_, B>, text: &str, ) { for r in rangos_emoji(text) { builder.push( parley::StyleProperty::FontStack(parley::FontStack::Single( parley::FontFamily::Generic(parley::GenericFamily::Emoji), )), r, ); } } // El gate va acá y no se hereda de nadie: sin él, un build `wire` (que NO // shapea) compila este `impl` contra el `Typesetter` de `wire.rs` —que ya trae // el suyo— y el crate no enlaza. Es la única línea de la vecindad que se quedó // sin `cfg`, y por eso el camino de la jaula estuvo roto sin que main lo viera. #[cfg(feature = "shaping")] impl Default for Typesetter { fn default() -> Self { Self::new() } } /// DejaVu Sans embebida como **fallback universal de símbolos**. El motor /// confía en las fuentes del sistema vía fontique, pero muchas instalaciones /// (p. ej. solo Liberation/Adwaita) carecen de glyphs para flechas (`→`), /// formas geométricas (`● ▶`), dingbats (`✓ ✗ ✎`), avisos (`⚠`) o astro /// (`♈ ☉ ☽`) — y entonces parley pinta el "tofu" (□). DejaVu cubre todo ese /// rango; la registramos y la enganchamos al fallback del script `Common` /// (`Zyyy`), que es donde Unicode clasifica esos símbolos. Así cualquier app /// Llimphi deja de mostrar cuadrados sin tocar una línea de su código. /// Licencia: Bitstream Vera + Arev (libre, redistribuible). #[cfg(feature = "shaping")] const DEJAVU_SANS: &[u8] = include_bytes!("../assets/DejaVuSans.ttf"); /// **Inter** embebida como **fuente de UI por defecto** (SIL OFL 1.1, libre y /// redistribuible — ver `assets/Inter-LICENSE.txt`). Inter es una grotesca /// neo-humanista diseñada específicamente para interfaces a tamaños chicos: /// caja alta de la x, aperturas amplias y espaciado parejo. Es el look 2026 /// que queremos de fábrica, sin depender de que el sistema tenga una sans /// linda instalada (en una instalación pelada el default de fontique podía /// caer en Liberation/Adwaita, que envejecen mal). La enganchamos como /// primera familia del genérico `sans-serif` (ver [`Typesetter::install_ui_font`]), /// que es lo que parley resuelve cuando el bloque no pide `font_family`. El /// fallback por-script sigue intacto: símbolos via DejaVu, CJK/árabe/etc. via /// las fuentes del sistema. const INTER_SANS: &[u8] = include_bytes!("../assets/Inter-Regular.ttf"); /// Fuente monoespaciada embebida (Liberation Mono, SIL OFL — metric- /// compatible con Courier). Va embebida para que *cualquier* app Llimphi /// pueda pedir ancho fijo (output de terminal, IDE-text, tablas que /// columnean) sin depender de que el sistema tenga una mono instalada. /// Se referencia por su nombre de familia con [`MONOSPACE`]. #[cfg(feature = "shaping")] const LIBERATION_MONO: &[u8] = include_bytes!("../assets/LiberationMono.ttf"); /// Bytes de la **variante NEGRITA** de la monospace embebida. /// /// Va embebida por la misma razón que la regular: sin ella, pedir `weight: 700` /// sobre [`MONOSPACE`] **no cambia un píxel**, porque fontique resuelve la única /// cara que hay. Medido el 2026-10-05 con `--example hay_negrita`: «Liberation /// Mono» devolvía la MISMA cara para 400 y para 700, mientras que la `monospace` /// genérica del sistema devolvía dos distintas. /// /// El síntoma afuera era que **ninguna terminal de la suite podía mostrar /// negrita** — el texto de claude salía plano, y el camino entero (celda del /// vt100 → `StyledSpan` → `TextSpanStyle` → `parley::FontWeight`) estaba bien. /// /// Y embebida, no del sistema: en wawa bare-metal **sólo existen las embebidas**, /// así que una negrita que dependiera de fontconfig no estaría ahí. #[cfg(feature = "shaping")] const LIBERATION_MONO_BOLD: &[u8] = include_bytes!("../assets/LiberationMono-Bold.ttf"); /// Bytes de la fuente **monospace embebida** (Liberation Mono TTF). Pública /// para que otros crates (p. ej. `llimphi-widget-terminal`, que necesita /// rasterizar glifos para su atlas GPU) usen exactamente la misma fuente /// que el render normal, sin volver a embeber el archivo. #[cfg(feature = "shaping")] pub const MONO_FONT_BYTES: &[u8] = LIBERATION_MONO; /// Bytes de la fuente **sans de UI embebida** (Inter Regular TTF). Pública para /// que otros crates rastericen texto proporcional con la misma fuente que el /// render normal (p. ej. `tullpu` para sus capas de texto), sin re-embeber el /// archivo. Referenciada por familia con [`UI_SANS`]. pub const SANS_FONT_BYTES: &[u8] = INTER_SANS; /// Nombre de familia de la fuente monoespaciada embebida. Pasalo como /// `font_family: Some(llimphi_text::MONOSPACE)` en un [`TextBlock`] (o el /// `font_family` de `layout`) para render de ancho fijo garantizado. pub const MONOSPACE: &str = "Liberation Mono"; /// Nombre de familia de la fuente de UI embebida ([Inter](https://rsms.me/inter/)). /// Es el default proporcional cuando un bloque **no** especifica `font_family` /// (la enganchamos como primera familia del genérico `sans-serif`). Exponemos /// el nombre por si un caller quiere pedirla explícitamente. pub const UI_SANS: &str = "Inter"; #[cfg(feature = "shaping")] impl Typesetter { pub fn new() -> Self { let mut font_cx = parley::FontContext::new(); Self::install_ui_font(&mut font_cx); Self::install_symbol_fallback(&mut font_cx); Self::install_monospace(&mut font_cx); let mono_stack = Self::stack_monospace(&mut font_cx); Self { font_cx, mono_stack, layout_cx: parley::LayoutContext::new(), runs_cx: parley::LayoutContext::new(), cache: ShapeCache::new(SHAPE_CACHE_CAP), cache_hits: 0, cache_misses: 0, } } /// Registra **Inter** y la pone como **primera familia del genérico /// `sans-serif`**. Ese genérico es lo que parley resuelve cuando un bloque /// no especifica `font_family` (su default es `FontStack::Source("sans-serif")`), /// así que con esto toda app Llimphi tipografía en Inter de fábrica sin /// tocar una línea de su código, y sin depender de la sans del sistema. /// Usamos `append_*` (no `set_*`) para no borrar las familias que el SO ya /// asociaba al genérico: Inter va primero, el resto queda detrás como /// respaldo. La cobertura de scripts no-latinos / símbolos sigue saliendo /// del fallback por-script (CJK del sistema, símbolos de DejaVu). Si una /// app pide otra familia explícita, gana esa. Best-effort: si el registro /// falla, el texto sigue con la sans del sistema. fn install_ui_font(font_cx: &mut parley::FontContext) { use parley::fontique::{Blob, GenericFamily}; let blob = Blob::new(alloc::sync::Arc::new(INTER_SANS)); let registered = font_cx.collection.register_fonts(blob, None); if let Some((family_id, _)) = registered.first() { // Las familias actuales del genérico (las del sistema) van detrás: // Inter primero, luego el respaldo previo. let existing: Vec<_> = font_cx .collection .generic_families(GenericFamily::SansSerif) .collect(); font_cx.collection.set_generic_families( GenericFamily::SansSerif, core::iter::once(*family_id).chain(existing), ); } } /// ¿La máquina tiene una fuente de emoji que fontique pueda resolver? /// /// Es la familia genérica `emoji` (en Linux, el alias de fontconfig; en /// Android/macOS/Windows, la lista fija que trae fontique). Sin ella, un /// emoji cae al fallback de siempre y sale monocromo — que es lo que hacía /// TODA la suite antes de [`rangos_emoji`], así que no es un modo roto, /// es el modo viejo. /// /// Existe para que un test pueda saltearse **diciendo por qué** en vez de /// fallar en una máquina pelada, y para que una app que muestre emoji /// pueda avisar en vez de mostrar caritas grises sin explicación. En el /// kernel de wawa da `false` por construcción: no hay fontconfig. /// **Las familias de fuente que esta máquina tiene**, ordenadas por nombre. /// /// Existe para que una app pueda **ofrecer** una fuente en vez de pedirle al /// usuario que adivine el nombre exacto: una familia mal escrita no da error, /// da la fuente de siempre y el ajuste parece no funcionar. El primer /// consumidor es el reloj de la barra de pata, donde el nombre se elige de /// una lista y no se tipea. /// /// Sale del `collection` de parley —o sea de fontconfig en Linux y de las /// embebidas en el kernel de wawa—, así que en bare-metal devuelve las tres /// que hay y no una lista vacía ni un error. pub fn familias_disponibles(&mut self) -> Vec { let mut v: Vec = self.font_cx.collection.family_names().map(|n| n.to_string()).collect(); v.sort_unstable(); v.dedup(); v } /// ¿Existe esta familia? Antes de guardar una elección conviene /// preguntarlo: guardar un nombre que no está deja el ajuste puesto y el /// texto igual que antes, que es el peor de los dos mundos. pub fn hay_familia(&mut self, nombre: &str) -> bool { self.font_cx.collection.family_names().any(|n| n.eq_ignore_ascii_case(nombre)) } pub fn hay_fuente_de_emoji(&mut self) -> bool { self.font_cx .collection .generic_families(parley::GenericFamily::Emoji) .next() .is_some() } /// Registra DejaVu Sans y la apila como último recurso para los símbolos /// del script `Common` (flechas, geométricos, dingbats, astro…). Ver la /// nota de [`DEJAVU_SANS`]. Best-effort: si algo falla, el texto sigue /// funcionando con las fuentes del sistema (solo reaparecería el tofu). fn install_symbol_fallback(font_cx: &mut parley::FontContext) { use parley::fontique::Blob; let blob = Blob::new(alloc::sync::Arc::new(DEJAVU_SANS)); let registered = font_cx.collection.register_fonts(blob, None); if let Some((family_id, _)) = registered.first() { // `Zyyy` (Common) es el script de la inmensa mayoría de los // símbolos que daban tofu; lo apilamos al final del fallback. font_cx .collection .append_fallbacks("Zyyy", core::iter::once(*family_id)); } } /// Registra la fuente monoespaciada embebida (Liberation Mono) bajo su /// nombre de familia [`MONOSPACE`], para que `FontStack::Source` /// (`font_family: Some(MONOSPACE)`) la resuelva aunque el sistema no /// tenga ninguna mono instalada. Best-effort: si falla, los callers que /// pidan monospace caen al fallback de fontique (mono del sistema, o la /// proporcional si no hay) — el texto sigue, sólo pierde el ancho fijo. fn install_monospace(font_cx: &mut parley::FontContext) { use parley::fontique::Blob; let blob = Blob::new(alloc::sync::Arc::new(LIBERATION_MONO)); font_cx.collection.register_fonts(blob, None); // **Y su NEGRITA** (CÑ3, 2026-10-05). Se registra bajo la misma familia // —el TTF declara `Liberation Mono` / `Bold`, así que fontique lo apila // como otra cara de la misma— y es lo que hace que `weight: 700` resuelva // algo distinto. Sin esto el peso se pedía y se ignoraba en silencio. let bold = Blob::new(alloc::sync::Arc::new(LIBERATION_MONO_BOLD)); font_cx.collection.register_fonts(bold, None); } /// La **stack** que se usa cuando un bloque pide [`MONOSPACE`]: la mono /// embebida y, detrás, una **Nerd Font del sistema** si la hay. /// /// Por qué (medido 2026-07-29). Las barras de estado y los prompts modernos /// —zellij, starship, powerlevel10k— están hechos de glifos Nerd Font: /// separadores «powerline» (`U+E0B0`), íconos de rama, de carpeta, de /// lenguaje. Viven en la zona **privada** Unicode, así que ninguna fuente /// estándar los trae. /// /// Lo engañoso es que con la stack por defecto (sans) **sí** resolvían — el /// fallback de fontique encontraba solo una Nerd Font del sistema. Pero el /// grid del terminal pide la familia mono explícita, y por ahí no llegaba. /// Shapeado a 14 px: /// /// ```text /// familia por defecto familia MONOSPACE MONOSPACE + Nerd /// U+E0B0 glifo 1548 glifo 0 ← tofu glifo 1012 /// U+F192 glifo 3738 glifo 0 glifo 3738 /// → glifo 1709 glifo 2104 glifo 2104 /// ``` /// /// **No alcanza con `append_fallbacks`.** Se probó: apilar la familia en el /// fallback de `Zzzz` (el script que swash le asigna a la zona privada — /// verificado, índice 156 de la tabla de parley) queda registrado /// —`fallback_families("Zzzz")` la lista— pero el glifo sigue saliendo 0. /// Extender la stack, en cambio, funciona y no depende de la semántica de /// fallback por-script. /// /// Preferimos una variante **Mono**: el grid cuenta con avance fijo y las /// «Propo» son proporcionales. Best-effort — sin ninguna Nerd Font /// instalada devuelve [`MONOSPACE`] a secas y los glifos siguen saliendo /// tofu, que es exactamente lo de antes. fn stack_monospace(font_cx: &mut parley::FontContext) -> Box { let mut candidatas: Vec<&str> = font_cx .collection .family_names() .filter(|n| n.contains("Nerd Font")) .collect(); // Orden determinista: primero las Mono y, a igualdad, por nombre — si // no, la elección dependería del orden en que el SO enumere. candidatas.sort_by_key(|n| { let rango = if n.ends_with("Nerd Font Mono") { 0 } else if n.ends_with("Nerd Font") { 1 } else { 2 }; (rango, *n) }); match candidatas.first() { // Sintaxis CSS-like, que es lo que `FontStack::Source` parsea. Some(nerd) => format!("{MONOSPACE}, {nerd}").into_boxed_str(), None => MONOSPACE.into(), } } /// Acceso al `FontContext` por si se necesita registrar fuentes extra /// o cambiar la stack de fallback. **Invalida el caché de shaping**: tocar /// el set de fuentes o el fallback puede cambiar el resultado de cualquier /// layout, así que descartamos lo cacheado (operación rara, de setup). pub fn font_context_mut(&mut self) -> &mut parley::FontContext { self.cache.clear(); &mut self.font_cx } /// Estadísticas del caché de shaping (hits/misses acumulados + entradas /// vivas). Para benchmark/evidencia; no afecta el render. pub fn cache_stats(&self) -> CacheStats { CacheStats { hits: self.cache_hits, misses: self.cache_misses, entries: self.cache.entries(), } } /// Construye y resuelve un `parley::Layout`. Aplica `font_size`, /// `line_height` (multiplicador del font_size), `max_width` (line /// break), `alignment` y `weight` (peso de fuente CSS: 400 normal, /// 700 bold). `italic`=true selecciona la variante italic/oblique de /// la fuente activa (vía `parley::FontStyle`). `underline`/`strikethrough` /// activan la decoración global del bloque — parley deja la metadata /// (offset + grosor) en cada `Run` y el pintado (`draw_layout_*`) emite /// el rect correspondiente sobre la línea base. /// API pública 12-arg (sin `overflow-wrap`): la usan showreels, canvas, /// hit-testing de selección, etc. Delega en [`Self::layout_inner`] con /// `overflow_wrap = false` (la palabra larga desborda, comportamiento /// histórico). El quiebre dentro de palabra entra sólo por `layout_clamped` /// (camino del compositor), para no propagar el flag a todos los callers. #[allow(clippy::too_many_arguments)] pub fn layout( &mut self, text: &str, size_px: f32, max_width: Option, alignment: Alignment, line_height: f32, italic: bool, font_family: Option<&str>, weight: f32, underline: bool, strikethrough: bool, letter_spacing: f32, word_spacing: f32, ) -> parley::Layout<()> { self.layout_inner( text, size_px, max_width, alignment, line_height, italic, font_family, weight, underline, strikethrough, letter_spacing, word_spacing, false, "", "", 100.0, ) } /// Impl real del shaping con el flag `overflow_wrap` (CSS /// `overflow-wrap: break-word`/`anywhere`). Privado: sólo lo invocan /// [`Self::layout`] (con `false`) y [`Self::layout_clamped`] (con el valor /// del estilo). Así la firma pública 12-arg no cambia y los ~20 callers de /// showreels/canvas siguen compilando sin tocar. #[allow(clippy::too_many_arguments)] fn layout_inner( &mut self, text: &str, size_px: f32, max_width: Option, alignment: Alignment, line_height: f32, italic: bool, font_family: Option<&str>, weight: f32, underline: bool, strikethrough: bool, letter_spacing: f32, word_spacing: f32, overflow_wrap: bool, font_features: &str, font_variations: &str, font_width: f32, ) -> parley::Layout<()> { // Caché de shaping: clave por todos los parámetros que determinan el // layout. En el hit clonamos el `parley::Layout` (memcpy de vectores, // ~órdenes de magnitud más barato que re-shapear). El `String`/clave // que se aloca para consultar es un costo menor frente al shaping que // evita; mantener la firma `&str` no fuerza alloc en el caller. let key = ShapeKey { text: text.to_string(), size_bits: size_px.to_bits(), max_width_bits: max_width.map(f32::to_bits), align: align_tag(alignment), line_height_bits: line_height.to_bits(), italic, font_family: font_family.map(str::to_string), weight_bits: weight.to_bits(), underline, strikethrough, letter_bits: letter_spacing.to_bits(), word_bits: word_spacing.to_bits(), overflow_wrap, features: font_features.to_string(), variations: font_variations.to_string(), font_width_bits: font_width.to_bits(), }; if let Some(hit) = self.cache.get(&key) { self.cache_hits += 1; return hit; } self.cache_misses += 1; let mut builder = self.layout_cx .ranged_builder(&mut self.font_cx, text, 1.0, true); builder.push_default(parley::StyleProperty::FontSize(size_px)); builder.push_default(parley::StyleProperty::LineHeight( parley::LineHeight::FontSizeRelative(line_height), )); // SIEMPRE fijamos el peso (antes se saltaba `== 400.0` como "optimización"): // con una fuente VARIABLE, NO setear `FontWeight` deja el peso en el DEFAULT // del archivo (que puede ser bold), no en el 400 pedido. Ese era el "texto // bold feo" al escribir en un campo. Pedir 400 debe dar 400. builder.push_default(parley::StyleProperty::FontWeight( parley::FontWeight::new(weight), )); if italic { builder.push_default(parley::StyleProperty::FontStyle( parley::FontStyle::Italic, )); } if let Some(ff) = font_family { // parley::FontStack::Source acepta CSS-like syntax // (`"Helvetica", sans-serif`). Pedir la mono trae además la Nerd // Font del sistema detrás: ver `stack_monospace`. let pedida: &str = if ff == MONOSPACE { &self.mono_stack } else { ff }; builder.push_default(parley::StyleProperty::FontStack( parley::FontStack::Source(Cow::Borrowed(pedida)), )); } if underline { builder.push_default(parley::StyleProperty::Underline(true)); } if strikethrough { builder.push_default(parley::StyleProperty::Strikethrough(true)); } // `letter-spacing`/`word-spacing` (px extra). 0 = sin override (normal). if letter_spacing != 0.0 { builder.push_default(parley::StyleProperty::LetterSpacing(letter_spacing)); } if word_spacing != 0.0 { builder.push_default(parley::StyleProperty::WordSpacing(word_spacing)); } // `overflow-wrap: break-word`/`anywhere`: habilita la partición dentro // de una palabra cuando no hay otra oportunidad de quiebre en la línea // (un token más ancho que la caja). `Anywhere` cubre ambos valores CSS // — su única diferencia con `BreakWord` es el min-content sizing, sin // efecto visible en el wrap del bloque. Sin el flag (normal) parley deja // desbordar la palabra larga (comportamiento previo). if overflow_wrap { builder.push_default(parley::StyleProperty::OverflowWrap( parley::OverflowWrap::Anywhere, )); } // `font-feature-settings`/`font-variation-settings` (+ `font-kerning` // plegado en la fuente de features): parley/swash parsean el string CSS // crudo (`FontSettings::Source`, gramática `"tag" value, …`). Vacío = // default de la fuente (no-op, no se empuja). El `&str` vive hasta el // `build`, así que el `Cow::Borrowed` es válido (mismo patrón que // `FontStack::Source`). if !font_features.is_empty() { builder.push_default(parley::StyleProperty::FontFeatures( parley::FontSettings::Source(Cow::Borrowed(font_features)), )); } if !font_variations.is_empty() { builder.push_default(parley::StyleProperty::FontVariations( parley::FontSettings::Source(Cow::Borrowed(font_variations)), )); } // `font-stretch`/`font-width` (porcentaje, 100 = normal): selecciona la // face condensada/expandida o el eje `wdth` de una variable. Sólo se // empuja si difiere de normal (no-op en el caso común). if (font_width - 100.0).abs() > 0.01 { builder.push_default(parley::StyleProperty::FontWidth( parley::FontWidth::from_percentage(font_width), )); } // Último, para que pise cualquier familia que el caller haya pedido // por rango: un emoji sale a color aunque caiga adentro de un tramo // con fuente propia. Ver `rangos_emoji`. empujar_familia_emoji(&mut builder, text); let mut layout = builder.build(text); layout.break_all_lines(max_width); layout.align( max_width, alignment.into(), parley::AlignmentOptions::default(), ); self.cache.put(key, layout.clone()); layout } /// Como [`Self::layout`] pero **clampado** a `max_lines` líneas (CSS /// `-webkit-line-clamp` / Flutter `maxLines`). Si el texto envuelto cabe en /// `max_lines` o menos, devuelve el layout completo. Si excede: /// - `ellipsis = true` → la última línea visible termina en `…` (se /// recortan graphemes del final hasta que el bloque vuelve a caber en /// `max_lines`). /// - `ellipsis = false` → se corta sin glifo (queda el prefijo que cupo). /// /// `max_lines = None` o `Some(0)` ⇒ sin límite (idéntico a `layout`). El /// clamp sólo recorta cuando hay envoltura, así que requiere un `max_width` /// definido para tener efecto (un label en una caja dimensionada — el caso /// típico). Reusa `layout` internamente: 0 costo extra cuando no trunca. #[allow(clippy::too_many_arguments)] pub fn layout_clamped( &mut self, text: &str, size_px: f32, max_width: Option, alignment: Alignment, line_height: f32, italic: bool, font_family: Option<&str>, weight: f32, max_lines: Option, ellipsis: bool, underline: bool, strikethrough: bool, letter_spacing: f32, word_spacing: f32, overflow_wrap: bool, font_features: &str, font_variations: &str, font_width: f32, ) -> parley::Layout<()> { let full = self.layout_inner( text, size_px, max_width, alignment, line_height, italic, font_family, weight, underline, strikethrough, letter_spacing, word_spacing, overflow_wrap, font_features, font_variations, font_width, ); let limit = match max_lines { Some(n) if n >= 1 => n, _ => return full, }; if full.lines().count() <= limit { return full; } // Byte de fin de la última línea visible (rango sobre `text` original). let mut cutoff = full .lines() .nth(limit - 1) .map(|l| l.text_range().end) .unwrap_or(text.len()) .min(text.len()); while cutoff > 0 && !text.is_char_boundary(cutoff) { cutoff -= 1; } let base = text[..cutoff].trim_end(); if !ellipsis { return self.layout_inner( base, size_px, max_width, alignment, line_height, italic, font_family, weight, underline, strikethrough, letter_spacing, word_spacing, overflow_wrap, font_features, font_variations, font_width, ); } // Recorta graphemes del final hasta que `base…` vuelva a caber en // `limit` líneas (apilar el `…` puede empujar una palabra a una línea // extra). Acotado: cada vuelta quita ≥1 char. let mut s = base.to_string(); loop { let candidate = format!("{s}…"); let lay = self.layout_inner( &candidate, size_px, max_width, alignment, line_height, italic, font_family, weight, underline, strikethrough, letter_spacing, word_spacing, overflow_wrap, font_features, font_variations, font_width, ); if s.is_empty() || lay.lines().count() <= limit { return lay; } s.pop(); while s.ends_with(char::is_whitespace) { s.pop(); } } } /// Construye un layout **multicolor** en una sola pasada de shaping: /// `default_color` cubre todo el texto y cada `(start_byte, end_byte, /// color)` lo sobreescribe en su rango (offsets en **bytes**, no chars — /// la convención de parley). Pensado para syntax highlighting: shapear /// la línea entera una vez con un color por token, en vez de un layout /// por token. Sin wrap (`max_width = None`); el caller posiciona la línea. /// /// `font_family` es la misma familia CSS-like que toma [`Self::layout`]. /// **No es opcional de verdad:** sin ella este camino shapeaba siempre con /// la sans default, así que cualquier vista que pedía `.mono()` **y** color /// por tramos perdía el ancho fijo en silencio — nada compilaba mal, sólo /// se torcían las tablas de la consola. #[allow(clippy::too_many_arguments)] pub fn layout_runs( &mut self, text: &str, size_px: f32, default_color: Color, runs: &[(usize, usize, Color)], alignment: Alignment, line_height: f32, weight: f32, underline: bool, strikethrough: bool, font_family: Option<&str>, ) -> parley::Layout { let mut builder = self .runs_cx .ranged_builder(&mut self.font_cx, text, 1.0, true); if let Some(ff) = font_family { // Pedir la mono trae la Nerd Font del sistema detrás: ver // `stack_monospace`. let pedida: &str = if ff == MONOSPACE { &self.mono_stack } else { ff }; builder.push_default(parley::StyleProperty::FontStack( parley::FontStack::Source(Cow::Borrowed(pedida)), )); } builder.push_default(parley::StyleProperty::FontSize(size_px)); builder.push_default(parley::StyleProperty::LineHeight( parley::LineHeight::FontSizeRelative(line_height), )); // SIEMPRE fijamos el peso (antes se saltaba `== 400.0` como "optimización"): // con una fuente VARIABLE, NO setear `FontWeight` deja el peso en el DEFAULT // del archivo (que puede ser bold), no en el 400 pedido. Ese era el "texto // bold feo" al escribir en un campo. Pedir 400 debe dar 400. builder.push_default(parley::StyleProperty::FontWeight( parley::FontWeight::new(weight), )); builder.push_default(parley::StyleProperty::Brush(RunBrush(default_color))); if underline { builder.push_default(parley::StyleProperty::Underline(true)); } if strikethrough { builder.push_default(parley::StyleProperty::Strikethrough(true)); } let len = text.len(); for &(start, end, color) in runs { if start < end && end <= len { builder.push(parley::StyleProperty::Brush(RunBrush(color)), start..end); } } // Último, para que pise cualquier familia que el caller haya pedido // por rango: un emoji sale a color aunque caiga adentro de un tramo // con fuente propia. Ver `rangos_emoji`. empujar_familia_emoji(&mut builder, text); let mut layout = builder.build(text); layout.break_all_lines(None); layout.align(None, alignment.into(), parley::AlignmentOptions::default()); layout } /// Construye un layout **RichText**: defaults a nivel bloque + un /// arreglo de [`TextSpan`] que sobreescriben tamaño/peso/italic/familia/ /// color/decoración **por rango de bytes**. A diferencia de /// [`Self::layout_runs`] (sólo color, sin wrap), este camino: /// /// - permite `max_width` (envuelve a párrafo); /// - aplica los siete `StyleProperty` por rango; /// - usa el mismo `runs_cx` (`RunBrush`), así puede convivir con el /// pintado multicolor. /// /// **Sin caché** en v1 (a diferencia de `layout`/`layout_clamped`): el /// RichText típico cambia frame-a-frame (cursor de editor, hover de /// link), y la clave de caché de un span-set arbitrario es pesada. /// Reusa todo el shaping interno de parley, que ya es rápido para /// párrafos de la magnitud de una UI. #[allow(clippy::too_many_arguments)] pub fn layout_spans( &mut self, text: &str, size_px: f32, default_color: Color, weight: f32, line_height: f32, italic: bool, font_family: Option<&str>, underline: bool, strikethrough: bool, spans: &[TextSpan], max_width: Option, alignment: Alignment, ) -> parley::Layout { let mut builder = self .runs_cx .ranged_builder(&mut self.font_cx, text, 1.0, true); builder.push_default(parley::StyleProperty::FontSize(size_px)); builder.push_default(parley::StyleProperty::LineHeight( parley::LineHeight::FontSizeRelative(line_height), )); // SIEMPRE fijamos el peso (antes se saltaba `== 400.0` como "optimización"): // con una fuente VARIABLE, NO setear `FontWeight` deja el peso en el DEFAULT // del archivo (que puede ser bold), no en el 400 pedido. Ese era el "texto // bold feo" al escribir en un campo. Pedir 400 debe dar 400. builder.push_default(parley::StyleProperty::FontWeight( parley::FontWeight::new(weight), )); if italic { builder.push_default(parley::StyleProperty::FontStyle( parley::FontStyle::Italic, )); } if let Some(ff) = font_family { // Pedir la mono trae la Nerd Font del sistema detrás: ver // `stack_monospace`. let pedida: &str = if ff == MONOSPACE { &self.mono_stack } else { ff }; builder.push_default(parley::StyleProperty::FontStack( parley::FontStack::Source(Cow::Borrowed(pedida)), )); } builder.push_default(parley::StyleProperty::Brush(RunBrush(default_color))); if underline { builder.push_default(parley::StyleProperty::Underline(true)); } if strikethrough { builder.push_default(parley::StyleProperty::Strikethrough(true)); } let len = text.len(); for span in spans { if span.start >= span.end || span.end > len { continue; } let range = span.start..span.end; let s = &span.style; if let Some(v) = s.size_px { builder.push(parley::StyleProperty::FontSize(v), range.clone()); } if let Some(v) = s.weight { builder.push( parley::StyleProperty::FontWeight(parley::FontWeight::new(v)), range.clone(), ); } if let Some(v) = s.italic { let style = if v { parley::FontStyle::Italic } else { parley::FontStyle::Normal }; builder.push(parley::StyleProperty::FontStyle(style), range.clone()); } if let Some(ff) = s.font_family.as_deref() { builder.push( parley::StyleProperty::FontStack(parley::FontStack::Source( Cow::Owned(ff.to_string()), )), range.clone(), ); } if let Some(c) = s.color { builder.push(parley::StyleProperty::Brush(RunBrush(c)), range.clone()); } if let Some(v) = s.underline { builder.push(parley::StyleProperty::Underline(v), range.clone()); } if let Some(v) = s.strikethrough { builder.push(parley::StyleProperty::Strikethrough(v), range.clone()); } } // Último, para que pise cualquier familia que el caller haya pedido // por rango: un emoji sale a color aunque caiga adentro de un tramo // con fuente propia. Ver `rangos_emoji`. empujar_familia_emoji(&mut builder, text); let mut layout = builder.build(text); layout.break_all_lines(max_width); layout.align( max_width, alignment.into(), parley::AlignmentOptions::default(), ); layout } } /// Brush por-run para texto multicolor. Newtype sobre [`Color`] porque /// parley exige que el brush genérico implemente `Default` (que `Color` no /// garantiza); aquí proveemos uno explícito (negro opaco) que nunca se ve /// en la práctica: todo run lleva su color o el `default_color` del bloque. #[derive(Clone, Copy, PartialEq, Debug)] pub struct RunBrush(pub Color); impl Default for RunBrush { fn default() -> Self { RunBrush(Color::from_rgba8(0, 0, 0, 255)) } } /// Overrides de estilo aplicables a un **rango de bytes** dentro de un /// bloque de texto, para `Typesetter::layout_spans` (RichText). Cada /// campo es opcional: `None` hereda del default del bloque. La granularidad /// es por bytes (convención de parley), igual que el `runs` multicolor. #[derive(Default, Clone, Debug, PartialEq)] pub struct TextSpanStyle { /// Tamaño de fuente (CSS `font-size`). El reshape recalcula el alto /// de la línea afectada. pub size_px: Option, /// Peso de fuente (400 = normal, 700 = bold). pub weight: Option, /// Italic on/off. pub italic: Option, /// Family CSS-like ("Helvetica, sans-serif"). Útil para `code` inline /// (forzar monospace en una palabra). pub font_family: Option, /// Color del texto (gana sobre el `default_color` del bloque). pub color: Option, /// Subrayado on/off. pub underline: Option, /// Tachado on/off. pub strikethrough: Option, } /// Un span de RichText: rango de bytes `[start, end)` + overrides de /// estilo (`style`). Los rangos pueden superponerse — parley aplica los /// `StyleProperty` en orden de inserción, así el caller debería pushar de /// menor a mayor especificidad. #[derive(Clone, Debug, PartialEq)] pub struct TextSpan { pub start: usize, pub end: usize, pub style: TextSpanStyle, } impl TextSpan { pub fn new(start: usize, end: usize, style: TextSpanStyle) -> Self { Self { start, end, style } } } /// Alineación horizontal del bloque dentro de su ancho máximo. #[derive(Debug, Clone, Copy)] pub enum Alignment { Start, Center, End, Justify, } #[cfg(feature = "shaping")] impl From for parley::Alignment { fn from(a: Alignment) -> Self { match a { Alignment::Start => parley::Alignment::Start, Alignment::Center => parley::Alignment::Center, Alignment::End => parley::Alignment::End, Alignment::Justify => parley::Alignment::Justify, } } } /// Especificación de un bloque de texto a rasterizar. pub struct TextBlock<'a> { pub text: &'a str, pub size_px: f32, pub color: Color, /// Esquina superior-izquierda del bloque (no el baseline — parley se /// encarga del baseline internamente). pub origin: (f64, f64), pub max_width: Option, pub alignment: Alignment, /// Múltiplo del font_size (1.0 = compacto, 1.3 = cómodo). pub line_height: f32, /// `true` → fuerza variante italic/oblique en la fuente activa. pub italic: bool, /// CSS-style `font-family` string. `None` = sans-serif default. pub font_family: Option, } impl<'a> TextBlock<'a> { /// Constructor simple para una línea sin wrap. pub fn simple(text: &'a str, size_px: f32, color: Color, origin: (f64, f64)) -> Self { Self { text, size_px, color, origin, max_width: None, alignment: Alignment::Start, line_height: 1.0, italic: false, font_family: None, } } } /// Medidas resultantes de un layout. #[derive(Debug, Clone, Copy)] pub struct Measurement { pub width: f32, pub height: f32, } /// Construye el layout (shaping + line break + alineación) listo para medir /// y/o pintar. Usa esta API cuando necesitas el alto **antes** de elegir el /// origen (p. ej. centrado vertical) y no quieres repetir el shaping en el /// `draw`: medís sobre el layout retornado y luego lo pasas a /// [`draw_layout`]. #[cfg(feature = "shaping")] pub fn layout_block(ts: &mut Typesetter, block: &TextBlock<'_>) -> parley::Layout<()> { ts.layout( block.text, block.size_px, block.max_width, block.alignment, block.line_height, block.italic, block.font_family.as_deref(), // `TextBlock` no transporta peso (su API queda en normal); el peso de // fuente fluye por el camino del compositor, que llama a `layout` // directamente con el `weight` del `TextSpec`/`TextMeasure`. 400.0, // Decoración tampoco viaja por `TextBlock`: la activa el compositor // por nodo según `TextSpec::{underline,strikethrough}`. false, false, // `letter-spacing`/`word-spacing` tampoco viajan por `TextBlock`; el // compositor los pasa por su camino directo (`layout_clamped`). 0.0, 0.0, ) } /// Devuelve las medidas de un layout ya resuelto. Equivalente conceptual a /// `(layout.width(), layout.height())` pero envuelto en [`Measurement`]. #[cfg(feature = "shaping")] pub fn measurement(layout: &parley::Layout<()>) -> Measurement { Measurement { width: layout.width(), height: layout.height(), } } /// Ancho en px de `text` en una sola línea, midiendo con un `Typesetter` que /// presta quien llama. /// /// Es el gemelo explícito de [`text_width`], y existe porque aquél se apoya en /// un `thread_local!` — que necesita `std` y, más de fondo, hilos. En wawa no /// hay ninguna de las dos cosas: el reactor es cooperativo de un solo núcleo. /// Acá el `Typesetter` viaja por argumento, que es la forma que funciona en los /// dos mundos. #[cfg(feature = "shaping")] pub fn text_width_with(ts: &mut Typesetter, text: &str, size_px: f32) -> f32 { if text.is_empty() { return 0.0; } let lp = ts.layout( text, size_px, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0, ); measurement(&lp).width } // Los DOS gates, como su único consumidor (`text_width`): el `thread_local` // necesita `std`, pero el `Typesetter` que guarda adentro vive tras `shaping` // (es parley). Con `std` y sin `shaping` esto referenciaba un tipo inexistente // — perfil real: quien sólo exporta SVG (`tawasuyu-apps-desktop`), que no pinta // ni mide. #[cfg(all(feature = "std", feature = "shaping"))] thread_local! { /// Typesetter dedicado a **medir** texto en el hilo de UI. Instala la MISMA /// fuente UI que usa el compositor, así el ancho medido coincide al píxel con /// el glifo dibujado. El costo de cargar la fuente se paga una vez por hilo. /// Origen: el `MEASURE_TS` privado del `text-input`, promovido aquí para que /// cualquier widget mida glifos reales en `update`/`view` sin `&mut Typesetter`. static MEASURE_TS: core::cell::RefCell = core::cell::RefCell::new(Typesetter::new()); } /// Ancho en px de `text` renderizado en **una sola línea** a `size_px`, medido /// con la fuente UI real (shaping proporcional, kerning, ligaduras). Es la /// alternativa correcta al anti-patrón `chars().count() * constante`, que finge /// una fuente monoespaciada sobre una proporcional y desalinea todo cálculo de /// ancho (chips, títulos de menú, botones, tooltips…). Reusa un `Typesetter` /// thread-local, así se puede llamar desde `update`/`view` sin prestar uno. /// /// `text` vacío → `0.0`. Para medir un **prefijo** por columnas usar /// `text.chars().take(n).collect::()` como argumento. #[cfg(feature = "std")] #[cfg(feature = "shaping")] pub fn text_width(text: &str, size_px: f32) -> f32 { if text.is_empty() { return 0.0; } MEASURE_TS.with(|ts| { let mut ts = ts.borrow_mut(); let lp = ts.layout( text, size_px, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0, ); measurement(&lp).width }) } /// Pinta un layout ya resuelto en `scene` con `color` y un offset `origin` /// (esquina superior-izquierda del bloque). No alloca: los glifos van /// directo del iterador de parley al builder de vello. #[cfg(feature = "shaping")] pub fn draw_layout( scene: &mut Lienzo, layout: &parley::Layout<()>, color: Color, origin: (f64, f64), ) { draw_layout_xf(scene, layout, color, peniko::kurbo::Affine::translate(origin)); } /// Igual que [`draw_layout`] pero con una **afín completa** en vez de sólo un /// desplazamiento: permite pintar texto girado/escalado (p. ej. dentro de un /// marco rotado en una presentación espacial). El origen del layout (0,0) es el /// que mapea `transform`; las posiciones de glifo se aplican en ese espacio. #[cfg(feature = "shaping")] pub fn draw_layout_xf( scene: &mut Lienzo, layout: &parley::Layout<()>, color: Color, transform: peniko::kurbo::Affine, ) { draw_layout_brush_xf(scene, layout, &Brush::Solid(color), transform); } /// Igual que [`draw_layout_xf`] pero con un [`Brush`] arbitrario en vez de un /// color sólido: permite rellenar los glifos con un gradiente o una imagen /// (p. ej. CSS `background-clip: text`). El brush se interpreta en el espacio /// **local** del layout (origen 0,0) y `transform` lo lleva al lugar final — /// así un gradiente construido en coords (0,0)-(w,h) queda alineado con los /// glifos. Para texto normal usa [`draw_layout_xf`] (solid = máxima compat). #[cfg(feature = "vello")] pub fn draw_layout_brush_xf( scene: &mut Lienzo, layout: &parley::Layout<()>, brush: &Brush, transform: peniko::kurbo::Affine, ) { for line in layout.lines() { for item in line.items() { if let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item { let run = glyph_run.run(); let font = run.font().clone(); let font_size = run.font_size(); scene .draw_glyphs(&font) .font_size(font_size) .brush(brush) .transform(transform) .draw( peniko::Fill::NonZero, glyph_run.positioned_glyphs().map(|g| vello::Glyph { id: g.id as u32, x: g.x, y: g.y, }), ); paint_decoration(scene, &glyph_run, brush, transform); } } } } /// Pinta las decoraciones (`underline`/`strikethrough`) del run si las trae /// del shaping. El offset que devuelve parley sigue la convención OpenType /// (positivo = sobre la línea base en font-space, eje Y arriba); en /// coordenadas de pantalla (Y abajo) el rect va a `baseline - offset`. El /// `transform` es el mismo que se usa para los glifos, así la decoración /// hereda el scroll/rotación/zoom del subárbol. #[cfg(feature = "vello")] fn paint_decoration( scene: &mut Lienzo, glyph_run: &parley::GlyphRun<'_, B>, brush: &Brush, transform: peniko::kurbo::Affine, ) { let style = glyph_run.style(); let run = glyph_run.run(); let metrics = run.metrics(); let x = glyph_run.offset() as f64; let baseline = glyph_run.baseline() as f64; let advance = glyph_run.advance() as f64; if let Some(dec) = &style.underline { let offset = dec.offset.unwrap_or(metrics.underline_offset) as f64; let size = dec.size.unwrap_or(metrics.underline_size) as f64; let y0 = baseline - offset; let rect = peniko::kurbo::Rect::new(x, y0, x + advance, y0 + size); scene.fill(peniko::Fill::NonZero, transform, brush, None, &rect); } if let Some(dec) = &style.strikethrough { let offset = dec.offset.unwrap_or(metrics.strikethrough_offset) as f64; let size = dec.size.unwrap_or(metrics.strikethrough_size) as f64; let y0 = baseline - offset; let rect = peniko::kurbo::Rect::new(x, y0, x + advance, y0 + size); scene.fill(peniko::Fill::NonZero, transform, brush, None, &rect); } } /// Pinta un layout **multicolor** ([`Typesetter::layout_runs`]): cada /// `glyph_run` usa el color de su propio brush ([`RunBrush`]) en vez de un /// color uniforme. `origin` es la esquina superior-izquierda del bloque. #[cfg(feature = "shaping")] pub fn draw_layout_runs( scene: &mut Lienzo, layout: &parley::Layout, origin: (f64, f64), ) { draw_layout_runs_xf(scene, layout, peniko::kurbo::Affine::translate(origin)); } /// Igual que [`draw_layout_runs`] pero con una **afín completa** en vez de sólo /// un desplazamiento — el equivalente multicolor de [`draw_layout_xf`]. Lo /// necesita el compositor para que el texto multicolor herede la /// transformación acumulada del subárbol (scroll/rotación del padre): sin esto, /// el texto con `runs` se pintaba en coords de layout crudas, **ignorando** el /// transform, y se desalineaba del resto (p. ej. el cuerpo coloreado del shell /// no seguía el scroll del panel). El origen del layout (0,0) lo mapea /// `transform`; las posiciones de glifo se aplican en ese espacio. #[cfg(feature = "vello")] pub fn draw_layout_runs_xf( scene: &mut Lienzo, layout: &parley::Layout, transform: peniko::kurbo::Affine, ) { for line in layout.lines() { for item in line.items() { if let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item { let brush = Brush::Solid(glyph_run.style().brush.0); let run = glyph_run.run(); let font = run.font().clone(); let font_size = run.font_size(); scene .draw_glyphs(&font) .font_size(font_size) .brush(&brush) .transform(transform) .draw( peniko::Fill::NonZero, glyph_run.positioned_glyphs().map(|g| vello::Glyph { id: g.id as u32, x: g.x, y: g.y, }), ); paint_decoration(scene, &glyph_run, &brush, transform); } } } } /// Mide sin pintar. Atajo de [`layout_block`] + [`measurement`] para /// llamadores que sólo necesitan el bounding box. #[cfg(feature = "shaping")] pub fn measure(ts: &mut Typesetter, block: &TextBlock<'_>) -> Measurement { measurement(&layout_block(ts, block)) } /// Rasteriza el bloque en `scene` haciendo shaping una sola vez. Equivale a /// `layout_block` + `draw_layout` con `block.origin`. #[cfg(feature = "shaping")] pub fn draw_block(scene: &mut Lienzo, ts: &mut Typesetter, block: &TextBlock<'_>) { let layout = layout_block(ts, block); draw_layout(scene, &layout, block.color, block.origin); } #[cfg(feature = "shaping")] #[cfg(test)] mod tests { use super::*; /// Texto que envuelve a muchas líneas en un ancho angosto. const LARGO: &str = "palabras varias que envuelven en bastantes renglones cuando el ancho \ disponible es realmente angosto y no caben de un solo tirón"; fn n_lineas(ts: &mut Typesetter, max_lines: Option, ellipsis: bool) -> usize { ts.layout_clamped( LARGO, 14.0, Some(120.0), Alignment::Start, 1.2, false, None, 400.0, max_lines, ellipsis, false, false, 0.0, 0.0, false, "", "", 100.0, ) .lines() .count() } #[test] fn text_width_es_proporcional_no_monoespaciado() { // El anti-patrón `chars*const` daría igual ancho a "iii" y "WWW" // (3 chars c/u). La medición real con fuente proporcional debe // distinguirlos: 'W' es mucho más ancho que 'i'. let angosto = text_width("iii", 13.0); let ancho = text_width("WWW", 13.0); assert!(angosto > 0.0 && ancho > 0.0); assert!( ancho > angosto * 1.5, "WWW ({ancho}) debe ser bastante más ancho que iii ({angosto})" ); } #[test] fn text_width_vacio_es_cero_y_crece_monotono() { assert_eq!(text_width("", 13.0), 0.0); let a = text_width("a", 13.0); let ab = text_width("ab", 13.0); let abc = text_width("abc", 13.0); assert!(a > 0.0 && ab > a && abc > ab, "el ancho crece al agregar chars"); } #[test] fn clamp_limita_el_numero_de_lineas() { let mut ts = Typesetter::new(); let libre = n_lineas(&mut ts, None, false); assert!(libre > 2, "el fixture debe envolver a >2 líneas (dio {libre})"); // Con clamp, nunca más que el límite — con o sin ellipsis. assert_eq!(n_lineas(&mut ts, Some(1), false), 1); assert_eq!(n_lineas(&mut ts, Some(1), true), 1); assert!(n_lineas(&mut ts, Some(2), true) <= 2); // max_lines None ⇒ sin límite (idéntico a layout). assert_eq!(n_lineas(&mut ts, None, true), libre); } #[test] fn letter_y_word_spacing_ensanchan_la_medida() { // letter-spacing y word-spacing agregan px al ancho del shaping; 0 es // el baseline (normal). Prueba directa del feature (Fase 7.1252). let mut ts = Typesetter::new(); let w = |ts: &mut Typesetter, ls: f32, ws: f32| { measurement(&ts.layout( "hola mundo cruel", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, ls, ws, )) .width }; let base = w(&mut ts, 0.0, 0.0); let con_letter = w(&mut ts, 4.0, 0.0); let con_word = w(&mut ts, 0.0, 10.0); assert!(con_letter > base, "letter-spacing ensancha ({con_letter} > {base})"); assert!(con_word > base, "word-spacing ensancha ({con_word} > {base})"); } #[test] fn clamp_no_trunca_si_ya_cabe() { let mut ts = Typesetter::new(); // "Hola" cabe en una línea: pedir 3 no debe inventar truncado. let lay = ts.layout_clamped( "Hola", 14.0, Some(200.0), Alignment::Start, 1.2, false, None, 400.0, Some(3), true, false, false, 0.0, 0.0, false, "", "", 100.0, ); assert_eq!(lay.lines().count(), 1); } /// El caché no debe cambiar el resultado: misma medida con o sin hit, y la /// segunda llamada idéntica tiene que pegar en el caché (hit), no re-shapear. #[test] fn cache_es_transparente_y_pega() { let mut ts = Typesetter::new(); let m1 = { let l = ts.layout(LARGO, 14.0, Some(120.0), Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0); (l.width(), l.height(), l.lines().count()) }; let s1 = ts.cache_stats(); assert_eq!(s1.misses, 1, "primera vez = miss"); assert_eq!(s1.hits, 0); // Misma llamada exacta: debe ser hit y dar la misma geometría. let m2 = { let l = ts.layout(LARGO, 14.0, Some(120.0), Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0); (l.width(), l.height(), l.lines().count()) }; let s2 = ts.cache_stats(); assert_eq!(s2.hits, 1, "segunda vez idéntica = hit"); assert_eq!(s2.misses, 1, "no hubo nuevo miss"); assert_eq!(m1, m2, "el layout cacheado es idéntico al fresco"); // Cambiar un parámetro (ancho) es una clave distinta: miss nuevo. let _ = ts.layout(LARGO, 14.0, Some(80.0), Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0); assert_eq!(ts.cache_stats().misses, 2, "otro ancho = otra clave"); } /// `font_context_mut` invalida el caché (cambiar fuentes puede alterar el /// shaping): la siguiente llamada idéntica vuelve a ser miss. #[test] fn font_context_mut_invalida_el_cache() { let mut ts = Typesetter::new(); let _ = ts.layout("hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0); assert_eq!(ts.cache_stats().entries, 1); let _ = ts.font_context_mut(); assert_eq!(ts.cache_stats().entries, 0, "el caché quedó vacío"); let _ = ts.layout("hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0); assert_eq!(ts.cache_stats().misses, 2, "post-invalidación = miss"); } /// Decoración (underline / strikethrough): el flag de entrada debe /// llegar al `parley::Layout` como `style.underline`/`style.strikethrough` /// presentes en cada run, y el caché debe distinguir su clave (mismo /// texto con vs sin decoración = entradas separadas). #[test] fn underline_y_strikethrough_se_propagan_al_layout() { let mut ts = Typesetter::new(); let with_dec = ts.layout( "Hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, true, true, 0.0, 0.0, ); // Caminamos los runs del layout y verificamos que cada GlyphRun trae // ambas decoraciones marcadas (no usamos `is_some` directo porque // `Layout::lines/items` exige iterar para llegar al Style). let mut visto_u = false; let mut visto_s = false; for line in with_dec.lines() { for item in line.items() { if let parley::PositionedLayoutItem::GlyphRun(gr) = item { if gr.style().underline.is_some() { visto_u = true; } if gr.style().strikethrough.is_some() { visto_s = true; } } } } assert!(visto_u, "underline=true ⇒ Decoration en al menos un run"); assert!(visto_s, "strikethrough=true ⇒ Decoration en al menos un run"); // Sin decoración el layout no las trae. let plain = ts.layout( "Hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0, ); for line in plain.lines() { for item in line.items() { if let parley::PositionedLayoutItem::GlyphRun(gr) = item { assert!(gr.style().underline.is_none(), "sin underline=true ⇒ None"); assert!(gr.style().strikethrough.is_none(), "sin strikethrough=true ⇒ None"); } } } // Caché: dos misses (uno por cada variante), no se pisan. let s = ts.cache_stats(); assert!(s.misses >= 2, "claves distintas por decoración ⇒ misses separados"); } /// Mecánica generacional: al pasar `cap`, `hot` rota a `cold`; un ítem /// reaccedido se promueve y sobrevive a la siguiente rotación. #[test] fn cache_generacional_promueve_y_rota() { let mut c = ShapeCache::new(2); let mk = |s: &str| ShapeKey { text: s.to_string(), size_bits: 0, max_width_bits: None, align: 0, line_height_bits: 0, italic: false, font_family: None, weight_bits: 0, underline: false, strikethrough: false, letter_bits: 0, word_bits: 0, overflow_wrap: false, features: String::new(), variations: String::new(), font_width_bits: 100.0_f32.to_bits(), }; // Layouts vacíos como valores (sólo nos importa la presencia de claves). let dummy = parley::Layout::<()>::default; c.put(mk("a"), dummy()); c.put(mk("b"), dummy()); // "a" sigue caliente; lo accedemos para que se quede al rotar. assert!(c.get(&mk("a")).is_some()); // Tercer insert: hot llegó a cap(2) → rota (a,b→cold), c entra a hot. c.put(mk("c"), dummy()); // "a" estaba en cold; get lo encuentra y lo promueve a hot. assert!(c.get(&mk("a")).is_some(), "ítem reaccedido sobrevive la rotación"); // "b" no se reaccedió: cae en la siguiente rotación. c.put(mk("d"), dummy()); // hot = {c, a-promovido}? -> al llegar a cap rota // Tras suficientes rotaciones sin tocar "b", desaparece. c.put(mk("e"), dummy()); c.put(mk("f"), dummy()); assert!(c.get(&mk("b")).is_none(), "ítem nunca reaccedido se libera"); } // ========================================================================= // W2 :: que el shaping SIRVA sin las fuentes del sistema // ------------------------------------------------------------------------- // Compilar sin `fontique/system` no prueba nada: si la resolución de // familias no encontrara ninguna cara, parley devolvería layouts vacíos y // el crate «compilaría perfecto» pintando nada. En wawa eso sería una // pantalla en blanco sin un solo error. // // Estos tests corren en LOS DOS perfiles y son los que de verdad certifican // W2. Para ejercitar el perfil de wawa en el anfitrión: // cargo test -p llimphi-text --no-default-features --features std // ========================================================================= /// Sin sistema de fuentes, las tres caras embebidas tienen que alcanzar /// para shapear texto latino con ancho real. #[test] fn shapea_sin_fuentes_del_sistema() { let mut ts = Typesetter::new(); let lay = ts.layout( "Guardar como…", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0, ); let m = measurement(&lay); assert!( m.width > 10.0, "el texto tiene que medir algo real, midió {}", m.width ); assert!(m.height > 5.0, "y tener alto, midió {}", m.height); // Lo anterior podría pasar con cajas .notdef. Contar glifos de verdad: let glifos: usize = lay .lines() .flat_map(|l| l.items()) .filter_map(|it| match it { parley::PositionedLayoutItem::GlyphRun(r) => Some(r.positioned_glyphs().count()), _ => None, }) .sum(); assert_eq!( glifos, "Guardar como…".chars().count(), "un glifo por carácter (sin ligaduras en este texto)" ); } /// El ancho tiene que ser PROPORCIONAL, no monoespaciado: si la resolución /// de fuentes fallara y todo cayera a una cara de reemplazo, `iii` y `WWW` /// medirían igual. Es la trampa que este test existe para atrapar. #[test] fn el_ancho_es_proporcional_no_monoespaciado() { let mut ts = Typesetter::new(); let angosto = text_width_with(&mut ts, "iii", 14.0); let ancho = text_width_with(&mut ts, "WWW", 14.0); assert!( ancho > angosto * 1.5, "«WWW» ({ancho}) debe ser bastante más ancho que «iii» ({angosto})" ); } // ========================================================================= // W3 :: del shaping a PÍXELES por el camino sin GPU // ------------------------------------------------------------------------- // Es la prueba de punta a punta del plan: parley shapea (W2), la `Escena` // graba los glifos, `vello_cpu` los rasteriza (W1) y se cuentan píxeles. // Sin esto, «wawa sabe shapear» sería una afirmación sobre estructuras de // datos, no sobre algo que se vea. // ========================================================================= /// El texto shapeado tiene que llegar a píxeles encendidos — y sólo donde /// corresponde. #[cfg(feature = "cpu")] #[test] fn el_texto_llega_a_pixeles_por_el_backend_cpu() { use llimphi_cpu::kurbo::Affine; use llimphi_cpu::{Escena, Pintor}; let mut ts = Typesetter::new(); let layout = ts.layout( "Hola wawa", 24.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0, ); let m = measurement(&layout); let mut escena = Escena::new(); // Mismo nombre que del lado vello: el alias `Lienzo` es lo que conmuta. draw_layout_xf( &mut escena, &layout, Color::WHITE, Affine::translate((4.0, 4.0)), ); assert!(!escena.is_empty(), "el layout tiene que emitir órdenes"); let mut pintor = Pintor::new(200, 60); let mut lienzo = pintor.lienzo_nuevo(); pintor.pintar(&escena, &mut lienzo); let encendidos = lienzo.data().iter().filter(|c| c.a > 0).count(); assert!( encendidos > 100, "«Hola wawa» a 24px tiene que encender bastantes píxeles, encendió {encendidos}" ); // Y tiene que haber ANTIALIASING: si algo rasterizara en modo binario, // no habría píxeles a medio cubrir y el texto se vería dentado. let parciales = lienzo.data().iter().filter(|c| c.a > 0 && c.a < 255).count(); assert!( parciales > 20, "el texto debe tener bordes suaves, hubo {parciales} píxeles parciales" ); // Los píxeles tienen que caer DENTRO de la caja que el layout prometió, // con margen de un par de px por el hinting y los bordes suaves. Si el // transform se aplicara mal, esto atrapa el corrimiento. let ancho = lienzo.width() as usize; let derecha_prometida = (4.0 + m.width) as usize + 3; let abajo_prometido = (4.0 + m.height) as usize + 3; for (i, px) in lienzo.data().iter().enumerate() { if px.a == 0 { continue; } let (x, y) = (i % ancho, i / ancho); assert!( x >= 3 && x <= derecha_prometida && y >= 3 && y <= abajo_prometido, "píxel encendido en ({x},{y}), fuera de la caja medida \ ({}x{} desde (4,4))", m.width, m.height ); } } /// Los ids de glifo con que shapea `s` pidiendo la familia `familia`. /// `0` es `.notdef` — el tofu. #[cfg(feature = "system")] fn glifos_de(ts: &mut Typesetter, s: &str, familia: Option<&str>) -> Vec { // Por el chokepoint PÚBLICO, no armando el builder a mano: la stack de // la mono se resuelve ahí adentro, y un helper propio se saltaría justo // lo que hay que certificar. let layout = ts.layout( s, 14.0, None, Alignment::Start, 1.2, false, familia, 400.0, false, false, 0.0, 0.0, ); let mut ids = Vec::new(); for line in layout.lines() { for item in line.items() { if let parley::PositionedLayoutItem::GlyphRun(gr) = item { ids.extend(gr.positioned_glyphs().map(|g| g.id)); } } } ids } /// Los glifos **Nerd Font** (zona privada) tienen que resolver cuando el /// bloque pide la mono — que es como pide su texto el grid del terminal. /// /// El caso real: la barra de zellij trae 25 separadores «powerline» /// (`U+E0B0`) y una veintena de íconos `U+F1xx`. Sin la Nerd Font en la /// stack salen todos `.notdef` y la barra es una fila de tofu. /// /// Se salta si la máquina no tiene ninguna Nerd Font instalada: el arreglo /// es best-effort contra las fuentes del sistema, y en esa máquina no hay /// nada que certificar. #[test] #[cfg(feature = "system")] fn los_glifos_nerd_resuelven_pidiendo_la_mono() { let mut ts = Typesetter::new(); if !ts.mono_stack.contains("Nerd Font") { eprintln!("sin Nerd Font instalada — test omitido"); return; } for s in ["\u{e0b0}", "\u{f192}", "\u{e712}"] { let ids = glifos_de(&mut ts, s, Some(MONOSPACE)); assert!( !ids.is_empty() && !ids.contains(&0), "«U+{:04X}» shapeó a .notdef con la mono ({ids:?}) — saldría tofu", s.chars().next().unwrap() as u32 ); } // Y lo de siempre no se rompe: latino por la mono, símbolos por DejaVu. // Y lo que ya andaba por la mono no se rompe. for s in ["A", "→", "●", "⚠"] { let ids = glifos_de(&mut ts, s, Some(MONOSPACE)); assert!(!ids.contains(&0), "«{s}» se rompió: {ids:?}"); } // HUECO CONOCIDO, anterior a esto y sin arreglar: `✓` (U+2713) shapea a // .notdef pidiendo la mono. Medido con la stack nueva Y con // `Liberation Mono` a secas — los dos dan `[0]`, así que no lo trajo la // Nerd Font. Ni la mono embebida ni la cadena de `Zyyy` lo cubren por // ese camino; por la sans sí resuelve, que es por qué // `los_simbolos_del_chrome_tienen_glifo` no lo ve. Queda como aviso // explícito en vez de como sorpresa: en el grid del terminal el check // sale tofu. assert_eq!( glifos_de(&mut ts, "✓", Some(MONOSPACE)), vec![0], "si esto empieza a fallar, alguien tapó el hueco del check — \ borrá esta aserción y metelo en la lista de arriba" ); } /// Las cuatro fases de la luna tienen que resolver **pidiendo la sans**, /// que es como pide su texto un panel de lectura. /// /// No es una precaución genérica: **Inter no tiene las medias lunas** /// (`U+25D0`/`U+25D1`; sí tiene `●` y `○`), así que esas dos salen sí o sí /// del fallback de `Zyyy` a DejaVu. Y este repo ya se quemó con eso — los /// glifos planetarios de cosmos se dibujan como geometría a mano /// (`cosmos_render::glyphs`) porque con unicode «el usuario veía sólo /// Venus y Marte» y el resto caía en `.notdef` **invisible**, que es la /// peor forma de fallar: no hay cuadrado que delate el hueco. /// /// Por ancho no alcanza —una caja de tofu también mide—: se miran los ids. #[test] #[cfg(feature = "system")] fn las_fases_de_la_luna_tienen_glifo() { let mut ts = Typesetter::new(); for s in ["●", "◐", "○", "◑"] { let ids = glifos_de(&mut ts, s, None); assert!( !ids.is_empty() && !ids.contains(&0), "«U+{:04X}» shapeó a .notdef por la sans ({ids:?}) — saldría tofu", s.chars().next().unwrap() as u32 ); } // Brazo de control: si el shapeo diera glifos para cualquier cosa, el // test de arriba pasaría sin certificar nada. Un punto de código sin // asignar tiene que dar `.notdef`. assert!( glifos_de(&mut ts, "\u{10FFFD}", None).contains(&0), "un punto de código sin asignar NO dio .notdef — el instrumento \ no distingue «hay glifo» de «no hay», y las fases de arriba no \ están certificadas por nada" ); } /// El fallback de símbolos embebido (DejaVu) tiene que cubrir lo que el /// chrome usa de verdad: flechas, checks, avisos. Sin esto, wawa pintaría /// tofu en los menús. #[test] fn los_simbolos_del_chrome_tienen_glifo() { let mut ts = Typesetter::new(); for s in ["→", "✓", "⚠", "●"] { let w = text_width_with(&mut ts, s, 14.0); assert!(w > 1.0, "«{s}» no shapeó a nada (ancho {w})"); } } } // ============================================================================= // El camino CPU :: los mismos nombres, otro pincel (W3.b) // ----------------------------------------------------------------------------- // Gemelos exactos de `draw_layout_brush_xf`, `draw_layout_runs_xf` y // `paint_decoration`, contra el backend sin GPU. Mismas firmas: parley resuelve // el layout igual de los dos lados —mismo `Layout`, mismos glifos, mismas // métricas— y lo único que cambia es a quién se le entregan. // // `hint` va fijo en `true`: a tamaños de UI (12-14 px) ajustar los contornos a // la grilla es la diferencia entre texto nítido y texto sucio, y no cuesta del // lado app —lo hace el rasterizador nativo—. No se expone como parámetro // porque la firma tiene que coincidir con la de vello, que no lo tiene. // ============================================================================= /// Pinta un layout con un [`Brush`] arbitrario en el backend CPU. #[cfg(feature = "cpu")] pub fn draw_layout_brush_xf( scene: &mut Lienzo, layout: &parley::Layout<()>, brush: &Brush, transform: peniko::kurbo::Affine, ) { for line in layout.lines() { for item in line.items() { let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue; }; let run = glyph_run.run(); let font = run.font().clone(); scene.draw_glyphs( &font, run.font_size(), brush, transform, true, glyph_run.positioned_glyphs().map(|g| llimphi_cpu::Glifo { id: g.id as u32, x: g.x, y: g.y, }), ); paint_decoration(scene, &glyph_run, brush, transform); } } } /// Pinta un layout multicolor (un brush por run) en el backend CPU. #[cfg(feature = "cpu")] pub fn draw_layout_runs_xf( scene: &mut Lienzo, layout: &parley::Layout, transform: peniko::kurbo::Affine, ) { for line in layout.lines() { for item in line.items() { let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue; }; let brush = Brush::Solid(glyph_run.style().brush.0); let run = glyph_run.run(); let font = run.font().clone(); scene.draw_glyphs( &font, run.font_size(), &brush, transform, true, glyph_run.positioned_glyphs().map(|g| llimphi_cpu::Glifo { id: g.id as u32, x: g.x, y: g.y, }), ); paint_decoration(scene, &glyph_run, &brush, transform); } } } /// Las decoraciones (`underline`/`strikethrough`) del run, en el backend CPU. /// /// Misma convención que el gemelo de vello: parley devuelve el offset en /// font-space (Y arriba, positivo = sobre la línea base), y en pantalla (Y /// abajo) el rect va a `baseline - offset`. #[cfg(feature = "cpu")] fn paint_decoration( scene: &mut Lienzo, glyph_run: &parley::GlyphRun<'_, B>, brush: &Brush, transform: peniko::kurbo::Affine, ) { let style = glyph_run.style(); let metrics = glyph_run.run().metrics(); let x = glyph_run.offset() as f64; let baseline = glyph_run.baseline() as f64; let advance = glyph_run.advance() as f64; for (dec, off_def, size_def) in [ ( &style.underline, metrics.underline_offset, metrics.underline_size, ), ( &style.strikethrough, metrics.strikethrough_offset, metrics.strikethrough_size, ), ] { let Some(d) = dec else { continue }; let offset = d.offset.unwrap_or(off_def) as f64; let size = d.size.unwrap_or(size_def) as f64; let y0 = baseline - offset; scene.fill( peniko::Fill::NonZero, transform, brush, None, &peniko::kurbo::Rect::new(x, y0, x + advance, y0 + size), ); } } #[cfg(test)] mod tests_negrita_real { /// Las caras que resuelve el shaping para un texto, como `id#índice`. fn caras(ts: &mut super::Typesetter, familia: Option<&str>, peso: f32) -> Vec { let l = ts.layout( "Negrita", 13.0, None, super::Alignment::Start, 18.0, false, familia, peso, false, false, 0.0, 0.0, ); let mut out = Vec::new(); for linea in l.lines() { for item in linea.items() { if let parley::PositionedLayoutItem::GlyphRun(g) = item { let f = g.run().font(); out.push(format!("{:?}#{}", f.data.id(), f.index)); } } } out } /// **`MONOSPACE` tiene variante negrita; la familia por defecto NO.** /// /// CÑ3 (2026-10-05): el texto de claude se veía plano. El camino entero /// estaba bien —la celda del vt100 llega a `TextSpanStyle { weight: 700 }` y /// de ahí a `parley::FontWeight`— y no se veía porque los spans de la consola /// pedían la familia **por defecto**, que en esta máquina resuelve la misma /// cara para 400 y 700. /// /// Los dos lados en el mismo test a propósito: sin el brazo de control /// —la familia que NO tiene negrita— esto pasaría igual si el shaping /// devolviera cualquier cosa. #[test] fn monospace_resuelve_una_cara_distinta_para_la_negrita() { let mut ts = super::Typesetter::new(); let normal = caras(&mut ts, Some(super::MONOSPACE), 400.0); let negra = caras(&mut ts, Some(super::MONOSPACE), 700.0); assert!( !normal.is_empty(), "sin caras resueltas el test no mide nada (¿sin fuentes en esta máquina?)" ); assert_ne!( normal, negra, "«{}» tiene que resolver otra cara con peso 700: si resuelve la misma, \ pedir negrita no cambia un píxel y el texto sale plano", super::MONOSPACE ); } }