Skip to main content

llimphi_compositor/
view.rs

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