tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ main llimphi-compositor/src/view.rs Rust · 2114 lines · 88.6 KB History Who touched what raw

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

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}