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