tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8
llimphi-compositor/src/view.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
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}