tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1
2// Preludio de `alloc`: bajo `no_std` estos tipos ya no vienen solos.
3#[allow(unused_imports)]
4use alloc::{boxed::Box, string::{String, ToString}, vec::Vec};
5use super::*;
6
7impl<Msg> View<Msg> {
8 pub fn new(style: Style) -> Self {
9 Self {
10 style,
11 fill: None,
12 hover_fill: None,
13 radius: 0.0,
14 over_layer: false,
15 on_click: None,
16 clip: false,
17 children: Vec::new(),
18 text: None,
19 painter: None,
20 alpha: None,
21 semantics: None,
22 // Props raras: nadie las paga hasta que alguien las usa.
23 rare: None,
24 }
25 }
26
27 /// Acceso mutable a las props raras, creándolas si es el primer uso.
28 ///
29 /// Todos los setters de props raras pasan por acá. El nodo que no usa
30 /// ninguna nunca asigna el `Box` — que es el punto entero del reparto (ver
31 /// [`ViewRare`]).
32 fn raro(&mut self) -> &mut ViewRare<Msg> {
33 self.rare.get_or_insert_with(|| Box::new(ViewRare::default()))
34 }
35
36 /// Igual que [`Self::raro`] pero público: para el código que tiene un
37 /// `&mut View` ya construido y necesita escribirle una prop rara sin poder
38 /// encadenar el builder (que consume `self`).
39 ///
40 /// La lectura simétrica es el macro [`crate::raro!`].
41 pub fn raro_mut(&mut self) -> &mut ViewRare<Msg> {
42 self.raro()
43 }
44
45 /// Aplica un **backdrop blur** Gaussiano al contenido pintado **debajo**
46 /// de este nodo, restringido al rect del nodo (CSS `backdrop-filter:
47 /// blur(N)` / Flutter `BackdropFilter`). El runtime descompone el árbol
48 /// en "fondo + subárbol del nodo", renderiza el fondo a la intermediate,
49 /// borronea el rect con un Gauss separable, y compone el subárbol del
50 /// nodo sobre el backdrop borroso vía un buffer secundario. Útil para
51 /// chrome translúcido: sidebars/topbars con "vidrio esmerilado".
52 ///
53 /// `sigma` (pixels) controla el ancho del kernel — `4.0` "frosted glass"
54 /// suave; `8.0`–`16.0` un blur fuerte; >`20` se ve apagado. v1 capa el
55 /// radius efectivo a 32 pixels (sigma > 10 empieza a clipear cola).
56 ///
57 /// **Limitación v1**: sólo nodos top-level (children directos del root o
58 /// de un agrupador sin `clip`/`alpha`) renderizan correctamente — un
59 /// nodo dentro de una capa clippeada se pinta SIN clip en su pase, por
60 /// el reset de layer-stack al cambiar de scene. Documentado en
61 /// `PARIDAD-FLUTTER.md` Bloque 11.
62 pub fn backdrop_blur(mut self, sigma: f32) -> Self {
63 self.raro().backdrop_blur = Some(sigma.max(0.0));
64 self
65 }
66
67 /// Aplica una lista de **filtros CSS** (`filter: …`) al **propio subárbol**
68 /// del nodo (a diferencia de [`Self::backdrop_blur`], que afecta lo pintado
69 /// *debajo*). El runtime los recolecta con [`collect_filters`] y los aplica
70 /// como post-pasada GPU sobre la intermediate, restringidos al rect del
71 /// nodo, en el orden de la lista. Hoy sólo se modela `blur` ([`FilterOp`]);
72 /// la lista crece por fase. Es **ortogonal** a clip/mask (un nodo puede
73 /// llevar todos). Lista vacía = sin filtro. Fase 7.1232.
74 ///
75 /// **Limitación v1** (igual que `backdrop_blur`): la post-pasada opera sobre
76 /// los píxeles finales del rect, así que no aísla el subárbol del fondo que
77 /// asome detrás. Adecuado para nodos opacos.
78 pub fn filter(mut self, ops: Vec<FilterOp>) -> Self {
79 self.raro().filter = ops;
80 self
81 }
82
83 /// Mezcla el **nodo entero** (su subárbol) contra su backdrop con el modo
84 /// `bm` (CSS `mix-blend-mode`). El runtime abre una capa aislada
85 /// (`push_layer(bm, …)`) alrededor del rect del nodo que envuelve fill +
86 /// contenido + hijos; al cerrarla, el subárbol se compone contra todo lo
87 /// pintado antes en el stacking context según `bm` (p. ej. `Mix::Multiply`).
88 /// Es **ortogonal** a clip/mask/filter/alpha (un nodo puede llevar todos).
89 /// Fase 7.1237.
90 ///
91 /// **Limitación v1** (igual que mask/filter): el backdrop es lo que ya está
92 /// pintado en la escena, no un fondo aislado del subárbol — exacto cuando
93 /// debajo hay contenido opaco, aproximado si la capa de abajo es el padre.
94 pub fn blend(mut self, bm: BlendMode) -> Self {
95 self.raro().blend = Some(bm);
96 self
97 }
98
99 /// Marca este nodo como un **límite de repintado** (`RepaintBoundary`,
100 /// Bloque 23): el runtime puede cachear la rasterización de su subárbol y
101 /// reusarla entre frames mientras el contenido no cambie, en vez de
102 /// re-pintarlo. Sólo surte efecto si el subárbol es *paint-puro* y está en
103 /// una posición sin transform/clip/alpha ancestral (si no, se pinta fresco
104 /// — nunca produce un frame incorrecto, sólo se saltea la optimización).
105 /// `key` debe ser **estable entre rebuilds** (identifica la entrada del
106 /// caché). Pensalo para subárboles grandes y estáticos: una página de
107 /// documento, un chart, un panel de chrome que no cambia. Ver
108 /// [`crate::RepaintCache`].
109 pub fn repaint_boundary(mut self, key: u64) -> Self {
110 self.raro().repaint_boundary = Some(key);
111 self
112 }
113
114 /// Marca este nodo como **memoizado** (`memo`, Bloque 25): su contenido lo
115 /// produce `builder`, y el runtime lo **reusa entre frames** mientras `deps`
116 /// no cambie — sin reconstruir el `View`, sin re-montar ni re-layoutear el
117 /// subárbol. `builder` devuelve un único `View` (el contenido que va dentro
118 /// de este nodo). El nodo debe llevar un `Style` con **tamaño definido**
119 /// (length/percent): el runtime lo trata como una hoja de ese tamaño y
120 /// computa el subárbol aislado con ese slot. `deps` debe resumir TODO lo que
121 /// el contenido depende (si te olvidas una dep, el subárbol queda stale).
122 /// Pensalo para chrome grande y estable: un panel, un sidebar, un formulario
123 /// que no cambia salvo un valor. Los `children` estáticos se ignoran.
124 pub fn memo<F>(mut self, key: u64, deps: u64, builder: F) -> Self
125 where
126 F: Fn() -> View<Msg> + Send + Sync + 'static,
127 {
128 self.raro().memo = Some(crate::MemoNode {
129 key,
130 deps,
131 builder: Some(alloc::sync::Arc::new(builder)),
132 });
133 self
134 }
135
136 /// Construye los hijos de este nodo **de forma diferida**, en función del
137 /// tamaño del slot que el layout le asigne (Flutter `LayoutBuilder`). El
138 /// runtime resuelve primero el rect del nodo (una pasada de layout con este
139 /// nodo como hoja, sized por su `Style`/contexto flex) y recién entonces
140 /// invoca `builder(Constraints)` para producir el subárbol — habilitando
141 /// paneles responsive cuyo punto de quiebre depende del **espacio local**,
142 /// no de la ventana (para eso alcanza `on_resize` + el Model).
143 ///
144 /// El `Style` de este nodo define su tamaño (debe quedar acotado por el
145 /// contexto: `flex_grow`, `size` definido o `percent` — no intrínseco a los
146 /// hijos, que aún no existen). Cualquier `children` estático que se haya
147 /// seteado se ignora: el builder es la fuente de los hijos.
148 ///
149 /// **Límite v1**: sin anidamiento — un `layout_builder` dentro del subárbol
150 /// que produce otro `layout_builder` no se resuelve (queda como hoja).
151 pub fn layout_builder<F>(mut self, builder: F) -> Self
152 where
153 F: Fn(Constraints) -> View<Msg> + Send + Sync + 'static,
154 {
155 self.raro().layout_builder = Some(Arc::new(builder));
156 self
157 }
158
159 /// Marca este nodo para emitir un **ripple/InkWell** (la salpicadura de tap
160 /// de Material) al recibir un press: un círculo que se expande desde el
161 /// punto presionado y se desvanece, recortado al contorno del nodo. `key`
162 /// debe ser **estable** entre rebuilds del `View` (índice/hash del item),
163 /// igual que la key de [`Self::animated`]. `color` es el tinte de la onda —
164 /// usa un color semitransparente (blanco a alpha ~0.25 sobre superficies
165 /// oscuras, negro a alpha ~0.12 sobre claras); su alpha se atenúa con el
166 /// fade. Es **aditivo**: convive con `on_click`/`drag` sin pisarlos. Duración
167 /// por defecto 450 ms; para otra usar [`Self::ripple_styled`].
168 pub fn ripple(self, key: u64, color: Color) -> Self {
169 self.ripple_styled(key, color, core::time::Duration::from_millis(450))
170 }
171
172 /// Como [`Self::ripple`] pero con la duración explícita de la salpicadura.
173 pub fn ripple_styled(
174 mut self,
175 key: u64,
176 color: Color,
177 duration: core::time::Duration,
178 ) -> Self {
179 self.raro().ripple = Some(Ripple { key, color, duration });
180 self
181 }
182
183 /// Fija la forma del puntero del mouse mientras el cursor está sobre este
184 /// nodo (o un descendiente que no declare la suya — se hereda del ancestro
185 /// más cercano que la tenga). El runtime la resuelve en el hit-test de hover
186 /// y la aplica a la ventana. Ejemplos: `.cursor(Cursor::Text)` en un input,
187 /// `.cursor(Cursor::ColResize)` en un divisor de splitter,
188 /// `.cursor(Cursor::Pointer)` en un botón.
189 pub fn cursor(mut self, cursor: Cursor) -> Self {
190 self.raro().cursor = Some(cursor);
191 self
192 }
193
194 /// Asocia un texto de **tooltip** a este nodo. Llimphi sólo lo transporta
195 /// hasta el [`MountedNode`](crate::MountedNode); el consumidor decide cómo
196 /// mostrarlo (un overlay del runtime, una surface popup del cliente) tras
197 /// localizar el nodo bajo el cursor con el hit-test de hover.
198 pub fn tooltip(mut self, text: impl Into<String>) -> Self {
199 self.raro().tooltip = Some(text.into());
200 self
201 }
202
203 /// Declara la **semántica accesible** completa del nodo de una vez. Usar
204 /// cuando ya tienes un [`SemanticsSpec`] armado (p. ej. construido por un
205 /// widget); para los casos puntuales preferí los atajos
206 /// [`Self::role`]/[`Self::aria_label`]/etc.
207 pub fn semantics(mut self, spec: SemanticsSpec) -> Self {
208 self.semantics = Some(spec);
209 self
210 }
211
212 /// Fija el **rol** semántico del nodo. Si ya había semántica declarada,
213 /// preserva label/value/flags y sólo sobreescribe el rol; si no, crea una
214 /// `SemanticsSpec` con sólo el rol.
215 pub fn role(mut self, role: Role) -> Self {
216 self.semantics = Some(match self.semantics.take() {
217 Some(mut s) => {
218 s.role = Some(role);
219 s
220 }
221 None => SemanticsSpec::role(role),
222 });
223 self
224 }
225
226 /// Fija el **label accesible** ("nombre" que el lector enuncia). Hace falta
227 /// cuando el contenido visible del nodo no alcanza (p. ej. un botón con
228 /// sólo un ícono). Preserva el resto de la `SemanticsSpec` si existía.
229 pub fn aria_label(mut self, label: impl Into<alloc::sync::Arc<str>>) -> Self {
230 let mut s = self.semantics.take().unwrap_or_default();
231 s.label = Some(label.into());
232 self.semantics = Some(s);
233 self
234 }
235
236 /// Fija la **descripción** (contexto adicional que el lector enuncia tras
237 /// el label, típicamente con un atajo). Para info que ayuda pero no es el
238 /// nombre principal — no abusar (los lectores perciben ruido).
239 pub fn aria_description(mut self, desc: impl Into<alloc::sync::Arc<str>>) -> Self {
240 let mut s = self.semantics.take().unwrap_or_default();
241 s.description = Some(desc.into());
242 self.semantics = Some(s);
243 self
244 }
245
246 /// Fija el **valor** (texto del input, valor del slider/spinner). Lo que
247 /// el lector lee después del label: "Volumen, 70".
248 pub fn aria_value(mut self, value: impl Into<alloc::sync::Arc<str>>) -> Self {
249 let mut s = self.semantics.take().unwrap_or_default();
250 s.value = Some(value.into());
251 self.semantics = Some(s);
252 self
253 }
254
255 /// Estado `checked` (checkbox/radio).
256 pub fn aria_checked(mut self, v: bool) -> Self {
257 let mut s = self.semantics.take().unwrap_or_default();
258 s.flags.checked = Some(v);
259 self.semantics = Some(s);
260 self
261 }
262
263 /// Estado `pressed` (toggle button).
264 pub fn aria_pressed(mut self, v: bool) -> Self {
265 let mut s = self.semantics.take().unwrap_or_default();
266 s.flags.pressed = Some(v);
267 self.semantics = Some(s);
268 self
269 }
270
271 /// Estado `expanded` (acordeón, menú abierto, tree row expandida).
272 pub fn aria_expanded(mut self, v: bool) -> Self {
273 let mut s = self.semantics.take().unwrap_or_default();
274 s.flags.expanded = Some(v);
275 self.semantics = Some(s);
276 self
277 }
278
279 /// Estado `disabled` — el control no responde a input.
280 pub fn aria_disabled(mut self, v: bool) -> Self {
281 let mut s = self.semantics.take().unwrap_or_default();
282 s.flags.disabled = Some(v);
283 self.semantics = Some(s);
284 self
285 }
286
287 /// Estado `readonly` — el control es visible/seleccionable pero no editable.
288 pub fn aria_readonly(mut self, v: bool) -> Self {
289 let mut s = self.semantics.take().unwrap_or_default();
290 s.flags.readonly = Some(v);
291 self.semantics = Some(s);
292 self
293 }
294
295 /// Estado `required` (campo de formulario obligatorio).
296 pub fn aria_required(mut self, v: bool) -> Self {
297 let mut s = self.semantics.take().unwrap_or_default();
298 s.flags.required = Some(v);
299 self.semantics = Some(s);
300 self
301 }
302
303 /// Registra un handler de rueda local: si el cursor está sobre este
304 /// nodo cuando la rueda gira, el runtime lo invoca con el delta
305 /// `(dx, dy)` en líneas lógicas ANTES de caer al `App::on_wheel`
306 /// global. Devolver `Some(Msg)` consume el evento. Es la base de las
307 /// áreas de scroll autocontenidas (`llimphi-widget-scroll`).
308 pub fn on_scroll<F>(mut self, handler: F) -> Self
309 where
310 F: Fn(f32, f32) -> Option<Msg> + Send + Sync + 'static,
311 {
312 self.raro().on_scroll = Some(Arc::new(handler));
313 self
314 }
315
316 /// Como [`Self::on_scroll`] pero con **reparto parcial** del delta
317 /// (scroll anidado real): el handler devuelve `(msg, rest_x, rest_y)` —
318 /// el `Msg` por lo que consumió y el sobrante en la misma unidad
319 /// (líneas), que el runtime entrega al siguiente scrollable de la cadena.
320 /// Así una lista casi topada absorbe los píxeles que le quedan Y el
321 /// ancestro scrollea el resto en el **mismo evento** (antes: consume
322 /// todo o nada por eje). Si el nodo tiene ambos handlers, gana éste.
323 pub fn on_scroll_take<F>(mut self, handler: F) -> Self
324 where
325 F: Fn(f32, f32) -> (Option<Msg>, f32, f32) + Send + Sync + 'static,
326 {
327 self.raro().on_scroll_take = Some(Arc::new(handler));
328 self
329 }
330
331 /// Registra un handler de **pinch-to-zoom** (gesto de escala). El runtime
332 /// lo invoca cuando el cursor está sobre este nodo y el usuario hace un
333 /// gesto de escala: **Ctrl + rueda** en cualquier desktop (camino
334 /// universal) o un pinch de trackpad en macOS. El handler recibe
335 /// `(phase, factor, focal_x, focal_y)` — ver [`ScaleFn`]: `factor` es el
336 /// cambio multiplicativo incremental (`>1` agranda, `<1` achica) y
337 /// `(focal_x, focal_y)` es el punto bajo el cursor relativo al rect del
338 /// nodo, para zoomear "hacia el cursor". El típico patrón de canvas:
339 /// `Msg::Zoom { factor, fx, fy }` que multiplica la escala del viewport y
340 /// reajusta el pan para mantener el punto focal fijo. Devolver `Some(Msg)`
341 /// consume el gesto (no cae al scroll/`on_wheel`).
342 pub fn on_scale<F>(mut self, handler: F) -> Self
343 where
344 F: Fn(GesturePhase, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
345 {
346 self.raro().on_scale = Some(Arc::new(handler));
347 self
348 }
349
350 /// Registra un handler de **rotación con dos dedos** (gesto de trackpad).
351 /// El runtime lo invoca cuando el cursor está sobre este nodo y el usuario
352 /// rota dos dedos en el trackpad (winit emite `RotationGesture` **sólo en
353 /// macOS**). El handler recibe `(phase, delta_radianes, focal_x, focal_y)`
354 /// — ver [`RotateFn`]: `delta_radianes` es el incremento angular (positivo
355 /// = horario) y `(focal_x, focal_y)` el punto bajo el cursor relativo al
356 /// rect del nodo, para rotar "alrededor del cursor". Patrón típico de
357 /// canvas/imagen: `Msg::Rotate { delta, fx, fy }` que acumula el ángulo del
358 /// viewport. Devolver `Some(Msg)` consume el gesto.
359 pub fn on_rotate<F>(mut self, handler: F) -> Self
360 where
361 F: Fn(GesturePhase, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
362 {
363 self.raro().on_rotate = Some(Arc::new(handler));
364 self
365 }
366
367 /// Emite `msg` en **doble-tap** (dos clicks izquierdos rápidos y cercanos
368 /// sobre este nodo). Aditivo respecto de `on_click`. Ver
369 /// [`Self::on_double_tap`](#structfield.on_double_tap) (campo) para la
370 /// semántica completa; para la posición del tap usar
371 /// [`Self::on_double_tap_at`].
372 pub fn on_double_tap(mut self, msg: Msg) -> Self {
373 self.raro().on_double_tap = Some(msg);
374 self
375 }
376
377 /// Como [`Self::on_double_tap`] pero el handler recibe la posición del
378 /// segundo tap relativa al rect del nodo `(lx, ly, w, h)` — para
379 /// zoom-to-point o seleccionar la entidad bajo el cursor. Gana sobre
380 /// `on_double_tap` si ambos están.
381 pub fn on_double_tap_at<F>(mut self, handler: F) -> Self
382 where
383 F: Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
384 {
385 self.raro().on_double_tap_at = Some(Arc::new(handler));
386 self
387 }
388
389 /// Hace que el doble-tap de este nodo **arbitre contra su propio click** en
390 /// vez de sumarse a él: el runtime retiene el click del primer tap hasta que
391 /// vence la ventana del doble y, si llega el segundo, lo descarta. Un
392 /// doble-click dispara entonces el `Msg` del doble **y nada más**.
393 ///
394 /// Cuesta latencia (el click de un toque suelto sale al vencer la ventana,
395 /// no al soltar), así que es opt-in: sin esto el doble-tap sigue siendo
396 /// aditivo, que es lo que quiere una fila que selecciona con un click y abre
397 /// con dos. Ver [`Self::double_tap_exclusive`](#structfield.double_tap_exclusive).
398 pub fn double_tap_exclusive(mut self) -> Self {
399 self.raro().double_tap_exclusive = Some(());
400 self
401 }
402
403 /// Emite `msg` en **long-press** (mantener el botón ~500 ms sin moverse).
404 /// El runtime lo cancela si el cursor se aleja (pasó a drag) o se suelta
405 /// antes. Aditivo respecto de `on_click`/`drag`. Ver
406 /// [`Self::on_long_press`](#structfield.on_long_press) (campo); para la
407 /// posición usar [`Self::on_long_press_at`].
408 pub fn on_long_press(mut self, msg: Msg) -> Self {
409 self.raro().on_long_press = Some(msg);
410 self
411 }
412
413 /// Como [`Self::on_long_press`] pero el handler recibe la posición del
414 /// press relativa al rect del nodo `(lx, ly, w, h)` — para abrir un menú
415 /// contextual en el punto. Gana sobre `on_long_press` si ambos están.
416 pub fn on_long_press_at<F>(mut self, handler: F) -> Self
417 where
418 F: Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
419 {
420 self.raro().on_long_press_at = Some(Arc::new(handler));
421 self
422 }
423
424 /// Marca este nodo como enfocable con el id opaco `id`. El runtime lo
425 /// incluye en el orden de Tab (pre-orden del árbol) y le da foco al
426 /// clickearlo; cada cambio de foco se notifica vía `App::on_focus`.
427 /// El caller pinta el focus-ring comparando el id contra el foco que
428 /// guardó en su `Model`.
429 pub fn focusable(mut self, id: u64) -> Self {
430 self.raro().focusable = Some(id);
431 self
432 }
433
434 /// Marca este nodo de **texto** como seleccionable con el mouse fuera del
435 /// editor: arrastrar sobre él resalta el rango y Ctrl/Cmd+C lo copia al
436 /// portapapeles. `key` debe ser **estable** entre rebuilds del `View`
437 /// (índice, hash del id) — la selección vive en el runtime anclada a esa
438 /// key, no al `NodeId` (que cambia cada frame). Piensa en labels, párrafos,
439 /// celdas de tabla, salidas de consola: cualquier texto que el usuario
440 /// querría copiar sin un editor. Sólo aplica a texto **uniforme** (el de
441 /// `.text(...)`/`.text_aligned(...)`); en nodos con `runs`/`spans` no tiene
442 /// efecto (esos son del editor / RichText). Componer con el texto:
443 /// `View::new(style).text_aligned(s, 14.0, col, al).selectable(key)`.
444 pub fn selectable(mut self, key: u64) -> Self {
445 self.raro().text_select_key = Some(key);
446 self
447 }
448
449 /// Marca este nodo como **hero shared-element** con la `key` indicada.
450 /// Cuando la misma `key` aparece en un rect distinto en el frame siguiente
451 /// (entre rutas, paneles, layouts), el runtime interpola `transform` para
452 /// "volar" del rect anterior al actual durante `duration`. La `key` debe
453 /// ser estable y única dentro del frame; idéntica semántica a la `key`
454 /// de [`Self::animated`]. Para easing distinto al ease-out cúbico default,
455 /// ver [`Self::hero_curve`].
456 pub fn hero(mut self, key: u64, duration: core::time::Duration) -> Self {
457 self.raro().hero = Some(Hero {
458 key,
459 duration,
460 easing: ease_out_cubic,
461 });
462 self
463 }
464
465 /// Como [`Self::hero`] pero con easing explícito.
466 pub fn hero_curve(
467 mut self,
468 key: u64,
469 duration: core::time::Duration,
470 easing: fn(f32) -> f32,
471 ) -> Self {
472 self.raro().hero = Some(Hero { key, duration, easing });
473 self
474 }
475
476 /// Aplica una transformación afín 2D a este nodo y todo su subtree,
477 /// **alrededor del centro de su rect** (CSS `transform-origin: 50%
478 /// 50%`). El centro se resuelve en `paint` contra el layout computado;
479 /// el caller sólo provee el afín "local" (producto de sus
480 /// `rotate`/`scale`/`translate`). Nodos anidados componen en el
481 /// espacio ya transformado del padre. Pensado para `transform` y
482 /// `@keyframes` CSS de puriy. `Affine::IDENTITY` equivale a no setear.
483 pub fn transform(mut self, xf: Affine) -> Self {
484 self.raro().transform = Some(xf);
485 self
486 }
487
488 /// Traslación relativa al tamaño del propio nodo: `(fx, fy)` desplaza
489 /// `(fx · w, fy · h)` px, resueltos contra el rect computado en `paint`.
490 /// Es el `translate(<%>)` de CSS que no cabe en un `Affine` fijo (p. ej.
491 /// el centrado `translate(-50%, -50%)` ⇒ `transform_rel((-0.5, -0.5))`).
492 /// Compone con `transform` (si está) como factor más externo. Ver
493 /// [`View::transform`]. `(0.0, 0.0)` equivale a no setear.
494 pub fn transform_rel(mut self, frac: (f64, f64)) -> Self {
495 self.raro().transform_rel = Some(frac);
496 self
497 }
498
499 /// `position: sticky` (CSS): insets `[top, right, bottom, left]` en px
500 /// relativos al scrollport (ancestro que recorta). El nodo fluye normal
501 /// hasta el umbral y luego se pega a esa distancia del borde. Ver el campo
502 /// [`View::sticky`]. Ejemplo: un header `top:0` ⇒ `.sticky([Some(0.0),
503 /// None, None, None])`.
504 pub fn sticky(mut self, insets: [Option<f32>; 4]) -> Self {
505 self.raro().sticky = Some(insets);
506 self
507 }
508
509 /// Punto de pivote de `transform` (CSS `transform-origin`). Sin setear ⇒
510 /// centro del rect (`50% 50%`). Ver [`TransformPivot`]. Sólo tiene efecto
511 /// junto con `transform`/`transform_rel`.
512 pub fn transform_origin(mut self, pivot: crate::TransformPivot) -> Self {
513 self.raro().transform_origin = Some(pivot);
514 self
515 }
516
517 pub fn fill(mut self, color: Color) -> Self {
518 self.fill = Some(color);
519 self
520 }
521
522 /// Opacidad uniforme aplicada a este nodo y todos sus descendientes
523 /// vía `scene.push_layer(Mix::Normal, a, …)`. Pensado para fade-in/out
524 /// de overlays, toasts y modales sin tener que tunear el alpha de
525 /// cada color del subtree. Valores fuera de `[0.0, 1.0]` se clampean.
526 /// Hace que el subtree se componga en una capa intermedia — usar sólo
527 /// cuando sea necesario (no es gratuito).
528 pub fn alpha(mut self, a: f32) -> Self {
529 self.alpha = Some(a.clamp(0.0, 1.0));
530 self
531 }
532
533 /// Anima de forma **implícita** las props de paint de este nodo
534 /// (hoy `fill` y `radius`): cuando su valor cambia entre frames, el
535 /// runtime interpola en `duration` con ease-out cúbico en vez de saltar
536 /// (estilo Flutter `AnimatedContainer`). `key` debe ser **estable** entre
537 /// rebuilds del `View` (índice de item, hash de id) — es lo que enlaza
538 /// "el mismo nodo" entre frames; dos nodos distintos no deben compartir
539 /// key. La primera aparición no anima; sólo los cambios posteriores. Para
540 /// otra curva, [`Self::animated_curve`].
541 pub fn animated(mut self, key: u64, duration: core::time::Duration) -> Self {
542 self.raro().anim = Some(Anim {
543 key,
544 duration,
545 easing: ease_out_cubic,
546 enter: false,
547 exit: false,
548 enter_from_xf: None,
549 switch: None,
550 exit_to_xf: None,
551 enter_fade: true,
552 exit_under: false,
553 exit_hold: false,
554 });
555 self
556 }
557
558 /// Anima de forma **implícita** el **tamaño** de este nodo (Flutter
559 /// `AnimatedSize` / Compose `animateContentSize()`). Cuando
560 /// `style.size` cambia entre frames, el runtime interpola en
561 /// `duration` con ease-out cúbico en vez de saltar — siblings y
562 /// hijos reflowean suave porque el reconciler parcha `style.size`
563 /// **antes** del layout. `key` debe ser estable entre rebuilds.
564 /// Para otra curva, [`Self::animated_size_curve`]. Bloque 15.
565 ///
566 /// **Límite v1**: ambos `style.size.width` y `style.size.height`
567 /// tienen que ser `Dimension::Length(_)`. Si una es `Percent`/`Auto`,
568 /// el nodo se monta tal cual sin animación (no hay valor en píxeles
569 /// estable para interpolar). El caller que necesite animar un nodo
570 /// flex puede envolver el contenido en un wrap con `length(...)`
571 /// fijo y mover el flex al padre.
572 pub fn animated_size(mut self, key: u64, duration: core::time::Duration) -> Self {
573 self.raro().animated_size = Some(SizeAnim {
574 key,
575 duration,
576 easing: ease_out_cubic,
577 });
578 self
579 }
580
581 /// Como [`Self::animated_size`] pero con curva de easing custom.
582 pub fn animated_size_curve(
583 mut self,
584 key: u64,
585 duration: core::time::Duration,
586 easing: fn(f32) -> f32,
587 ) -> Self {
588 self.raro().animated_size = Some(SizeAnim { key, duration, easing });
589 self
590 }
591
592 /// Como [`Self::animated`] pero además **anima la entrada**: la primera vez
593 /// que esta `key` aparece, su opacidad sube de 0 a su valor (`alpha` o 1.0)
594 /// en `duration` — fade-in estilo `AnimatedSwitcher`/`AnimatedVisibility`.
595 /// Útil para toasts, items de lista que aparecen, paneles que se montan,
596 /// resultados que entran. Como toda animación implícita, depende de una
597 /// `key` estable; reutilizar la key de un nodo que ya estaba NO refadea
598 /// (sólo la primera aparición anima). Para animar también la salida, ver
599 /// [`Self::animated_inout`].
600 pub fn animated_enter(mut self, key: u64, duration: core::time::Duration) -> Self {
601 self.raro().anim = Some(Anim {
602 key,
603 duration,
604 easing: ease_out_cubic,
605 enter: true,
606 exit: false,
607 enter_from_xf: None,
608 switch: None,
609 exit_to_xf: None,
610 enter_fade: true,
611 exit_under: false,
612 exit_hold: false,
613 });
614 self
615 }
616
617 /// Como [`Self::animated_enter`] pero además arranca la entrada desde una
618 /// **transformación afín** específica hacia la del nodo (o la identidad si
619 /// no setea `.transform`). Habilita scale-in / slide-in / rotate-in
620 /// implícitos: el caller declara la pose inicial, el runtime interpola.
621 /// Ejemplos:
622 /// - `Affine::scale(0.6)` → "pop" (FAB de Material, modales).
623 /// - `Affine::translate((0.0, 60.0))` → slide-in vertical (snackbars).
624 /// - `Affine::translate((-w, 0.0))` → slide-in lateral (drawers).
625 /// Combina con el fade-in de entrada (`alpha 0 → opaque`). Sin animación
626 /// de salida; para entrada+salida con pose, ver
627 /// [`Self::animated_inout_from`].
628 pub fn animated_enter_from(
629 mut self,
630 key: u64,
631 duration: core::time::Duration,
632 from_xf: Affine,
633 ) -> Self {
634 self.raro().anim = Some(Anim {
635 key,
636 duration,
637 easing: ease_out_cubic,
638 enter: true,
639 exit: false,
640 enter_from_xf: Some(from_xf),
641 switch: None,
642 exit_to_xf: None,
643 enter_fade: true,
644 exit_under: false,
645 exit_hold: false,
646 });
647 self
648 }
649
650 /// Atajo Material: scale-in desde 0.6 (entrada "pop" del FAB). Combina con
651 /// fade-in. Equivalente a `.animated_enter_from(key, dur, Affine::scale(0.6))`.
652 pub fn animated_pop_in(self, key: u64, duration: core::time::Duration) -> Self {
653 self.animated_enter_from(key, duration, Affine::scale(0.6))
654 }
655
656 /// **Anima la salida** (fade-out): cuando esta `key` desaparece del árbol,
657 /// el runtime retiene la última subescena que pintó y la reproduce con
658 /// opacidad decreciente durante `duration` — estilo `AnimatedSwitcher` /
659 /// `AnimatedVisibility` al ocultarse. No anima la entrada (para ambas, ver
660 /// [`Self::animated_inout`]). Tiene coste por frame mientras el nodo vive
661 /// (captura su subárbol); usar con moderación (toasts, modales, paneles).
662 pub fn animated_exit(mut self, key: u64, duration: core::time::Duration) -> Self {
663 self.raro().anim = Some(Anim {
664 key,
665 duration,
666 easing: ease_out_cubic,
667 enter: false,
668 exit: true,
669 enter_from_xf: None,
670 switch: None,
671 exit_to_xf: None,
672 enter_fade: true,
673 exit_under: false,
674 exit_hold: false,
675 });
676 self
677 }
678
679 /// Anima **entrada y salida**: fade-in en la primera aparición y fade-out al
680 /// desmontarse, ambos en `duration`. La pieza completa de "animación de
681 /// contenido" para un nodo que aparece y desaparece (un toast, un panel que
682 /// se abre y cierra, un resultado que entra y se va).
683 pub fn animated_inout(mut self, key: u64, duration: core::time::Duration) -> Self {
684 self.raro().anim = Some(Anim {
685 key,
686 duration,
687 easing: ease_out_cubic,
688 enter: true,
689 exit: true,
690 enter_from_xf: None,
691 switch: None,
692 exit_to_xf: None,
693 enter_fade: true,
694 exit_under: false,
695 exit_hold: false,
696 });
697 self
698 }
699
700 /// Como [`Self::animated_inout`] pero arranca la entrada desde la
701 /// transformación afín `from_xf` (igual semántica que
702 /// [`Self::animated_enter_from`]). La salida sigue siendo el fade-out
703 /// estándar (la subescena retenida no transforma).
704 pub fn animated_inout_from(
705 mut self,
706 key: u64,
707 duration: core::time::Duration,
708 from_xf: Affine,
709 ) -> Self {
710 self.raro().anim = Some(Anim {
711 key,
712 duration,
713 easing: ease_out_cubic,
714 enter: true,
715 exit: true,
716 enter_from_xf: Some(from_xf),
717 switch: None,
718 exit_to_xf: None,
719 enter_fade: true,
720 exit_under: false,
721 exit_hold: false,
722 });
723 self
724 }
725
726 /// Como [`Self::animated`] pero con easing explícito (p. ej.
727 /// `llimphi_theme::motion::ease_in_out_cubic`).
728 pub fn animated_curve(
729 mut self,
730 key: u64,
731 duration: core::time::Duration,
732 easing: fn(f32) -> f32,
733 ) -> Self {
734 self.raro().anim = Some(Anim {
735 key,
736 duration,
737 easing,
738 enter: false,
739 exit: false,
740 enter_from_xf: None,
741 switch: None,
742 exit_to_xf: None,
743 enter_fade: true,
744 exit_under: false,
745 exit_hold: false,
746 });
747 self
748 }
749
750 /// Cross-fade real entre **variantes de contenido** bajo la misma `key`
751 /// (Flutter `AnimatedSwitcher`). `variant` identifica el contenido actual
752 /// (índice de pestaña, hash del estado, discriminante de un enum…). Cuando
753 /// `variant` cambia entre frames, el runtime desvanece la subescena vieja
754 /// (fade-out, retenida del frame previo) mientras hace fade-in del subárbol
755 /// nuevo, en el mismo rect — la transición real entre dos identidades, en
756 /// vez de combinar `animated_enter`+`animated_exit` de dos keys distintas.
757 ///
758 /// Envolvé el contenido conmutable en un nodo con esta marca; sus hijos son
759 /// el contenido. La primera aparición no cruza (sólo fija la variante).
760 /// Igual que `exit`, captura el subárbol por frame — usar en pocos nodos
761 /// (un panel central, un visor que cambia de documento), no por fila.
762 pub fn animated_switch(
763 mut self,
764 key: u64,
765 variant: u64,
766 duration: core::time::Duration,
767 ) -> Self {
768 self.raro().anim = Some(Anim {
769 key,
770 duration,
771 easing: ease_out_cubic,
772 enter: false,
773 exit: false,
774 enter_from_xf: None,
775 switch: Some(variant),
776 exit_to_xf: None,
777 enter_fade: true,
778 exit_under: false,
779 exit_hold: false,
780 });
781 self
782 }
783
784 /// Como [`Self::animated_switch`] pero **direccional**: al cambiar la
785 /// variante, el contenido nuevo entra desde `enter_from_xf` (además del
786 /// fade-in) y el viejo sale hacia `exit_to_xf` (además del fade-out),
787 /// ambas afines aplicadas alrededor del centro del rect. Es la base de las
788 /// transiciones de página del router: push = entrar desde la derecha /
789 /// salir hacia la izquierda; pop = lo inverso. `None` en cualquiera de las
790 /// dos degrada a fade puro en ese lado.
791 pub fn animated_switch_from(
792 self,
793 key: u64,
794 variant: u64,
795 duration: core::time::Duration,
796 enter_from_xf: Option<Affine>,
797 exit_to_xf: Option<Affine>,
798 ) -> Self {
799 self.animated_switch_styled(
800 key,
801 variant,
802 duration,
803 crate::SwitchStyle {
804 enter_from_xf,
805 exit_to_xf,
806 ..Default::default()
807 },
808 )
809 }
810
811 /// La forma **completa** del switch direccional: [`crate::SwitchStyle`]
812 /// agrupa los cinco knobs — afín de entrada, fade de entrada, afín de
813 /// salida, placement del fantasma (encima vs **debajo** del contenido
814 /// nuevo, en su posición z real) y hold de opacidad (salida opaca, sin
815 /// desvanecer). Con `enter_fade: false` + `exit_under: true` sale el push
816 /// opaco estilo UIKit; el default reproduce `animated_switch`.
817 pub fn animated_switch_styled(
818 mut self,
819 key: u64,
820 variant: u64,
821 duration: core::time::Duration,
822 style: crate::SwitchStyle,
823 ) -> Self {
824 self.raro().anim = Some(Anim {
825 key,
826 duration,
827 easing: ease_out_cubic,
828 enter: false,
829 exit: false,
830 enter_from_xf: style.enter_from_xf,
831 switch: Some(variant),
832 exit_to_xf: style.exit_to_xf,
833 enter_fade: style.enter_fade,
834 exit_under: style.exit_under,
835 exit_hold: style.exit_hold,
836 });
837 self
838 }
839
840 /// Como [`Self::animated_exit`] pero la salida además se **desliza** hacia
841 /// `to_xf` mientras se desvanece (p. ej. un toast que se va hacia abajo:
842 /// `Affine::translate((0.0, 40.0))`). La afín se aplica alrededor del
843 /// centro del rect del nodo.
844 pub fn animated_exit_to(
845 mut self,
846 key: u64,
847 duration: core::time::Duration,
848 to_xf: Affine,
849 ) -> Self {
850 self.raro().anim = Some(Anim {
851 key,
852 duration,
853 easing: ease_out_cubic,
854 enter: false,
855 exit: true,
856 enter_from_xf: None,
857 switch: None,
858 exit_to_xf: Some(to_xf),
859 enter_fade: true,
860 exit_under: false,
861 exit_hold: false,
862 });
863 self
864 }
865
866 /// Color a usar cuando el cursor está sobre este nodo. Habilita
867 /// el hit-test de hover sobre el nodo.
868 pub fn hover_fill(mut self, color: Color) -> Self {
869 self.hover_fill = Some(color);
870 self
871 }
872
873 /// Marca este nodo como draggable. Mientras el usuario sostenga el
874 /// botón izquierdo sobre él, el runtime llama `handler(Move, dx, dy)`
875 /// por cada `CursorMoved` (dx/dy = delta desde el evento anterior) y
876 /// `handler(End, 0, 0)` al soltar. Sobreescribe `on_click` para este
877 /// nodo: un nodo es draggable **o** clickable.
878 pub fn draggable<F>(mut self, handler: F) -> Self
879 where
880 F: Fn(DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
881 {
882 self.raro().drag = Some(Arc::new(handler));
883 self
884 }
885
886 /// Como `draggable`, pero el handler también recibe la posición
887 /// inicial del press relativa al rect del nodo `(initial_lx,
888 /// initial_ly)`. Útil cuando el caller necesita resolver qué
889 /// entidad bajo el cursor inició el drag (Conceptos, lemmings,
890 /// nodos de un grafo, etc.). Gana sobre `draggable` si ambos están.
891 pub fn draggable_at<F>(mut self, handler: F) -> Self
892 where
893 F: Fn(DragPhase, f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
894 {
895 self.raro().drag_at = Some(Arc::new(handler));
896 self
897 }
898
899 /// Como [`Self::draggable`] pero el handler recibe además la **velocidad
900 /// del drag al soltarlo** (`vx`, `vy` en px/s) en la fase
901 /// `DragPhase::End` — `Fn(DragPhase, dx, dy, vx, vy) -> Option<Msg>`. El
902 /// runtime mide el desplazamiento sobre los últimos ~100 ms y lo divide
903 /// por el tiempo transcurrido. Durante `DragPhase::Move`, `vx == vy == 0`
904 /// (la velocidad sólo se calcula al final). **Gana sobre `draggable` y
905 /// `draggable_at`** si conviven en el mismo nodo — un nodo elige un
906 /// único sabor de drag. Habilita **fling-desde-drag**: el caller emite
907 /// `Msg::Fling { vx, vy }` en End y arranca un ticker que decae la
908 /// velocidad con [`fling_step`] hasta asentar.
909 pub fn draggable_velocity<F>(mut self, handler: F) -> Self
910 where
911 F: Fn(DragPhase, f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
912 {
913 self.raro().drag_velocity = Some(Arc::new(handler));
914 self
915 }
916
917 /// Declara el payload `u64` que viaja con el drag de este nodo. Los
918 /// drop targets bajo cursor al soltar reciben este valor en su
919 /// `on_drop`. Sin payload, los drop targets no reaccionan (útil para
920 /// drags de "resize/scroll" que no representan transferencia).
921 ///
922 /// **El payload también cambia cuándo dispara el `on_click_at` del nodo.**
923 /// Un drag con payload es una transferencia (el nodo se suelta sobre otro),
924 /// así que el click se arma en el press y lo dispara el *release* si el
925 /// cursor no se alejó — un arrastre de verdad no activa nada. Sin payload
926 /// (canvas: seleccionar y mover dentro del mismo nodo) el click sigue
927 /// disparando en el press. Ver `handle_left_press` en `llimphi-ui`.
928 pub fn drag_payload(mut self, payload: u64) -> Self {
929 self.raro().drag_payload = Some(payload);
930 self
931 }
932
933 /// **Qué se ve mientras este nodo viaja arrastrado.** Un fantasma pegado al
934 /// cursor con el rótulo y, si son varias, cuántas.
935 ///
936 /// Va con [`Self::drag_payload`]: sin payload el drag no transfiere nada y
937 /// no hay carga que mostrar. Sin esto declarado el arrastre sigue siendo
938 /// invisible —lo único que se movía era el resaltado del destino— y el que
939 /// arrastra no tiene forma de saber qué agarró ni de darse cuenta de que
940 /// agarró algo sin querer.
941 pub fn drag_carga(mut self, carga: crate::CargaArrastre) -> Self {
942 self.raro().drag_carga = Some(Arc::new(carga));
943 self
944 }
945
946 /// Marca este nodo como drop target. El runtime invoca `handler(payload)`
947 /// cuando un drag termina sobre el rect de este nodo y el origen del
948 /// drag declaró un payload. Si devuelve `Some(Msg)`, se dispatchea al
949 /// `update` antes del `DragPhase::End` del origen.
950 pub fn on_drop<F>(mut self, handler: F) -> Self
951 where
952 F: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
953 {
954 self.raro().on_drop = Some(Arc::new(handler));
955 self
956 }
957
958 /// Color de relleno cuando un drag activo está hovereando este drop
959 /// target. Análogo a `hover_fill` pero solo aplica mientras dura un
960 /// drag. Útil para resaltar el destino válido.
961 pub fn drop_hover_fill(mut self, color: Color) -> Self {
962 self.raro().drop_hover_fill = Some(color);
963 self
964 }
965
966 /// **El resorte de este drop target**: qué emite cuando un arrastre se
967 /// queda POSADO encima un rato, sin soltar todavía.
968 ///
969 /// Es la carpeta con resorte de toda la vida: para soltar algo adentro de
970 /// un contenedor plegado hay que abrirlo, y con el botón apretado no se
971 /// puede clickear. El runtime cuenta la posada sobre ESTE nodo y emite el
972 /// `Msg` una sola vez al pasar el umbral.
973 ///
974 /// El mensaje va **idempotente** («abrí esto»), nunca un toggle: el runtime
975 /// no sabe si el destino ya estaba abierto, y un toggle cerraría justo lo
976 /// que estás por usar. Ver [`crate::MountedNode::on_drop_hover`].
977 pub fn on_drop_hover(mut self, msg: Msg) -> Self {
978 self.raro().on_drop_hover = Some(msg);
979 self
980 }
981
982 pub fn radius(mut self, r: f64) -> Self {
983 self.radius = r;
984 self
985 }
986
987 /// Radio **por esquina** (top-left, top-right, bottom-right, bottom-left,
988 /// en sentido horario desde arriba-izquierda) — CSS `border-radius` con
989 /// cuatro valores. Sobreescribe a [`Self::radius`] mientras esté presente.
990 /// Para cards con sólo las esquinas de arriba redondeadas, pestañas,
991 /// bocadillos de chat asimétricos, etc. El **borde** respeta las cuatro
992 /// esquinas; la **sombra** sigue usando el `radius` escalar (el blur
993 /// nativo de vello no acepta radios por esquina).
994 pub fn radius_corners(mut self, tl: f64, tr: f64, br: f64, bl: f64) -> Self {
995 self.raro().corner_radii = Some(RoundedRectRadii::new(tl, tr, br, bl));
996 self
997 }
998
999 /// Proyecta una sombra detrás del nodo (drop shadow), rasterizada con
1000 /// el blur gaussiano nativo de vello. Se pinta antes del relleno, así
1001 /// el fill opaco la tapa y la sombra asoma por el desenfoque/offset.
1002 /// El radio de la sombra sigue al del nodo (más el `spread`). Ver
1003 /// [`Shadow`] (`Shadow::soft(alpha, blur)` es el default tasteful).
1004 pub fn shadow(mut self, shadow: Shadow) -> Self {
1005 self.raro().shadow = Some(shadow);
1006 self
1007 }
1008
1009 /// Rellena el nodo con un **gradiente** en vez de un color sólido. El
1010 /// gradiente se autorea en el **cuadrado unidad** `[0,1]²` y el runtime
1011 /// lo mapea al rect del nodo (así no necesitas saber el tamaño al
1012 /// construir el `View`) — igual que `Alignment` relativo de Flutter.
1013 ///
1014 /// ```ignore
1015 /// use llimphi_ui::llimphi_raster::peniko::{Color, Gradient};
1016 /// use llimphi_ui::llimphi_raster::kurbo::Point;
1017 /// // vertical: arriba claro → abajo oscuro
1018 /// let g = Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
1019 /// .with_stops([Color::from_rgba8(80,90,110,255), Color::from_rgba8(30,34,44,255)].as_slice());
1020 /// view.fill_gradient(g)
1021 /// ```
1022 ///
1023 /// Gana sobre `fill` como base; un `hover_fill` (color) lo sigue
1024 /// overrideando mientras el cursor está encima.
1025 pub fn fill_gradient(mut self, gradient: Gradient) -> Self {
1026 self.raro().fill_gradient = Some(gradient);
1027 self
1028 }
1029
1030 /// Como [`fill_gradient`](Self::fill_gradient), pero el degradé se autorea
1031 /// en **px de la superficie** y no en el cuadrado unidad del nodo: es UNO
1032 /// solo para toda la superficie, y cada nodo muestra el tramo que le cae
1033 /// encima. Para el acento que depende de la posición en la pantalla
1034 /// (2026-10-12): el que arma la vista corre las puntas a donde cae el
1035 /// monitor respecto de la superficie.
1036 pub fn fill_gradient_superficie(mut self, gradient: Gradient) -> Self {
1037 let r = self.raro();
1038 r.fill_gradient = Some(gradient);
1039 r.fill_gradient_superficie = true;
1040 self
1041 }
1042
1043 /// Dibuja un borde (stroke) sobre el contorno redondeado del nodo,
1044 /// inset media línea hacia adentro (el grosor queda dentro del rect).
1045 /// Reemplaza el viejo truco de envolver el nodo en un rect-padre del
1046 /// color del borde con padding de 1px.
1047 pub fn border(mut self, width: f64, color: Color) -> Self {
1048 self.raro().border = Some(Border::new(width, color));
1049 self
1050 }
1051
1052 pub fn text(mut self, content: impl Into<String>, size_px: f32, color: Color) -> Self {
1053 self.text = Some(TextSpec {
1054 content: content.into(),
1055 size_px,
1056 color,
1057 alignment: llimphi_text::Alignment::Center,
1058 italic: false,
1059 font_family: None,
1060 line_height: 1.2,
1061 weight: 400.0,
1062 max_lines: None,
1063 ellipsis: false,
1064 runs: None,
1065 underline: false,
1066 strikethrough: false,
1067 letter_spacing: 0.0,
1068 word_spacing: 0.0,
1069 spans: None,
1070 no_wrap: false,
1071 overflow_wrap: false,
1072 font_features: String::new(),
1073 font_variations: String::new(),
1074 font_width: 100.0,
1075 });
1076 self
1077 }
1078
1079 pub fn text_aligned(
1080 mut self,
1081 content: impl Into<String>,
1082 size_px: f32,
1083 color: Color,
1084 alignment: llimphi_text::Alignment,
1085 ) -> Self {
1086 self.text = Some(TextSpec {
1087 content: content.into(),
1088 size_px,
1089 color,
1090 alignment,
1091 italic: false,
1092 font_family: None,
1093 line_height: 1.2,
1094 weight: 400.0,
1095 max_lines: None,
1096 ellipsis: false,
1097 runs: None,
1098 underline: false,
1099 strikethrough: false,
1100 letter_spacing: 0.0,
1101 word_spacing: 0.0,
1102 spans: None,
1103 no_wrap: false,
1104 overflow_wrap: false,
1105 font_features: String::new(),
1106 font_variations: String::new(),
1107 font_width: 100.0,
1108 });
1109 self
1110 }
1111
1112 /// Como `text_aligned` pero con un flag `italic`. Si la fuente activa
1113 /// no tiene variante italic, parley aplica synthesizing.
1114 pub fn text_aligned_italic(
1115 mut self,
1116 content: impl Into<String>,
1117 size_px: f32,
1118 color: Color,
1119 alignment: llimphi_text::Alignment,
1120 italic: bool,
1121 ) -> Self {
1122 self.text = Some(TextSpec {
1123 content: content.into(),
1124 size_px,
1125 color,
1126 alignment,
1127 italic,
1128 font_family: None,
1129 line_height: 1.2,
1130 weight: 400.0,
1131 max_lines: None,
1132 ellipsis: false,
1133 runs: None,
1134 underline: false,
1135 strikethrough: false,
1136 letter_spacing: 0.0,
1137 word_spacing: 0.0,
1138 spans: None,
1139 no_wrap: false,
1140 overflow_wrap: false,
1141 font_features: String::new(),
1142 font_variations: String::new(),
1143 font_width: 100.0,
1144 });
1145 self
1146 }
1147
1148 /// Como `text_aligned_italic` pero con font-family explícito.
1149 /// La cadena se pasa como `parley::FontStack::Source` (acepta listas
1150 /// CSS con fallbacks).
1151 pub fn text_aligned_full(
1152 mut self,
1153 content: impl Into<String>,
1154 size_px: f32,
1155 color: Color,
1156 alignment: llimphi_text::Alignment,
1157 italic: bool,
1158 font_family: Option<String>,
1159 ) -> Self {
1160 self.text = Some(TextSpec {
1161 content: content.into(),
1162 size_px,
1163 color,
1164 alignment,
1165 italic,
1166 font_family,
1167 line_height: 1.2,
1168 weight: 400.0,
1169 max_lines: None,
1170 ellipsis: false,
1171 runs: None,
1172 underline: false,
1173 strikethrough: false,
1174 letter_spacing: 0.0,
1175 word_spacing: 0.0,
1176 spans: None,
1177 no_wrap: false,
1178 overflow_wrap: false,
1179 font_features: String::new(),
1180 font_variations: String::new(),
1181 font_width: 100.0,
1182 });
1183 self
1184 }
1185
1186 /// Texto **multicolor** en una sola pasada de shaping: `content` se pinta
1187 /// con `default_color` y cada `(start_byte, end_byte, color)` de `runs`
1188 /// sobreescribe su rango (offsets en bytes). Pensado para syntax
1189 /// highlighting — un nodo por línea en vez de uno por token. Anclado
1190 /// arriba-izquierda (sin centrado vertical); el caller dimensiona el rect.
1191 pub fn text_runs(
1192 mut self,
1193 content: impl Into<String>,
1194 size_px: f32,
1195 default_color: Color,
1196 runs: Vec<(usize, usize, Color)>,
1197 alignment: llimphi_text::Alignment,
1198 ) -> Self {
1199 self.text = Some(TextSpec {
1200 content: content.into(),
1201 size_px,
1202 color: default_color,
1203 alignment,
1204 italic: false,
1205 font_family: None,
1206 line_height: 1.2,
1207 weight: 400.0,
1208 max_lines: None,
1209 ellipsis: false,
1210 runs: Some(runs),
1211 underline: false,
1212 strikethrough: false,
1213 letter_spacing: 0.0,
1214 word_spacing: 0.0,
1215 spans: None,
1216 no_wrap: false,
1217 overflow_wrap: false,
1218 font_features: String::new(),
1219 font_variations: String::new(),
1220 font_width: 100.0,
1221 });
1222 self
1223 }
1224
1225 /// Texto **RichText** (Bloque 13 de PARIDAD-FLUTTER, cierra Tier 2):
1226 /// `content` se pinta con los defaults del bloque (`size_px`,
1227 /// `default_color`, alignment, weight 400, no italic, line-height 1.2,
1228 /// fuente default) y cada [`llimphi_text::TextSpan`] sobreescribe en su
1229 /// rango de bytes uno o más de
1230 /// `size_px`/`weight`/`italic`/`font_family`/`color`/`underline`/
1231 /// `strikethrough`. Soporta wrap (el ancho lo fija el layout taffy del
1232 /// nodo); apto para párrafos con un `<b>`/`<i>`/`<code>`/`<small>`
1233 /// inline, links subrayados, headings dentro del mismo flujo, render
1234 /// barato de markdown.
1235 pub fn text_spans(
1236 mut self,
1237 content: impl Into<String>,
1238 size_px: f32,
1239 default_color: Color,
1240 spans: Vec<llimphi_text::TextSpan>,
1241 alignment: llimphi_text::Alignment,
1242 ) -> Self {
1243 self.text = Some(TextSpec {
1244 content: content.into(),
1245 size_px,
1246 color: default_color,
1247 alignment,
1248 italic: false,
1249 font_family: None,
1250 line_height: 1.2,
1251 weight: 400.0,
1252 max_lines: None,
1253 ellipsis: false,
1254 runs: None,
1255 underline: false,
1256 strikethrough: false,
1257 letter_spacing: 0.0,
1258 word_spacing: 0.0,
1259 spans: Some(spans),
1260 no_wrap: false,
1261 overflow_wrap: false,
1262 font_features: String::new(),
1263 font_variations: String::new(),
1264 font_width: 100.0,
1265 });
1266 self
1267 }
1268
1269 /// Adjunta o reemplaza los [`TextSpec::spans`] del texto ya seteado
1270 /// (RichText). Permite construir el texto con los builders uniformes
1271 /// (`.text_aligned(...).bold().underline()`) y luego inyectar overrides
1272 /// inline. No-op si el nodo no tiene texto.
1273 pub fn with_spans(mut self, spans: Vec<llimphi_text::TextSpan>) -> Self {
1274 if let Some(t) = self.text.as_mut() {
1275 t.spans = Some(spans);
1276 }
1277 self
1278 }
1279
1280 /// Sobreescribe el múltiplo de interlínea del texto ya seteado (default
1281 /// 1.2). No-op si el nodo no tiene texto. Pensado para puriy, que pasa
1282 /// el `line-height` computado de CSS para que medición y pintado usen
1283 /// el mismo valor.
1284 pub fn line_height(mut self, mult: f32) -> Self {
1285 if let Some(t) = self.text.as_mut() {
1286 t.line_height = mult;
1287 }
1288 self
1289 }
1290
1291 /// Sobreescribe el peso de fuente del texto ya seteado (default 400 =
1292 /// normal). Convención CSS: 400 normal, 500 medium, 600 semibold, 700
1293 /// bold. parley elige la variante más cercana de la familia activa o la
1294 /// sintetiza. No-op si el nodo no tiene texto. Afecta medida y pintado.
1295 pub fn text_weight(mut self, weight: f32) -> Self {
1296 if let Some(t) = self.text.as_mut() {
1297 t.weight = weight;
1298 }
1299 self
1300 }
1301
1302 /// Atajo de [`Self::text_weight`] a 700 (bold). No-op sin texto.
1303 pub fn bold(self) -> Self {
1304 self.text_weight(700.0)
1305 }
1306
1307 /// Fija la familia de fuente del texto ya seteado a la monoespaciada
1308 /// embebida ([`llimphi_text::MONOSPACE`]) — ancho fijo garantizado para
1309 /// que `ls`, tablas y logs columneen. No-op si el nodo no tiene texto.
1310 /// Afecta medida y pintado.
1311 pub fn mono(mut self) -> Self {
1312 if let Some(t) = self.text.as_mut() {
1313 t.font_family = Some(llimphi_text::MONOSPACE.to_string());
1314 }
1315 self
1316 }
1317
1318 /// Clampa el texto a `n` líneas **sin** glifo de ellipsis (corte seco del
1319 /// prefijo que cupo). CSS `-webkit-line-clamp` sin `text-overflow`. No-op
1320 /// sin texto. Para el corte con `…` usar [`Self::ellipsis`]. Sólo trunca si
1321 /// hay envoltura (requiere ancho acotado por el layout).
1322 pub fn max_lines(mut self, n: usize) -> Self {
1323 if let Some(t) = self.text.as_mut() {
1324 t.max_lines = Some(n);
1325 t.ellipsis = false;
1326 }
1327 self
1328 }
1329
1330 /// Clampa el texto a `n` líneas terminando la última en `…` cuando excede
1331 /// (CSS `text-overflow: ellipsis` + `-webkit-line-clamp: n`). Lo más común
1332 /// para items de lista, celdas de tabla, breadcrumbs y labels en cajas
1333 /// dimensionadas. `n = 1` es el clásico single-line ellipsis. No-op sin
1334 /// texto.
1335 pub fn ellipsis(mut self, n: usize) -> Self {
1336 if let Some(t) = self.text.as_mut() {
1337 t.max_lines = Some(n.max(1));
1338 t.ellipsis = true;
1339 }
1340 self
1341 }
1342
1343 /// `letter-spacing` (CSS): px **extra** entre letras (0 = normal, negativo
1344 /// junta). Afecta medida y pintado. No-op sin texto. Sólo el camino
1345 /// uniforme; el RichText con spans lo ignora en v1.
1346 pub fn letter_spacing(mut self, px: f32) -> Self {
1347 if let Some(t) = self.text.as_mut() {
1348 t.letter_spacing = px;
1349 }
1350 self
1351 }
1352
1353 /// `word-spacing` (CSS): px **extra** entre palabras (0 = normal). Mismo
1354 /// régimen que [`Self::letter_spacing`]. No-op sin texto.
1355 pub fn word_spacing(mut self, px: f32) -> Self {
1356 if let Some(t) = self.text.as_mut() {
1357 t.word_spacing = px;
1358 }
1359 self
1360 }
1361
1362 /// `font-feature-settings` (CSS): string crudo de features OpenType en
1363 /// gramática CSS (`"liga" 1, "kern" 0`) — parley/swash lo parsean. Vacío =
1364 /// default de la fuente. `font-kerning` se pliega aquí como el feature
1365 /// `kern`. Afecta medida y pintado. No-op sin texto (o string vacío).
1366 pub fn font_features(mut self, css: String) -> Self {
1367 if let Some(t) = self.text.as_mut() {
1368 t.font_features = css;
1369 }
1370 self
1371 }
1372
1373 /// `font-variation-settings` (CSS): string crudo de ejes de fuente variable
1374 /// en gramática CSS (`"wght" 700, "wdth" 85`). Vacío = ejes en su default.
1375 /// Mismo régimen que [`Self::font_features`]. No-op sin texto.
1376 pub fn font_variations(mut self, css: String) -> Self {
1377 if let Some(t) = self.text.as_mut() {
1378 t.font_variations = css;
1379 }
1380 self
1381 }
1382
1383 /// `font-stretch`/`font-width` (CSS): porcentaje de ancho de la fuente
1384 /// (100 = normal, <100 condensada, >100 expandida). Selecciona la face o el
1385 /// eje `wdth` de una variable. Afecta medida y pintado. No-op sin texto.
1386 pub fn font_width(mut self, pct: f32) -> Self {
1387 if let Some(t) = self.text.as_mut() {
1388 t.font_width = pct;
1389 }
1390 self
1391 }
1392
1393 /// `white-space: nowrap`/`pre` (CSS): el texto **no envuelve** — se shapea
1394 /// en una sola línea (`break_all_lines(None)`) sin importar el ancho de la
1395 /// caja, y desborda (lo recorta `overflow: hidden` si lo hay). Afecta
1396 /// medida y pintado. No-op sin texto. Sólo el camino uniforme; el RichText
1397 /// con spans lo ignora en v1.
1398 pub fn no_wrap(mut self) -> Self {
1399 if let Some(t) = self.text.as_mut() {
1400 t.no_wrap = true;
1401 }
1402 self
1403 }
1404
1405 /// `overflow-wrap: break-word`/`anywhere` (CSS): una palabra más ancha que
1406 /// la caja se **parte** para que entre, en vez de desbordar. Afecta medida
1407 /// y pintado. No-op sin texto. Sólo el camino uniforme; el RichText con
1408 /// spans lo ignora en v1, igual que `no_wrap`.
1409 pub fn overflow_wrap(mut self) -> Self {
1410 if let Some(t) = self.text.as_mut() {
1411 t.overflow_wrap = true;
1412 }
1413 self
1414 }
1415
1416 /// Activa subrayado del texto (CSS `text-decoration: underline` / Flutter
1417 /// `TextDecoration.underline`). parley registra la decoración por run y el
1418 /// runtime pinta la línea bajo la base usando `underline_offset` y
1419 /// `underline_size` del font metric — proporcional al tamaño de fuente
1420 /// elegido. No-op sin texto.
1421 pub fn underline(mut self) -> Self {
1422 if let Some(t) = self.text.as_mut() {
1423 t.underline = true;
1424 }
1425 self
1426 }
1427
1428 /// Activa tachado del texto (CSS `text-decoration: line-through` /
1429 /// Flutter `TextDecoration.lineThrough`). Mismo régimen que [`Self::underline`]
1430 /// pero usando el strikethrough metric. No-op sin texto.
1431 pub fn strikethrough(mut self) -> Self {
1432 if let Some(t) = self.text.as_mut() {
1433 t.strikethrough = true;
1434 }
1435 self
1436 }
1437
1438 pub fn on_click(mut self, msg: Msg) -> Self {
1439 self.on_click = Some(msg);
1440 self
1441 }
1442
1443 /// Dispatch `msg` cuando el cursor entra al rect del nodo
1444 /// (transición no-hover → hover). Sólo emite una vez por entrada —
1445 /// el runtime no repite el msg si el cursor se mueve dentro del rect.
1446 pub fn on_pointer_enter(mut self, msg: Msg) -> Self {
1447 self.raro().on_pointer_enter = Some(msg);
1448 self
1449 }
1450
1451 /// Dispatch `msg` cuando el cursor sale del rect del nodo.
1452 pub fn on_pointer_leave(mut self, msg: Msg) -> Self {
1453 self.raro().on_pointer_leave = Some(msg);
1454 self
1455 }
1456
1457 /// Handler de **movimiento del cursor** sobre el nodo. Recibe `(local_x,
1458 /// local_y, rect_w, rect_h)` (posición relativa al rect del nodo) en CADA
1459 /// `CursorMoved` mientras el cursor está encima — no sólo al entrar, a
1460 /// diferencia de [`Self::on_pointer_enter`]. Útil para seguir el cursor:
1461 /// thumbnail de hover sobre un timeline, drawer que reacciona a la posición.
1462 /// Devolver `None` no dispara update.
1463 pub fn on_pointer_move_at<F>(mut self, handler: F) -> Self
1464 where
1465 F: Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
1466 {
1467 self.raro().on_pointer_move_at = Some(Arc::new(handler));
1468 self
1469 }
1470
1471 /// Como `on_click`, pero el handler recibe `(local_x, local_y,
1472 /// rect_w, rect_h)` — la posición del cursor relativa al rect del
1473 /// nodo más las dimensiones actuales del nodo. Útil para canvas
1474 /// elements que necesitan saber dónde fue el click para convertirlo
1475 /// a coordenadas de mundo. Sobrescribe `on_click` para este nodo
1476 /// si ambos están presentes.
1477 pub fn on_click_at<F>(mut self, handler: F) -> Self
1478 where
1479 F: Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
1480 {
1481 self.raro().on_click_at = Some(Arc::new(handler));
1482 self
1483 }
1484
1485 /// Declara el `Msg` a emitir cuando el usuario hace click derecho
1486 /// sobre este nodo. Para menús contextuales, conviene pasar un
1487 /// `Msg::OpenMenu { ... }` y dejar que el modelo guarde la
1488 /// posición; el overlay se abre vía [`App::view_overlay`].
1489 pub fn on_right_click(mut self, msg: Msg) -> Self {
1490 self.raro().on_right_click = Some(msg);
1491 self
1492 }
1493
1494 /// Variante posicional de [`Self::on_right_click`]. El handler recibe
1495 /// `(local_x, local_y, rect_w, rect_h)` para que un nodo "grilla"
1496 /// pueda resolver internamente qué subcelda recibió el click. La
1497 /// posición está relativa al rect del nodo.
1498 pub fn on_right_click_at<F>(mut self, handler: F) -> Self
1499 where
1500 F: Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
1501 {
1502 self.raro().on_right_click_at = Some(Arc::new(handler));
1503 self
1504 }
1505
1506 /// Como [`Self::on_right_click_at`] pero el handler recibe
1507 /// `(abs_x, abs_y, rect_w, rect_h)` con las coords **absolutas de ventana**
1508 /// del click (no locales al nodo). Es lo que quiere un menú contextual:
1509 /// aparecer en la posición del puntero en pantalla. Los backends (winit /
1510 /// layer-shell) le pasan el cursor absoluto. Úsalo en vez de
1511 /// `on_right_click_at` cuando el nodo va a abrir un popup anclado al cursor.
1512 pub fn on_right_click_screen<F>(mut self, handler: F) -> Self
1513 where
1514 F: Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
1515 {
1516 self.raro().on_right_click_screen = Some(Arc::new(handler));
1517 self
1518 }
1519
1520 /// Como [`Self::on_click_at`] pero el handler recibe `(abs_x, abs_y, rect_w,
1521 /// rect_h)` con las coords **absolutas de ventana** del click (no locales al
1522 /// nodo) — el gemelo de izquierda de [`Self::on_right_click_screen`]. Es lo
1523 /// que quiere un **select/dropdown**: al hacer click en el disparador, abrir
1524 /// su menú anclado a la posición en pantalla (en la capa `view_overlay`,
1525 /// encima de todo). Los backends le pasan el cursor absoluto.
1526 pub fn on_click_screen<F>(mut self, handler: F) -> Self
1527 where
1528 F: Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
1529 {
1530 self.raro().on_click_screen = Some(Arc::new(handler));
1531 self
1532 }
1533
1534 /// Declara el `Msg` a emitir cuando el usuario hace click con el
1535 /// botón del medio (rueda presionada). Usado típicamente para abrir
1536 /// links en pestaña nueva — igual que Ctrl+Click pero más rápido.
1537 pub fn on_middle_click(mut self, msg: Msg) -> Self {
1538 self.raro().on_middle_click = Some(msg);
1539 self
1540 }
1541
1542 /// Pinta `image` dentro del rect del nodo. El encaje default es
1543 /// [`ImageFit::Contain`] (preservar aspect ratio cabiendo);
1544 /// usar [`Self::image_fit`] para `Cover`/`Fill`/`None`. El clip
1545 /// respeta `radius`/`corner_radii`, así avatares y cards
1546 /// redondeadas funcionan sin envolver en `clip(true)`. Re-exporta
1547 /// `peniko::Image` vía `llimphi_raster::peniko::Image` — el
1548 /// caller decodifica los bytes con el crate `image` (u otro) y
1549 /// construye el `Image` con `Blob<u8>` + `ImageFormat::Rgba8`.
1550 /// Declara el tamaño NATURAL del contenido reemplazado de este nodo (una
1551 /// imagen): el layout lo va a preferir, sin pasarse del espacio disponible y
1552 /// pudiendo encogerlo hasta 0. Ver [`IntrinsicSize`] para por qué esto no se
1553 /// puede decir con `Style` solo.
1554 ///
1555 /// Sólo tiene efecto en nodos HOJA (igual que la medida de texto).
1556 pub fn intrinsic(mut self, width: f32, height: f32) -> Self {
1557 self.raro().intrinsic = Some(IntrinsicSize { width, height });
1558 self
1559 }
1560
1561 pub fn image(mut self, image: Image) -> Self {
1562 self.raro().image = Some(image);
1563 self
1564 }
1565
1566 /// Política de encaje de la imagen (CSS `object-fit` / Flutter
1567 /// `BoxFit`). Solo aplica si hay [`Self::image`] seteada. Ver
1568 /// [`ImageFit`].
1569 pub fn image_fit(mut self, fit: ImageFit) -> Self {
1570 self.raro().image_fit = Some(fit);
1571 self
1572 }
1573
1574 /// Aplica `image` como **máscara de luminancia** del subárbol del nodo
1575 /// (CSS `mask-image`). El paint aísla el subárbol en una capa y multiplica
1576 /// su alpha por la luminancia de la máscara (blanco = visible, negro =
1577 /// oculto). La imagen se estira al border-box del nodo. Ortogonal a
1578 /// [`Self::image`] (que pinta contenido) y a los `clip_*` (que recortan):
1579 /// un nodo puede llevar máscara y recorte a la vez.
1580 pub fn mask_image(mut self, image: Image) -> Self {
1581 self.raro().mask_image = Some(image);
1582 self
1583 }
1584
1585 /// Fija el encaje de la máscara (CSS `mask-size`/`-position`/`-repeat`).
1586 /// Sólo surte efecto junto a [`Self::mask_image`]. Sin esto, la máscara se
1587 /// estira al border-box (Fase 7.1226). Ver [`MaskPlacement`]. Fase 7.1227.
1588 pub fn mask_placement(mut self, placement: MaskPlacement) -> Self {
1589 self.raro().mask_placement = Some(placement);
1590 self
1591 }
1592
1593 /// Capas de máscara adicionales `(imagen, operador)` (CSS `mask-image` con
1594 /// lista). Comparten el [`Self::mask_placement`] con la capa 0
1595 /// ([`Self::mask_image`]) y se combinan con ella según cada operador. Fase
1596 /// 7.1231.
1597 pub fn mask_extra(mut self, layers: Vec<(Image, MaskCompose)>) -> Self {
1598 self.raro().mask_extra = layers;
1599 self
1600 }
1601
1602 /// Registra una closure de pintura custom. El runtime la invoca
1603 /// con `(&mut Lienzo, &mut Typesetter, PaintRect)` durante
1604 /// el paint del nodo. La closure es responsable de pintar
1605 /// primitivas custom dentro del rect; no debe dejar `push_layer`
1606 /// sin par. Soporte para canvas elements estilo
1607 /// dominium/pluma/cosmos.
1608 pub fn paint_with<F>(mut self, painter: F) -> Self
1609 where
1610 F: Fn(&mut Lienzo, &mut llimphi_text::Typesetter, PaintRect)
1611 + Send
1612 + Sync
1613 + 'static,
1614 {
1615 self.painter = Some(Arc::new(painter));
1616 self
1617 }
1618
1619 /// Registra una closure de pintura GPU directo. La closure recibe
1620 /// `(&Device, &Queue, &mut CommandEncoder, &TextureView, PaintRect, (viewport_w, viewport_h))`
1621 /// y debe escribir sobre el `TextureView` con `LoadOp::Load` (no
1622 /// clear) para preservar la pasada vello previa. El último
1623 /// argumento es el tamaño en pixels de la `TextureView` destino
1624 /// (la intermedia del frame) — necesario para calcular NDC sin
1625 /// asumir un viewport fijo. Ver [`GpuPaintFn`] para semántica
1626 /// completa, contexto y orden de pintura.
1627 #[cfg(feature = "gpu")]
1628 pub fn gpu_paint_with<F>(mut self, painter: F) -> Self
1629 where
1630 F: Fn(
1631 &wgpu::Device,
1632 &wgpu::Queue,
1633 &mut wgpu::CommandEncoder,
1634 &wgpu::TextureView,
1635 PaintRect,
1636 (u32, u32),
1637 ) + Send
1638 + Sync
1639 + 'static,
1640 {
1641 self.raro().gpu_painter = Some(Arc::new(painter));
1642 self
1643 }
1644
1645 /// Registra una closure de pintura vello "over" — misma firma que
1646 /// [`Self::paint_with`] `(&mut Scene, &mut Typesetter, PaintRect)`,
1647 /// pero el runtime la ejecuta en una pasada vello FINAL **después**
1648 /// del pase GPU directo del frame, componiéndola con alpha sobre la
1649 /// intermedia. Es el complemento opt-in de [`Self::gpu_paint_with`]:
1650 /// permite pintar sprites/texto AA por vello ENCIMA de las celdas
1651 /// instanciadas por GPU del mismo (o de otro) nodo.
1652 ///
1653 /// Orden resultante del frame: `[vello base] → [gpu_paint] →
1654 /// [paint_over] → [overlay/menús]`. Backward-compat total: si nadie
1655 /// usa `paint_over`, no se crea la pasada final (coste cero) y el
1656 /// resto del pipeline es idéntico. La closure no debe dejar
1657 /// `push_layer` sin par ni resetear la escena. Ver [`OverPaintFn`].
1658 pub fn paint_over<F>(mut self, painter: F) -> Self
1659 where
1660 F: Fn(&mut Lienzo, &mut llimphi_text::Typesetter, PaintRect)
1661 + Send
1662 + Sync
1663 + 'static,
1664 {
1665 self.raro().over_painter = Some(Arc::new(painter));
1666 self
1667 }
1668
1669 /// Marca este nodo y **todo su subárbol** como capa "over": se pinta
1670 /// en la pasada vello FINAL, después del pase GPU directo del frame,
1671 /// con toda la maquinaria normal de Views (fills, texto, imágenes,
1672 /// `paint_with`, hover). Es el primitivo para componer **UI encima de
1673 /// contenido `gpu_paint_with`** — la barra de controles flotante de un
1674 /// reproductor de video, un HUD sobre un canvas GPU — sin reescribir
1675 /// esos widgets como closures de [`Self::paint_over`].
1676 ///
1677 /// El orden total del frame no cambia: `[vello base] → [gpu_paint] →
1678 /// [vello over (paint_over + subárboles over)] → [overlay/menús]`. El
1679 /// hit-test es el de siempre (usa el layout, no la pasada de pintura),
1680 /// así que los handlers del subárbol siguen funcionando idéntico.
1681 /// Limitación (como `paint_over`): la capa over se compone con alpha
1682 /// sobre la intermedia — clips/alpha de ancestros fuera del subárbol
1683 /// no la afectan.
1684 pub fn over(mut self, enabled: bool) -> Self {
1685 self.over_layer = enabled;
1686 self
1687 }
1688
1689 /// Recorta los hijos al rect de este nodo (paint y hit-test). Útil
1690 /// para paneles con contenido virtualizado que no debe sangrar a
1691 /// vecinos (listas, scrollers, viewers).
1692 pub fn clip(mut self, enabled: bool) -> Self {
1693 self.clip = enabled;
1694 self
1695 }
1696
1697 /// Recorta los descendientes a un rect encogido por `insets` px
1698 /// `[top, right, bottom, left]` desde el rect del nodo — modela
1699 /// `clip-path: inset(...)`. Activa el recorte (paint + hit-test).
1700 pub fn clip_inset(mut self, insets: [f32; 4]) -> Self {
1701 self.clip = true;
1702 self.raro().clip_inset = Some(insets);
1703 self
1704 }
1705
1706 /// Recorta los descendientes a una elipse — modela
1707 /// `clip-path: circle()`/`ellipse()`. `spec` es de 14 floats: centro
1708 /// `[cx_px, cx_pct, cy_px, cy_pct]` + dos radios `[px, pct_w, pct_h,
1709 /// pct_diag, side]`, todos resueltos contra el rect del nodo en el
1710 /// pintado. Activa el recorte (paint; hit-test usa el rect completo).
1711 pub fn clip_ellipse(mut self, spec: [f32; 14]) -> Self {
1712 self.clip = true;
1713 self.raro().clip_ellipse = Some(spec);
1714 self
1715 }
1716
1717 /// Recorta los descendientes a un polígono — modela `clip-path:
1718 /// polygon()`. `evenodd` = regla de relleno; cada punto `[x_px, x_pct,
1719 /// y_px, y_pct]` resuelve contra el rect del nodo en el pintado. Activa el
1720 /// recorte (paint; hit-test usa el rect completo).
1721 pub fn clip_polygon(mut self, evenodd: bool, points: Vec<[f32; 4]>) -> Self {
1722 self.clip = true;
1723 self.raro().clip_polygon = Some((evenodd, points));
1724 self
1725 }
1726
1727 /// Recorta los descendientes a un path SVG — modela `clip-path: path()`.
1728 /// `d` es el string SVG crudo (user units px, relativos al origen del
1729 /// rect); el pintado lo parsea con `BezPath::from_svg`. `evenodd` = regla
1730 /// de relleno. Activa el recorte (paint; hit-test usa el rect completo).
1731 pub fn clip_path_svg(mut self, evenodd: bool, d: impl Into<String>) -> Self {
1732 self.clip = true;
1733 self.raro().clip_path_svg = Some((evenodd, d.into()));
1734 self
1735 }
1736
1737 /// Fija la caja de referencia del clip-path (`<geometry-box>`): el rect del
1738 /// nodo se encoge por `insets` px `[top, right, bottom, left]` antes de
1739 /// resolver la forma. Sin forma, recorta a ese rect. Activa el recorte.
1740 pub fn clip_ref_inset(mut self, insets: [f32; 4]) -> Self {
1741 self.clip = true;
1742 self.raro().clip_ref_inset = Some(insets);
1743 self
1744 }
1745
1746 pub fn children(mut self, children: Vec<View<Msg>>) -> Self {
1747 self.children = children;
1748 self
1749 }
1750}
1751
1752#[cfg(test)]
1753mod semantics_tests {
1754 use super::*;
1755 use llimphi_layout::Style;
1756
1757 #[test]
1758 fn map_transforma_msg_y_recursa_hijos() {
1759 // `View::map` eleva el Msg de todo el árbol (para embeber el view de un
1760 // sub-app en un host). Verificamos el caso simple (on_click) en el nodo
1761 // y en un hijo.
1762 #[derive(Clone, PartialEq, Debug)]
1763 enum Sub {
1764 Hi,
1765 }
1766 #[derive(Clone, PartialEq, Debug)]
1767 enum Host {
1768 FromSub(Sub),
1769 }
1770 let child = View::<Sub>::new(Style::default()).on_click(Sub::Hi);
1771 let parent = View::<Sub>::new(Style::default())
1772 .on_click(Sub::Hi)
1773 .children(vec![child]);
1774 let mapped: View<Host> = parent.map(Host::FromSub);
1775 assert_eq!(mapped.on_click, Some(Host::FromSub(Sub::Hi)));
1776 assert_eq!(mapped.children.len(), 1);
1777 assert_eq!(mapped.children[0].on_click, Some(Host::FromSub(Sub::Hi)));
1778 }
1779
1780 #[test]
1781 fn clip_inset_setea_campo_y_activa_clip() {
1782 // `.clip_inset(...)` guarda los insets y activa el recorte (Fase 7.1219).
1783 let v = View::<()>::new(Style::default()).clip_inset([1.0, 2.0, 3.0, 4.0]);
1784 assert_eq!(raro!(v, clip_inset), Some([1.0, 2.0, 3.0, 4.0]));
1785 assert!(v.clip, "clip_inset implica clip activo");
1786 // `.clip(true)` solo (overflow:hidden) deja clip_inset en None.
1787 let h = View::<()>::new(Style::default()).clip(true);
1788 assert!(h.clip);
1789 assert_eq!(raro!(h, clip_inset), None);
1790 // Default: sin recorte.
1791 let d = View::<()>::new(Style::default());
1792 assert!(!d.clip);
1793 assert_eq!(raro!(d, clip_inset), None);
1794 }
1795
1796 #[test]
1797 fn clip_ellipse_setea_campo_y_activa_clip() {
1798 // `.clip_ellipse(...)` guarda el spec de 14 floats y activa el recorte
1799 // (Fase 7.1220 rect, 7.1221 radios %, 7.1222 lados).
1800 let spec =
1801 [0.0, 50.0, 0.0, 50.0, 30.0, 0.0, 0.0, 0.0, 0.0, 20.0, 0.0, 0.0, 0.0, 0.0];
1802 let v = View::<()>::new(Style::default()).clip_ellipse(spec);
1803 assert_eq!(raro!(v, clip_ellipse), Some(spec));
1804 assert!(v.clip, "clip_ellipse implica clip activo");
1805 // No interfiere con clip_inset (campos independientes).
1806 assert_eq!(raro!(v, clip_inset), None);
1807 // Default: sin elipse.
1808 let d = View::<()>::new(Style::default());
1809 assert_eq!(raro!(d, clip_ellipse), None);
1810 }
1811
1812 #[test]
1813 fn clip_polygon_setea_campo_y_activa_clip() {
1814 // `.clip_polygon(...)` guarda (evenodd, puntos) y activa el recorte
1815 // (Fase 7.1223).
1816 let pts = vec![[0.0, 0.0, 0.0, 0.0], [0.0, 100.0, 0.0, 0.0], [0.0, 50.0, 0.0, 100.0]];
1817 let v = View::<()>::new(Style::default()).clip_polygon(true, pts.clone());
1818 assert_eq!(raro!(v, clip_polygon), Some((true, pts)));
1819 assert!(v.clip, "clip_polygon implica clip activo");
1820 // No interfiere con elipse/inset.
1821 assert_eq!(raro!(v, clip_ellipse), None);
1822 assert_eq!(raro!(v, clip_inset), None);
1823 // Default: sin polígono.
1824 assert_eq!(raro!(View::<()>::new(Style::default()), clip_polygon), None);
1825 }
1826
1827 #[test]
1828 fn clip_path_svg_setea_campo_y_activa_clip() {
1829 // `.clip_path_svg(...)` guarda (evenodd, d) y activa el recorte
1830 // (Fase 7.1224). El string SVG debe parsear con kurbo.
1831 let v = View::<()>::new(Style::default()).clip_path_svg(false, "M0 0 L10 0 L10 10 Z");
1832 assert_eq!(raro!(v, clip_path_svg), Some((false, "M0 0 L10 0 L10 10 Z".to_string())));
1833 assert!(v.clip, "clip_path_svg implica clip activo");
1834 // El path de muestra parsea a un BezPath no vacío.
1835 let bez = llimphi_text::peniko::kurbo::BezPath::from_svg("M0 0 L10 0 L10 10 Z").unwrap();
1836 assert!(!bez.elements().is_empty());
1837 // Default: sin path.
1838 assert_eq!(raro!(View::<()>::new(Style::default()), clip_path_svg), None);
1839 }
1840
1841 #[test]
1842 fn clip_ref_inset_setea_campo_y_activa_clip() {
1843 // `.clip_ref_inset(...)` guarda la caja de referencia y activa el
1844 // recorte (Fase 7.1225).
1845 let v = View::<()>::new(Style::default()).clip_ref_inset([5.0, 5.0, 5.0, 5.0]);
1846 assert_eq!(raro!(v, clip_ref_inset), Some([5.0, 5.0, 5.0, 5.0]));
1847 assert!(v.clip, "clip_ref_inset implica clip activo");
1848 // Default: sin caja de referencia.
1849 assert_eq!(raro!(View::<()>::new(Style::default()), clip_ref_inset), None);
1850 }
1851
1852 #[test]
1853 fn mask_image_setea_campo_sin_tocar_clip() {
1854 // `.mask_image(img)` guarda la imagen-máscara para que el paint la
1855 // aplique como luminancia sobre el subárbol. Es ORTOGONAL al recorte:
1856 // NO activa `clip` (a diferencia de los `clip_*`). Fase 7.1226.
1857 use llimphi_text::peniko::{Blob, ImageAlphaType, ImageData, ImageFormat};
1858 let data = ImageData {
1859 data: Blob::from(vec![255u8, 255, 255, 255]),
1860 format: ImageFormat::Rgba8,
1861 alpha_type: ImageAlphaType::Alpha,
1862 width: 1,
1863 height: 1,
1864 };
1865 let v = View::<()>::new(Style::default()).mask_image(Image::new(data));
1866 assert!(raro!(v, mask_image).is_some(), "mask_image queda seteada");
1867 assert!(!v.clip, "mask_image NO activa clip (es ortogonal al recorte)");
1868 // Sin `mask_placement`, el paint estira al border-box (Fase 7.1226).
1869 assert!(raro!(v, mask_placement).is_none());
1870 // Default: sin máscara.
1871 assert!(raro!(View::<()>::new(Style::default()), mask_image).is_none());
1872 }
1873
1874 #[test]
1875 fn mask_placement_setea_encaje() {
1876 // `.mask_placement(...)` guarda el encaje (size/position/repeat/mode) que
1877 // el paint resuelve contra el rect, igual que background-image. Fase
1878 // 7.1227 (encaje), 7.1228 (mode).
1879 let p = MaskPlacement {
1880 size: MaskSize::Contain,
1881 pos_x: MaskLen::Pct(50.0),
1882 pos_y: MaskLen::Px(8.0),
1883 repeat_x: true,
1884 repeat_y: false,
1885 mode: MaskMode::Alpha,
1886 clip_inset: Some([2.0, 2.0, 2.0, 2.0]),
1887 origin_inset: None,
1888 };
1889 let v = View::<()>::new(Style::default()).mask_placement(p);
1890 assert_eq!(raro!(v, mask_placement), Some(p));
1891 // No activa clip ni implica máscara por sí solo (es sólo el encaje).
1892 assert!(!v.clip);
1893 assert!(raro!(v, mask_image).is_none());
1894 // Default: sin encaje (estira al border-box, modo luminancia).
1895 assert!(raro!(View::<()>::new(Style::default()), mask_placement).is_none());
1896 // El modo default del compositor es luminancia (el camino estirado de
1897 // la Fase 7.1226 sin placement). Fase 7.1228.
1898 assert_eq!(MaskMode::default(), MaskMode::Luminance);
1899 }
1900
1901 #[test]
1902 fn mask_extra_setea_capas_adicionales() {
1903 // `.mask_extra(...)` guarda las capas extra `(imagen, operador)` que el
1904 // paint combina con la capa 0 según el operador. Fase 7.1231.
1905 use llimphi_text::peniko::{Blob, ImageAlphaType, ImageData, ImageFormat};
1906 let mk = || {
1907 Image::new(ImageData {
1908 data: Blob::from(vec![255u8, 255, 255, 255]),
1909 format: ImageFormat::Rgba8,
1910 alpha_type: ImageAlphaType::Alpha,
1911 width: 1,
1912 height: 1,
1913 })
1914 };
1915 let v = View::<()>::new(Style::default())
1916 .mask_extra(vec![(mk(), MaskCompose::Intersect), (mk(), MaskCompose::Add)]);
1917 assert_eq!(raro!(v, mask_extra).len(), 2);
1918 assert_eq!(raro!(v, mask_extra)[0].1, MaskCompose::Intersect);
1919 assert_eq!(raro!(v, mask_extra)[1].1, MaskCompose::Add);
1920 // Default: sin capas extra; el operador default es `add`.
1921 assert!(raro!(View::<()>::new(Style::default()), mask_extra).is_empty());
1922 assert_eq!(MaskCompose::default(), MaskCompose::Add);
1923 }
1924
1925 #[test]
1926 fn aria_label_sobre_role_preserva_role() {
1927 let v = View::<()>::new(Style::default())
1928 .role(Role::Button)
1929 .aria_label("Guardar");
1930 let s = v.semantics.clone().expect("semantics");
1931 assert_eq!(s.role, Some(Role::Button));
1932 assert_eq!(s.label.as_deref(), Some("Guardar"));
1933 }
1934
1935 #[test]
1936 fn role_sobre_aria_label_preserva_label() {
1937 // Orden invertido: el segundo setter no debe pisar lo del primero.
1938 let v = View::<()>::new(Style::default())
1939 .aria_label("Buscar")
1940 .role(Role::TextInput);
1941 let s = v.semantics.clone().expect("semantics");
1942 assert_eq!(s.role, Some(Role::TextInput));
1943 assert_eq!(s.label.as_deref(), Some("Buscar"));
1944 }
1945
1946 #[test]
1947 fn flags_independientes_no_se_pisan() {
1948 let v = View::<()>::new(Style::default())
1949 .role(Role::Checkbox)
1950 .aria_checked(true)
1951 .aria_required(true);
1952 let s = v.semantics.clone().expect("semantics");
1953 assert_eq!(s.flags.checked, Some(true));
1954 assert_eq!(s.flags.required, Some(true));
1955 assert!(s.flags.disabled.is_none(), "no se setea lo que no se pidió");
1956 }
1957
1958 #[test]
1959 fn semantics_spec_completo_reemplaza_lo_acumulado() {
1960 // `.semantics(spec)` es el setter "todo o nada"; debe sobrescribir.
1961 let v = View::<()>::new(Style::default())
1962 .role(Role::Button)
1963 .aria_label("Vieja")
1964 .semantics(SemanticsSpec::role(Role::Link).with_label("Nueva"));
1965 let s = v.semantics.clone().expect("semantics");
1966 assert_eq!(s.role, Some(Role::Link));
1967 assert_eq!(s.label.as_deref(), Some("Nueva"));
1968 }
1969
1970 #[test]
1971 fn filter_setea_campo_sin_tocar_clip() {
1972 // `.filter([Blur])` guarda la lista de filtros del propio subárbol. Es
1973 // ORTOGONAL al recorte (NO activa clip) y al backdrop_blur. Fase 7.1232.
1974 let v = View::<()>::new(Style::default()).filter(vec![FilterOp::Blur(4.0)]);
1975 assert_eq!(raro!(v, filter), vec![FilterOp::Blur(4.0)]);
1976 assert!(!v.clip, "filter NO activa clip (es ortogonal al recorte)");
1977 assert!(raro!(v, backdrop_blur).is_none(), "filter NO es backdrop_blur");
1978 // Default: sin filtro.
1979 assert!(raro!(View::<()>::new(Style::default()), filter).is_empty());
1980 }
1981
1982 #[test]
1983 fn blend_setea_campo_sin_tocar_clip_ni_filter() {
1984 // `.blend(bm)` guarda el modo de mezcla del nodo entero (CSS
1985 // `mix-blend-mode`). Es ORTOGONAL a clip/filter/alpha. Fase 7.1237.
1986 let bm = BlendMode::from(llimphi_text::peniko::Mix::Multiply);
1987 let v = View::<()>::new(Style::default()).blend(bm);
1988 assert_eq!(raro!(v, blend), Some(bm));
1989 assert!(!v.clip, "blend NO activa clip (es ortogonal al recorte)");
1990 assert!(raro!(v, filter).is_empty(), "blend NO es filter");
1991 assert!(v.alpha.clone().is_none(), "blend NO es alpha");
1992 // Default: sin blend (source-over).
1993 assert!(raro!(View::<()>::new(Style::default()), blend).is_none());
1994 // El campo sobrevive el mount (llega al MountedNode).
1995 use llimphi_layout::LayoutTree;
1996 let mut layout = LayoutTree::new();
1997 let mounted = mount(&mut layout, View::<()>::new(Style::default()).blend(bm));
1998 assert_eq!(mounted.nodes[0].blend, Some(bm), "blend llega al MountedNode");
1999 }
2000
2001 #[test]
2002 fn collect_filters_aplana_ops_con_rect_del_nodo() {
2003 // `collect_filters` recolecta cada FilterOp con el rect computado del
2004 // nodo, en orden de lista. Verificamos el camino mount → compute →
2005 // collect sin GPU (la aplicación del blur en sí es GPU). Fase 7.1232.
2006 use llimphi_layout::taffy::prelude::{length, Size};
2007 use llimphi_layout::LayoutTree;
2008 let root = View::<()>::new(Style {
2009 size: Size { width: length(100.0), height: length(40.0) },
2010 ..Default::default()
2011 })
2012 .filter(vec![FilterOp::Blur(5.0)]);
2013 let mut layout = LayoutTree::new();
2014 let mounted = mount(&mut layout, root);
2015 let computed = layout
2016 .compute(mounted.root, (200.0, 200.0))
2017 .expect("layout");
2018 let passes = collect_filters(&mounted, &computed);
2019 assert_eq!(passes.len(), 1, "un FilterOp → un FilterPass");
2020 assert!(matches!(passes[0].op, FilterOp::Blur(s) if (s - 5.0).abs() < 1e-3));
2021 assert_eq!(passes[0].rect.2, 100.0, "ancho del rect del nodo");
2022 assert_eq!(passes[0].rect.3, 40.0, "alto del rect del nodo");
2023 }
2024
2025 #[test]
2026 fn collect_filters_vacio_sin_filtros() {
2027 // Sin `.filter(...)` en ningún nodo, la recolección es vacía (coste cero
2028 // en el runtime). Fase 7.1232.
2029 use llimphi_layout::LayoutTree;
2030 let root = View::<()>::new(Style::default());
2031 let mut layout = LayoutTree::new();
2032 let mounted = mount(&mut layout, root);
2033 let computed = layout.compute(mounted.root, (50.0, 50.0)).expect("layout");
2034 assert!(collect_filters(&mounted, &computed).is_empty());
2035 }
2036
2037 #[test]
2038 fn no_wrap_setea_campo_del_texto_fase_7_1253() {
2039 // `.no_wrap()` marca el TextSpec para shapear en una sola línea (CSS
2040 // `white-space: nowrap`). Ortogonal al resto del estilo de texto;
2041 // default false (wrap). No-op si el nodo no tiene texto.
2042 let v = View::<()>::new(Style::default())
2043 .text("hola mundo", 14.0, Color::BLACK)
2044 .no_wrap();
2045 let t = v.text.clone().expect("text");
2046 assert!(t.no_wrap, "no_wrap=true tras el builder");
2047 // Default: el texto envuelve (no_wrap=false).
2048 let def = View::<()>::new(Style::default()).text("x", 14.0, Color::BLACK);
2049 assert!(!def.text.clone().unwrap().no_wrap, "default es wrap");
2050 // No-op sin texto: no panickea ni inventa un TextSpec.
2051 let sin = View::<()>::new(Style::default()).no_wrap();
2052 assert!(sin.text.clone().is_none(), "no_wrap sin texto no crea TextSpec");
2053 }
2054
2055 #[test]
2056 fn overflow_wrap_setea_campo_del_texto_fase_7_1254() {
2057 // `.overflow_wrap()` marca el TextSpec para partir la palabra larga (CSS
2058 // `overflow-wrap: break-word`). Ortogonal al resto; default false (la
2059 // palabra desborda). No-op si el nodo no tiene texto.
2060 let v = View::<()>::new(Style::default())
2061 .text("palabralarga", 14.0, Color::BLACK)
2062 .overflow_wrap();
2063 let t = v.text.clone().expect("text");
2064 assert!(t.overflow_wrap, "overflow_wrap=true tras el builder");
2065 // Default: la palabra larga desborda (overflow_wrap=false).
2066 let def = View::<()>::new(Style::default()).text("x", 14.0, Color::BLACK);
2067 assert!(
2068 !def.text.clone().unwrap().overflow_wrap,
2069 "default es desbordar"
2070 );
2071 // No-op sin texto: no panickea ni inventa un TextSpec.
2072 let sin = View::<()>::new(Style::default()).overflow_wrap();
2073 assert!(sin.text.clone().is_none(), "overflow_wrap sin texto no crea TextSpec");
2074 }
2075
2076 #[test]
2077 fn font_features_y_variations_setean_campos_del_texto_fase_7_1256() {
2078 // `.font_features()`/`.font_variations()` guardan el string CSS crudo en
2079 // el TextSpec (parley/swash lo parsean al shapear). Ortogonales entre sí
2080 // y al resto; default vacío. No-op si el nodo no tiene texto.
2081 let v = View::<()>::new(Style::default())
2082 .text("hola", 14.0, Color::BLACK)
2083 .font_features("\"liga\" 0, \"kern\" 0".to_string())
2084 .font_variations("\"wght\" 700".to_string());
2085 let t = v.text.clone().expect("text");
2086 assert_eq!(t.font_features, "\"liga\" 0, \"kern\" 0");
2087 assert_eq!(t.font_variations, "\"wght\" 700");
2088 // Default: sin override (strings vacíos).
2089 let def = View::<()>::new(Style::default()).text("x", 14.0, Color::BLACK);
2090 let dt = def.text.clone().unwrap();
2091 assert!(dt.font_features.is_empty(), "default features vacío");
2092 assert!(dt.font_variations.is_empty(), "default variations vacío");
2093 // No-op sin texto: no panickea ni inventa un TextSpec.
2094 let sin = View::<()>::new(Style::default()).font_features("\"liga\" 1".to_string());
2095 assert!(sin.text.clone().is_none(), "font_features sin texto no crea TextSpec");
2096 }
2097
2098 #[test]
2099 fn font_width_setea_campo_del_texto_fase_7_1257() {
2100 // `.font_width()` guarda el porcentaje de ancho de la fuente en el
2101 // TextSpec (100 = normal). Ortogonal al resto; default 100. No-op sin texto.
2102 let v = View::<()>::new(Style::default())
2103 .text("hola", 14.0, Color::BLACK)
2104 .font_width(75.0);
2105 let t = v.text.clone().expect("text");
2106 assert_eq!(t.font_width, 75.0, "font_width=75 tras el builder");
2107 // Default: 100 (normal).
2108 let def = View::<()>::new(Style::default()).text("x", 14.0, Color::BLACK);
2109 assert_eq!(def.text.clone().unwrap().font_width, 100.0, "default 100 (normal)");
2110 // No-op sin texto.
2111 let sin = View::<()>::new(Style::default()).font_width(50.0);
2112 assert!(sin.text.clone().is_none(), "font_width sin texto no crea TextSpec");
2113 }
2114}