tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ main llimphi-text/src/wire.rs Rust · 749 líneas · 26.5 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0) b575c63f1 · Sergio ·

1// =============================================================================
2//  wire :: el encoder — el lado app del cable  (W4 de PLAN-LLIMPHI)
3// -----------------------------------------------------------------------------
4//  Este módulo es la mitad que faltaba de W4. El crate `llimphi-wire-escena`
5//  define el cable; `llimphi-wawa::reproductor` es el extremo nativo; esto es el
6//  extremo de la app.
7//
8//  ## Por qué la costura está acá y no en `render.rs`
9//
10//  El primer instinto era ponerle `#[cfg]` al bloque de texto de `render.rs` —
11//  110 líneas con tres ramas dentro del archivo más caliente del compositor.
12//  Contar las llamadas dijo otra cosa: en todo `llimphi-compositor` hay
13//  **tres** invocaciones a `typesetter.layout_*`, y todas piden lo mismo
14//  (shapear) para hacer lo mismo (medir y dibujar). O sea que la costura natural
15//  no es el llamador, es el llamado.
16//
17//  Así que `Typesetter` es OTRO TIPO acá. Mismo nombre, mismas firmas, otro
18//  cuerpo — exactamente el truco que ya funcionó dos veces en este plan: `Escena`
19//  espejando a `vello::Scene` en W1, y los gemelos CPU en W3.b. El resultado es
20//  que **`render.rs` no cambia ni una línea**.
21//
22//  ## Lo que este Typesetter hace en vez de shapear
23//
24//  No puede shapear: parley es justamente lo que no debe correr en la jaula (el
25//  30,3x del intérprete se comería el 72% del fotograma — W4.1). Pero el layout
26//  SÍ necesita medir: taffy no puede dimensionar una etiqueta sin saber cuánto
27//  mide. Ése es el nudo que el plan no había desatado.
28//
29//  La salida es que **medir se delega, igual que rasterizar**. El typesetter de
30//  la app lleva un *medidor*: una función que pregunta del otro lado de la
31//  frontera. En wawa será un `sys_`; en un test es un `Typesetter` de verdad. La
32//  medida es cacheable nativamente (el caché de shaping ya existe), así que un
33//  acierto no cuesta un shaping.
34//
35//  ## Sin medidor NO se devuelve cero
36//
37//  Un cero silencioso colapsaría el layout entero sin un solo error: todas las
38//  cajas de texto medirían nada y la pantalla saldría vacía «funcionando». Sin
39//  medidor se usa una estimación grosera **y se cuenta**, para que
40//  [`Typesetter::medidas_estimadas`] pueda delatarlo en un test o en una traza.
41// =============================================================================
42
43use alloc::boxed::Box;
44use alloc::string::{String, ToString};
45use alloc::vec::Vec;
46
47use llimphi_wire_escena::{
48    AlineacionWire, EstiloRangoWire, EstiloTextoWire, RangoWire, Texto, UbicacionWire,
49};
50use peniko::Color;
51
52use crate::{Alignment, Measurement, TextSpan};
53
54/// El lienzo de este backend: la escena serializable.
55pub use llimphi_wire_escena::EscenaWire;
56
57/// Lo que el medidor tiene que contestar.
58///
59/// Son los tres números que el compositor le pide hoy a parley: `width` (ancho
60/// de línea real), `full_width` (idem pero contando el espacio final, que parley
61/// excluye) y `height`. Que sean exactamente tres, y estos tres, no es
62/// arbitrario — es lo que `measure_text_node` consume, y pedir menos rompería el
63/// layout de maneras sutiles que `render.rs` ya documenta en detalle.
64#[derive(Clone, Copy, Debug, Default, PartialEq)]
65pub struct Medida {
66    pub width: f32,
67    pub full_width: f32,
68    pub height: f32,
69}
70
71/// Lo que el medidor recibe para contestar.
72pub struct Peticion<'a> {
73    pub cadena: &'a str,
74    pub estilo: &'a EstiloTextoWire,
75    /// Ancho de envoltura. `None` = una sola línea (max-content).
76    pub ancho_max: Option<f32>,
77}
78
79/// El typesetter del lado app: no shapea, pregunta y anota.
80///
81/// Mismo nombre y mismas firmas que el de parley, otro cuerpo. Ver el encabezado
82/// del módulo para el porqué.
83pub struct Typesetter {
84    medidor: Option<Box<dyn FnMut(&Peticion<'_>) -> Medida>>,
85    estimadas: usize,
86}
87
88impl Default for Typesetter {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl Typesetter {
95    /// Un typesetter **sin medidor**.
96    ///
97    /// Existe porque la firma de `Typesetter::new()` es parte del contrato que
98    /// este tipo espeja, pero mide por estimación y lo cuenta. Para algo real,
99    /// [`Typesetter::con_medidor`].
100    pub fn new() -> Self {
101        Self {
102            medidor: None,
103            estimadas: 0,
104        }
105    }
106
107    /// Un typesetter que pregunta del otro lado de la frontera.
108    ///
109    /// En wawa el cierre envuelve un `sys_`; en un test, un `Typesetter` de
110    /// parley de verdad — que es como se certifica que la app y el kernel miden
111    /// IGUAL, sin lo cual el texto se pintaría corrido respecto del layout.
112    pub fn con_medidor<F>(medidor: F) -> Self
113    where
114        F: FnMut(&Peticion<'_>) -> Medida + 'static,
115    {
116        Self {
117            medidor: Some(Box::new(medidor)),
118            estimadas: 0,
119        }
120    }
121
122    /// Cuántas medidas salieron de la estimación en vez del medidor real.
123    ///
124    /// Distinto de cero significa que el layout de esas etiquetas es aproximado.
125    /// Es la estadística que un test debe exigir en cero.
126    pub fn medidas_estimadas(&self) -> usize {
127        self.estimadas
128    }
129
130    fn medir(&mut self, p: &Peticion<'_>) -> Medida {
131        match self.medidor.as_mut() {
132            Some(f) => f(p),
133            None => {
134                self.estimadas += 1;
135                estimar(p)
136            }
137        }
138    }
139
140    // -------------------------------------------------------------------------
141    //  Las tres firmas que `llimphi-compositor` invoca. Idénticas a las del
142    //  typesetter de parley — es lo único que hace falta para que `render.rs`
143    //  compile sin cambios.
144    // -------------------------------------------------------------------------
145
146    /// Espeja `Typesetter::layout_clamped`. No shapea: mide y guarda el pedido.
147    #[allow(clippy::too_many_arguments)]
148    /// Espeja `crate::Typesetter::layout` — el layout **sin clampear**.
149    ///
150    /// Faltaba, y no era un olvido inocente: sin él,
151    /// `llimphi-widget-text-input` —el campo de una línea que usan el buscador,
152    /// el picker y la paleta— no compilaba contra este backend, así que el editor
153    /// entero no podía cruzar a una jaula por culpa de un campito.
154    ///
155    /// **Delega en [`Self::layout_clamped`] con exactamente los mismos valores
156    /// neutros que usa el backend que pinta** (`layout` → `layout_inner` con
157    /// `overflow_wrap: false`, sin features ni variaciones, ancho 100), más
158    /// `max_lines: None` y `ellipsis: false`. Que los dos lados elijan igual no
159    /// es prolijidad: es lo que hace que una app se mida igual nativa que
160    /// enjaulada, y una diferencia acá no mueve el glifo — mueve la caja de
161    /// línea.
162    #[allow(clippy::too_many_arguments)]
163    pub fn layout(
164        &mut self,
165        text: &str,
166        size_px: f32,
167        max_width: Option<f32>,
168        alignment: Alignment,
169        line_height: f32,
170        italic: bool,
171        font_family: Option<&str>,
172        weight: f32,
173        underline: bool,
174        strikethrough: bool,
175        letter_spacing: f32,
176        word_spacing: f32,
177    ) -> TextoMedido {
178        self.layout_clamped(
179            text,
180            size_px,
181            max_width,
182            alignment,
183            line_height,
184            italic,
185            font_family,
186            weight,
187            None,
188            false,
189            underline,
190            strikethrough,
191            letter_spacing,
192            word_spacing,
193            false,
194            "",
195            "",
196            100.0,
197        )
198    }
199
200    pub fn layout_clamped(
201        &mut self,
202        text: &str,
203        size_px: f32,
204        max_width: Option<f32>,
205        alignment: Alignment,
206        line_height: f32,
207        italic: bool,
208        font_family: Option<&str>,
209        weight: f32,
210        max_lines: Option<usize>,
211        ellipsis: bool,
212        underline: bool,
213        strikethrough: bool,
214        letter_spacing: f32,
215        word_spacing: f32,
216        overflow_wrap: bool,
217        font_features: &str,
218        font_variations: &str,
219        font_width: f32,
220    ) -> TextoMedido {
221        let estilo = EstiloTextoWire {
222            tamano_px: size_px,
223            alineacion: alineacion(alignment),
224            italica: italic,
225            familia: font_family.map(|s| s.to_string()),
226            alto_linea: line_height,
227            peso: weight,
228            max_lineas: max_lines.map(|n| n as u32),
229            elipsis: ellipsis,
230            subrayado: underline,
231            tachado: strikethrough,
232            espaciado_letra: letter_spacing,
233            espaciado_palabra: word_spacing,
234            // Redundante con `ancho_max == None` y se llena igual: el que
235            // reproduce usa `ancho_max`, pero el que INSPECCIONE la escena (un
236            // depurador, una traza) merece leer la intención y no deducirla.
237            sin_envolver: max_width.is_none(),
238            partir_palabra: overflow_wrap,
239            caracteristicas: font_features.to_string(),
240            variaciones: font_variations.to_string(),
241            ancho_fuente: font_width,
242            rangos: Vec::new(),
243        };
244        self.armar(text, estilo, None, max_width)
245    }
246
247    /// Espeja `Typesetter::layout_runs` — texto multicolor.
248    ///
249    /// Los `runs` (color por rango de bytes) se vuelcan a los rangos del IR con
250    /// sólo el color puesto: es exactamente lo que expresan, y el shaper del
251    /// otro lado los aplica igual que parley acá.
252    #[allow(clippy::too_many_arguments)]
253    pub fn layout_runs(
254        &mut self,
255        text: &str,
256        size_px: f32,
257        default_color: Color,
258        runs: &[(usize, usize, Color)],
259        alignment: Alignment,
260        line_height: f32,
261        weight: f32,
262        underline: bool,
263        strikethrough: bool,
264        font_family: Option<&str>,
265    ) -> TextoMedido {
266        let rangos = runs
267            .iter()
268            .map(|&(inicio, fin, color)| RangoWire {
269                inicio: inicio as u32,
270                fin: fin as u32,
271                estilo: EstiloRangoWire {
272                    color: Some(rgba(color)),
273                    ..Default::default()
274                },
275            })
276            .collect();
277        let estilo = EstiloTextoWire {
278            tamano_px: size_px,
279            alineacion: alineacion(alignment),
280            familia: font_family.map(|s| s.to_string()),
281            alto_linea: line_height,
282            peso: weight,
283            subrayado: underline,
284            tachado: strikethrough,
285            // `layout_runs` no envuelve (así está documentado del otro lado):
286            // shapea la línea entera de una y el caller la posiciona.
287            sin_envolver: true,
288            rangos,
289            ..Default::default()
290        };
291        self.armar(text, estilo, Some(default_color), None)
292    }
293
294    /// Espeja `Typesetter::layout_spans` — RichText.
295    #[allow(clippy::too_many_arguments)]
296    pub fn layout_spans(
297        &mut self,
298        text: &str,
299        size_px: f32,
300        default_color: Color,
301        weight: f32,
302        line_height: f32,
303        italic: bool,
304        font_family: Option<&str>,
305        underline: bool,
306        strikethrough: bool,
307        spans: &[TextSpan],
308        max_width: Option<f32>,
309        alignment: Alignment,
310    ) -> TextoMedido {
311        let rangos = spans
312            .iter()
313            .map(|s| RangoWire {
314                inicio: s.start as u32,
315                fin: s.end as u32,
316                estilo: EstiloRangoWire {
317                    tamano_px: s.style.size_px,
318                    peso: s.style.weight,
319                    italica: s.style.italic,
320                    familia: s.style.font_family.clone(),
321                    color: s.style.color.map(rgba),
322                    subrayado: s.style.underline,
323                    tachado: s.style.strikethrough,
324                },
325            })
326            .collect();
327        let estilo = EstiloTextoWire {
328            tamano_px: size_px,
329            alineacion: alineacion(alignment),
330            italica: italic,
331            familia: font_family.map(|s| s.to_string()),
332            alto_linea: line_height,
333            peso: weight,
334            subrayado: underline,
335            tachado: strikethrough,
336            sin_envolver: max_width.is_none(),
337            rangos,
338            ..Default::default()
339        };
340        self.armar(text, estilo, Some(default_color), max_width)
341    }
342
343    fn armar(
344        &mut self,
345        text: &str,
346        estilo: EstiloTextoWire,
347        color: Option<Color>,
348        ancho_max: Option<f32>,
349    ) -> TextoMedido {
350        let medida = self.medir(&Peticion {
351            cadena: text,
352            estilo: &estilo,
353            ancho_max,
354        });
355        TextoMedido {
356            cadena: text.to_string(),
357            estilo,
358            color,
359            ancho_max,
360            medida,
361        }
362    }
363}
364
365/// Lo que `layout_*` devuelve de este lado: el pedido, sin resolver.
366///
367/// Ocupa el lugar de `parley::Layout` en el flujo del compositor y expone los
368/// mismos tres consultores que éste usa. Que no haya un solo glifo adentro es el
369/// punto entero de W4.
370#[derive(Clone, Debug, PartialEq)]
371pub struct TextoMedido {
372    cadena: String,
373    estilo: EstiloTextoWire,
374    /// Color base cuando lo trajo el `layout_*` (runs/spans). El camino
375    /// uniforme lo recibe recién al dibujar.
376    color: Option<Color>,
377    ancho_max: Option<f32>,
378    medida: Medida,
379}
380
381impl TextoMedido {
382    /// Ancho de línea. Espeja `parley::Layout::width`.
383    pub fn width(&self) -> f32 {
384        self.medida.width
385    }
386
387    /// Ancho contando el espacio final. Espeja `parley::Layout::full_width`.
388    pub fn full_width(&self) -> f32 {
389        self.medida.full_width
390    }
391
392    /// Alto del bloque. Espeja `parley::Layout::height`.
393    pub fn height(&self) -> f32 {
394        self.medida.height
395    }
396
397    /// La cadena, sin shapear.
398    pub fn cadena(&self) -> &str {
399        &self.cadena
400    }
401
402    /// El estilo con el que hay que shapearla del otro lado.
403    pub fn estilo(&self) -> &EstiloTextoWire {
404        &self.estilo
405    }
406}
407
408/// La medida de un layout. Espeja `crate::measurement`.
409pub fn measurement(l: &TextoMedido) -> Measurement {
410    Measurement {
411        width: l.medida.width,
412        height: l.medida.height,
413    }
414}
415
416/// Ancho de una cadena SIN typesetter. Espeja `crate::text_width`.
417///
418/// Bajo `wire` no hay typesetter global al que preguntarle —el de este backend
419/// pregunta del otro lado de la frontera, y esta función no tiene por dónde—,
420/// así que responde con la ESTIMACIÓN de emergencia. Es deliberadamente honesto
421/// que exista así: los widgets que dimensionan su cromo con `text_width` (el
422/// ancho de un menú, la pestaña de una tab) obtienen un número aproximado, y el
423/// texto real se posiciona igual de bien porque el paint no pasa por acá — la
424/// medida buena la da el kernel vía el medidor de la jaula. Si la aproximación
425/// molesta en algún widget, el camino es pasarle el `Typesetter` de la jaula
426/// (`text_width_with`), no inventar un global.
427pub fn text_width(text: &str, size_px: f32) -> f32 {
428    let estilo = EstiloTextoWire {
429        tamano_px: size_px,
430        sin_envolver: true,
431        ..Default::default()
432    };
433    estimar(&Peticion {
434        cadena: text,
435        estilo: &estilo,
436        ancho_max: None,
437    })
438    .width
439}
440
441/// Ancho de una cadena. Espeja `crate::text_width_with`.
442pub fn text_width_with(ts: &mut Typesetter, text: &str, size_px: f32) -> f32 {
443    let estilo = EstiloTextoWire {
444        tamano_px: size_px,
445        sin_envolver: true,
446        ..Default::default()
447    };
448    ts.medir(&Peticion {
449        cadena: text,
450        estilo: &estilo,
451        ancho_max: None,
452    })
453    .width
454}
455
456// -----------------------------------------------------------------------------
457//  Las funciones de dibujo: acá el pedido se convierte en una orden del cable.
458// -----------------------------------------------------------------------------
459
460/// Espeja `crate::draw_layout_xf` — texto de color único.
461pub fn draw_layout_xf(
462    scene: &mut EscenaWire,
463    layout: &TextoMedido,
464    color: Color,
465    transform: peniko::kurbo::Affine,
466) {
467    emitir(scene, layout, color, transform);
468}
469
470/// Espeja `crate::draw_layout_brush_xf` — texto con un **pincel** (degradé o
471/// imagen), no un color plano.
472///
473/// El IR de escena lleva el color del texto y no un pincel entero: el degradé
474/// sobre glifos es lo único del DSL que el cable todavía no expresa. Se emite
475/// con el color que el pincel tenga más a mano —el primer stop de un degradé, o
476/// negro— en vez de no pintar nada: **un renglón del color equivocado se ve y se
477/// arregla; uno que falta se lee como que la app no cargó.**
478pub fn draw_layout_brush_xf(
479    scene: &mut EscenaWire,
480    layout: &TextoMedido,
481    brush: &peniko::Brush,
482    transform: peniko::kurbo::Affine,
483) {
484    let color = match brush {
485        peniko::Brush::Solid(c) => *c,
486        peniko::Brush::Gradient(g) => g.stops.first().map(|s| s.color.to_alpha_color()).unwrap_or(Color::BLACK),
487        _ => Color::BLACK,
488    };
489    emitir(scene, layout, color, transform);
490}
491
492/// Espeja `crate::measure` — mide sin emitir. Es `layout_block` + `measurement`,
493/// las dos ya de este lado; faltaba sólo el atajo.
494///
495/// Es la tercera de esta familia (con `draw_block` y `draw_layout_brush_xf`), y
496/// las tres fallan igual: el crate que las use compila **suelto** y no
497/// enjaulado, así que el hueco no se ve hasta que alguien intenta salir del
498/// proceso. Cuando aparezca una cuarta, el lugar es éste.
499pub fn measure(ts: &mut Typesetter, block: &crate::TextBlock<'_>) -> Measurement {
500    measurement(&layout_block(ts, block))
501}
502
503/// Espeja `crate::draw_block` — shapea una vez y emite, con el origen del
504/// bloque. Existía sólo del lado que shapea, y por eso una app que lo usara
505/// —el visor de mapas es la primera— compilaba suelta y **no** enjaulada. Es la
506/// misma clase de hueco que cerró F6b con las otras tres: no mueve un glifo, y
507/// sin ella el crate entero queda afuera del cable.
508pub fn draw_block(scene: &mut EscenaWire, ts: &mut Typesetter, block: &crate::TextBlock<'_>) {
509    let layout = layout_block(ts, block);
510    draw_layout(scene, &layout, block.color, block.origin);
511}
512
513/// Espeja `crate::draw_layout` — la variante con origen en vez de afín.
514pub fn draw_layout(
515    scene: &mut EscenaWire,
516    layout: &TextoMedido,
517    color: Color,
518    origin: (f64, f64),
519) {
520    emitir(
521        scene,
522        layout,
523        color,
524        peniko::kurbo::Affine::translate(origin),
525    );
526}
527
528/// Espeja `crate::draw_layout_runs` — multicolor, con origen en vez de afín.
529///
530/// Estaba la variante con afín y faltaba ésta. Es una línea, y por esa línea
531/// `text-input` no cruzaba.
532pub fn draw_layout_runs(scene: &mut EscenaWire, layout: &TextoMedido, origin: (f64, f64)) {
533    draw_layout_runs_xf(scene, layout, peniko::kurbo::Affine::translate(origin));
534}
535
536/// Espeja `crate::draw_layout_runs_xf` — multicolor y RichText.
537///
538/// El color base ya viajó en el `TextoMedido` (se lo pasaron a `layout_runs`/
539/// `layout_spans`); los rangos lo pisan donde corresponde.
540pub fn draw_layout_runs_xf(
541    scene: &mut EscenaWire,
542    layout: &TextoMedido,
543    transform: peniko::kurbo::Affine,
544) {
545    let color = layout.color.unwrap_or(Color::BLACK);
546    emitir(scene, layout, color, transform);
547}
548
549fn emitir(
550    scene: &mut EscenaWire,
551    layout: &TextoMedido,
552    color: Color,
553    transform: peniko::kurbo::Affine,
554) {
555    scene.texto(Texto {
556        cadena: layout.cadena.clone(),
557        estilo: layout.estilo.clone(),
558        color: rgba(color),
559        ancho_max: layout.ancho_max,
560        xf: UbicacionWire::de(transform),
561        // Fijo en `true` por la misma razón que en el backend CPU: a tamaños de
562        // UI ajustar los contornos a la grilla es la diferencia entre texto
563        // nítido y texto sucio, y no cuesta nada de este lado — lo hace el
564        // rasterizador nativo.
565        hint: true,
566    });
567}
568
569// -----------------------------------------------------------------------------
570
571fn rgba(c: Color) -> [u8; 4] {
572    let r = c.to_rgba8();
573    [r.r, r.g, r.b, r.a]
574}
575
576fn alineacion(a: Alignment) -> AlineacionWire {
577    match a {
578        Alignment::Start => AlineacionWire::Inicio,
579        Alignment::Center => AlineacionWire::Centro,
580        Alignment::End => AlineacionWire::Fin,
581        Alignment::Justify => AlineacionWire::Justificado,
582    }
583}
584
585/// La estimación de emergencia, para cuando no hay medidor.
586///
587/// No pretende ser buena: pretende ser **distinta de cero y contada**. Un cero
588/// colapsaría el layout sin un solo error —todas las cajas de texto medirían
589/// nada— y la pantalla saldría vacía «funcionando». 0,5 × tamaño es el ancho
590/// medio de un glifo proporcional a ojo; sirve para que el árbol tenga forma
591/// mientras alguien se olvidó de enchufar el medidor.
592fn estimar(p: &Peticion<'_>) -> Medida {
593    let chars = p.cadena.chars().count() as f32;
594    let ancho_glifo = p.estilo.tamano_px * 0.5;
595    let alto_linea = p.estilo.tamano_px * p.estilo.alto_linea;
596    let ancho_natural = chars * ancho_glifo;
597    match p.ancho_max {
598        Some(w) if w > 0.0 && ancho_natural > w => {
599            let lineas = (ancho_natural / w).ceil_estimado();
600            Medida {
601                width: w,
602                full_width: w,
603                height: alto_linea * lineas,
604            }
605        }
606        _ => Medida {
607            width: ancho_natural,
608            full_width: ancho_natural,
609            height: alto_linea,
610        },
611    }
612}
613
614/// `ceil` sin `std` y sin `libm`: la estimación no merece una dependencia.
615trait CeilEstimado {
616    fn ceil_estimado(self) -> f32;
617}
618
619impl CeilEstimado for f32 {
620    fn ceil_estimado(self) -> f32 {
621        let t = self as i64 as f32;
622        if self > t {
623            t + 1.0
624        } else {
625            t
626        }
627    }
628}
629
630/// Espeja `crate::layout_block` — construye el layout de un [`TextBlock`].
631///
632/// **Las tres constantes de abajo son las del backend que pinta, copiadas a
633/// propósito**, y son la parte que hay que cuidar: un `TextBlock` no transporta
634/// peso, ni decoración, ni espaciado —esos fluyen por el camino directo del
635/// compositor— así que acá se rellenan con los mismos valores que allá. Si un
636/// día cambian de un solo lado, el texto de una app enjaulada se acomodaría
637/// distinto que el de la misma app suelta, y el síntoma sería una caja de línea
638/// corrida: casi bien, que es lo peor.
639pub fn layout_block(ts: &mut Typesetter, block: &crate::TextBlock<'_>) -> TextoMedido {
640    ts.layout(
641        block.text,
642        block.size_px,
643        block.max_width,
644        block.alignment,
645        block.line_height,
646        block.italic,
647        block.font_family.as_deref(),
648        // Peso: `TextBlock` no lo transporta (su API queda en normal).
649        400.0,
650        // Decoración: tampoco viaja por `TextBlock`.
651        false,
652        false,
653        // Espaciado de letra y palabra: ídem.
654        0.0,
655        0.0,
656    )
657}
658
659#[cfg(test)]
660mod pruebas {
661    use super::*;
662
663    fn ts_con_medida(w: f32, h: f32) -> Typesetter {
664        Typesetter::con_medidor(move |_p: &Peticion<'_>| Medida {
665            width: w,
666            full_width: w,
667            height: h,
668        })
669    }
670
671    /// **`layout` es `layout_clamped` sin clamp, y nada más.** Se fija porque los
672    /// valores neutros tienen que ser los mismos que usa el backend que pinta: si
673    /// acá se colara un `overflow_wrap: true` o un ancho de fuente distinto, una
674    /// app enjaulada se acomodaría distinto que la misma app suelta.
675    #[test]
676    fn layout_es_el_clamped_sin_clampear() {
677        let mut a = ts_con_medida(40.0, 12.0);
678        let mut b = ts_con_medida(40.0, 12.0);
679        let uno = a.layout("hola", 12.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
680        let otro = b.layout_clamped(
681            "hola", 12.0, None, Alignment::Start, 1.2, false, None, 400.0,
682            None, false, false, false, 0.0, 0.0, false, "", "", 100.0,
683        );
684        assert_eq!(uno.estilo(), otro.estilo(), "el estilo tiene que salir idéntico");
685        assert_eq!(uno.cadena(), otro.cadena());
686        assert_eq!(uno.width(), otro.width());
687        assert_eq!(uno.height(), otro.height());
688    }
689
690    /// **Brazo de control**: si el clamp SÍ se pide, el estilo cambia. Sin esto,
691    /// un `layout_clamped` que ignorara sus argumentos dejaría verde el test de
692    /// arriba y no probaría nada.
693    #[test]
694    fn con_clamp_el_estilo_deja_de_ser_el_mismo() {
695        let mut a = ts_con_medida(40.0, 12.0);
696        let mut b = ts_con_medida(40.0, 12.0);
697        let sin = a.layout("hola", 12.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
698        let con = b.layout_clamped(
699            "hola", 12.0, None, Alignment::Start, 1.2, false, None, 400.0,
700            Some(2), true, false, false, 0.0, 0.0, false, "", "", 100.0,
701        );
702        assert_ne!(sin.estilo(), con.estilo(), "pedir dos líneas con elipsis NO es lo mismo");
703    }
704
705    /// `layout_block` rellena peso, decoración y espaciado con **las constantes
706    /// del backend que pinta**. Es lo único de las tres que se puede desincronizar
707    /// en silencio, así que se fija por número.
708    #[test]
709    fn layout_block_usa_las_constantes_del_lado_que_pinta() {
710        let mut ts = ts_con_medida(30.0, 14.0);
711        let bloque = crate::TextBlock {
712            text: "hola",
713            size_px: 13.0,
714            color: Color::BLACK,
715            origin: (0.0, 0.0),
716            max_width: Some(200.0),
717            alignment: Alignment::Center,
718            line_height: 1.4,
719            italic: true,
720            font_family: None,
721        };
722        let l = layout_block(&mut ts, &bloque);
723        let e = l.estilo();
724        assert_eq!(e.peso, 400.0, "un TextBlock no transporta peso: normal");
725        assert!(!e.subrayado && !e.tachado, "ni decoración");
726        assert_eq!(e.espaciado_letra, 0.0);
727        assert_eq!(e.espaciado_palabra, 0.0);
728        // Y lo que SÍ transporta llega entero.
729        assert_eq!(e.tamano_px, 13.0);
730        assert!(e.italica);
731        assert_eq!(l.cadena(), "hola");
732    }
733
734    /// La variante con origen es la de la afín con una traslación. Se fija
735    /// comparando la orden que sale al cable.
736    #[test]
737    fn draw_con_origen_es_la_afin_trasladada() {
738        let mut ts = ts_con_medida(20.0, 10.0);
739        let l = ts.layout("x", 11.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
740
741        let mut por_origen = EscenaWire::new();
742        draw_layout_runs(&mut por_origen, &l, (7.0, 3.0));
743        let mut por_afin = EscenaWire::new();
744        draw_layout_runs_xf(&mut por_afin, &l, peniko::kurbo::Affine::translate((7.0, 3.0)));
745
746        assert_eq!(por_origen.ordenes().len(), 1);
747        assert_eq!(por_origen.ordenes(), por_afin.ordenes(), "misma orden, mismo lugar");
748    }
749}