tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
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` — la variante con origen en vez de afín.
471pub fn draw_layout(
472 scene: &mut EscenaWire,
473 layout: &TextoMedido,
474 color: Color,
475 origin: (f64, f64),
476) {
477 emitir(
478 scene,
479 layout,
480 color,
481 peniko::kurbo::Affine::translate(origin),
482 );
483}
484
485/// Espeja `crate::draw_layout_runs` — multicolor, con origen en vez de afín.
486///
487/// Estaba la variante con afín y faltaba ésta. Es una línea, y por esa línea
488/// `text-input` no cruzaba.
489pub fn draw_layout_runs(scene: &mut EscenaWire, layout: &TextoMedido, origin: (f64, f64)) {
490 draw_layout_runs_xf(scene, layout, peniko::kurbo::Affine::translate(origin));
491}
492
493/// Espeja `crate::draw_layout_runs_xf` — multicolor y RichText.
494///
495/// El color base ya viajó en el `TextoMedido` (se lo pasaron a `layout_runs`/
496/// `layout_spans`); los rangos lo pisan donde corresponde.
497pub fn draw_layout_runs_xf(
498 scene: &mut EscenaWire,
499 layout: &TextoMedido,
500 transform: peniko::kurbo::Affine,
501) {
502 let color = layout.color.unwrap_or(Color::BLACK);
503 emitir(scene, layout, color, transform);
504}
505
506fn emitir(
507 scene: &mut EscenaWire,
508 layout: &TextoMedido,
509 color: Color,
510 transform: peniko::kurbo::Affine,
511) {
512 scene.texto(Texto {
513 cadena: layout.cadena.clone(),
514 estilo: layout.estilo.clone(),
515 color: rgba(color),
516 ancho_max: layout.ancho_max,
517 xf: UbicacionWire::de(transform),
518 // Fijo en `true` por la misma razón que en el backend CPU: a tamaños de
519 // UI ajustar los contornos a la grilla es la diferencia entre texto
520 // nítido y texto sucio, y no cuesta nada de este lado — lo hace el
521 // rasterizador nativo.
522 hint: true,
523 });
524}
525
526// -----------------------------------------------------------------------------
527
528fn rgba(c: Color) -> [u8; 4] {
529 let r = c.to_rgba8();
530 [r.r, r.g, r.b, r.a]
531}
532
533fn alineacion(a: Alignment) -> AlineacionWire {
534 match a {
535 Alignment::Start => AlineacionWire::Inicio,
536 Alignment::Center => AlineacionWire::Centro,
537 Alignment::End => AlineacionWire::Fin,
538 Alignment::Justify => AlineacionWire::Justificado,
539 }
540}
541
542/// La estimación de emergencia, para cuando no hay medidor.
543///
544/// No pretende ser buena: pretende ser **distinta de cero y contada**. Un cero
545/// colapsaría el layout sin un solo error —todas las cajas de texto medirían
546/// nada— y la pantalla saldría vacía «funcionando». 0,5 × tamaño es el ancho
547/// medio de un glifo proporcional a ojo; sirve para que el árbol tenga forma
548/// mientras alguien se olvidó de enchufar el medidor.
549fn estimar(p: &Peticion<'_>) -> Medida {
550 let chars = p.cadena.chars().count() as f32;
551 let ancho_glifo = p.estilo.tamano_px * 0.5;
552 let alto_linea = p.estilo.tamano_px * p.estilo.alto_linea;
553 let ancho_natural = chars * ancho_glifo;
554 match p.ancho_max {
555 Some(w) if w > 0.0 && ancho_natural > w => {
556 let lineas = (ancho_natural / w).ceil_estimado();
557 Medida {
558 width: w,
559 full_width: w,
560 height: alto_linea * lineas,
561 }
562 }
563 _ => Medida {
564 width: ancho_natural,
565 full_width: ancho_natural,
566 height: alto_linea,
567 },
568 }
569}
570
571/// `ceil` sin `std` y sin `libm`: la estimación no merece una dependencia.
572trait CeilEstimado {
573 fn ceil_estimado(self) -> f32;
574}
575
576impl CeilEstimado for f32 {
577 fn ceil_estimado(self) -> f32 {
578 let t = self as i64 as f32;
579 if self > t {
580 t + 1.0
581 } else {
582 t
583 }
584 }
585}
586
587/// Espeja `crate::layout_block` — construye el layout de un [`TextBlock`].
588///
589/// **Las tres constantes de abajo son las del backend que pinta, copiadas a
590/// propósito**, y son la parte que hay que cuidar: un `TextBlock` no transporta
591/// peso, ni decoración, ni espaciado —esos fluyen por el camino directo del
592/// compositor— así que acá se rellenan con los mismos valores que allá. Si un
593/// día cambian de un solo lado, el texto de una app enjaulada se acomodaría
594/// distinto que el de la misma app suelta, y el síntoma sería una caja de línea
595/// corrida: casi bien, que es lo peor.
596pub fn layout_block(ts: &mut Typesetter, block: &crate::TextBlock<'_>) -> TextoMedido {
597 ts.layout(
598 block.text,
599 block.size_px,
600 block.max_width,
601 block.alignment,
602 block.line_height,
603 block.italic,
604 block.font_family.as_deref(),
605 // Peso: `TextBlock` no lo transporta (su API queda en normal).
606 400.0,
607 // Decoración: tampoco viaja por `TextBlock`.
608 false,
609 false,
610 // Espaciado de letra y palabra: ídem.
611 0.0,
612 0.0,
613 )
614}
615
616#[cfg(test)]
617mod pruebas {
618 use super::*;
619
620 fn ts_con_medida(w: f32, h: f32) -> Typesetter {
621 Typesetter::con_medidor(move |_p: &Peticion<'_>| Medida {
622 width: w,
623 full_width: w,
624 height: h,
625 })
626 }
627
628 /// **`layout` es `layout_clamped` sin clamp, y nada más.** Se fija porque los
629 /// valores neutros tienen que ser los mismos que usa el backend que pinta: si
630 /// acá se colara un `overflow_wrap: true` o un ancho de fuente distinto, una
631 /// app enjaulada se acomodaría distinto que la misma app suelta.
632 #[test]
633 fn layout_es_el_clamped_sin_clampear() {
634 let mut a = ts_con_medida(40.0, 12.0);
635 let mut b = ts_con_medida(40.0, 12.0);
636 let uno = a.layout("hola", 12.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
637 let otro = b.layout_clamped(
638 "hola", 12.0, None, Alignment::Start, 1.2, false, None, 400.0,
639 None, false, false, false, 0.0, 0.0, false, "", "", 100.0,
640 );
641 assert_eq!(uno.estilo(), otro.estilo(), "el estilo tiene que salir idéntico");
642 assert_eq!(uno.cadena(), otro.cadena());
643 assert_eq!(uno.width(), otro.width());
644 assert_eq!(uno.height(), otro.height());
645 }
646
647 /// **Brazo de control**: si el clamp SÍ se pide, el estilo cambia. Sin esto,
648 /// un `layout_clamped` que ignorara sus argumentos dejaría verde el test de
649 /// arriba y no probaría nada.
650 #[test]
651 fn con_clamp_el_estilo_deja_de_ser_el_mismo() {
652 let mut a = ts_con_medida(40.0, 12.0);
653 let mut b = ts_con_medida(40.0, 12.0);
654 let sin = a.layout("hola", 12.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
655 let con = b.layout_clamped(
656 "hola", 12.0, None, Alignment::Start, 1.2, false, None, 400.0,
657 Some(2), true, false, false, 0.0, 0.0, false, "", "", 100.0,
658 );
659 assert_ne!(sin.estilo(), con.estilo(), "pedir dos líneas con elipsis NO es lo mismo");
660 }
661
662 /// `layout_block` rellena peso, decoración y espaciado con **las constantes
663 /// del backend que pinta**. Es lo único de las tres que se puede desincronizar
664 /// en silencio, así que se fija por número.
665 #[test]
666 fn layout_block_usa_las_constantes_del_lado_que_pinta() {
667 let mut ts = ts_con_medida(30.0, 14.0);
668 let bloque = crate::TextBlock {
669 text: "hola",
670 size_px: 13.0,
671 color: Color::BLACK,
672 origin: (0.0, 0.0),
673 max_width: Some(200.0),
674 alignment: Alignment::Center,
675 line_height: 1.4,
676 italic: true,
677 font_family: None,
678 };
679 let l = layout_block(&mut ts, &bloque);
680 let e = l.estilo();
681 assert_eq!(e.peso, 400.0, "un TextBlock no transporta peso: normal");
682 assert!(!e.subrayado && !e.tachado, "ni decoración");
683 assert_eq!(e.espaciado_letra, 0.0);
684 assert_eq!(e.espaciado_palabra, 0.0);
685 // Y lo que SÍ transporta llega entero.
686 assert_eq!(e.tamano_px, 13.0);
687 assert!(e.italica);
688 assert_eq!(l.cadena(), "hola");
689 }
690
691 /// La variante con origen es la de la afín con una traslación. Se fija
692 /// comparando la orden que sale al cable.
693 #[test]
694 fn draw_con_origen_es_la_afin_trasladada() {
695 let mut ts = ts_con_medida(20.0, 10.0);
696 let l = ts.layout("x", 11.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
697
698 let mut por_origen = EscenaWire::new();
699 draw_layout_runs(&mut por_origen, &l, (7.0, 3.0));
700 let mut por_afin = EscenaWire::new();
701 draw_layout_runs_xf(&mut por_afin, &l, peniko::kurbo::Affine::translate((7.0, 3.0)));
702
703 assert_eq!(por_origen.ordenes().len(), 1);
704 assert_eq!(por_origen.ordenes(), por_afin.ordenes(), "misma orden, mismo lugar");
705 }
706}