tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Sutichaykuna

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 llimphi-compositor/src/view.rs Rust · 2072 siq'ikuna · 84.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

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