llimphi-compositor 0.1.0

llimphi-compositor — el núcleo declarativo de Llimphi sin winit: el árbol `View<Msg>`, el mount sobre taffy, el paint a `vello::Scene` y el hit-test. No depende de llimphi-hal ni de una surface concreta, así que la misma composición sirve sobre winit (llimphi-ui) o, a futuro, sobre el framebuffer del kernel wawa. `wgpu` entra sólo por la firma de `GpuPaintFn` (tipos, no windowing).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
//! llimphi-compositor — el núcleo declarativo de Llimphi, sin winit.
//!
//! Aquí vive el árbol de vista `View<Msg>` (DSL declarativo), su instalación
//! sobre taffy (`mount`), el pintado a `vello::Scene` (`paint`/`paint_gpu`) y
//! el hit-test. Nada de esto necesita una ventana ni `llimphi-hal`: la
//! composición `view → layout → scene` es pura y reutilizable.
//!
//! El runtime que la maneja vive aparte:
//! - `llimphi-ui` la corre sobre winit (`run<A: App>()`).
//! - a futuro, un runtime sobre el framebuffer del kernel `wawa` puede
//!   reusar exactamente este compositor sin arrastrar winit.
//!
//! `wgpu` entra sólo por la firma de [`GpuPaintFn`] (tipos de Device/Queue/
//! Encoder/TextureView); `wgpu` no depende de winit, así que el compositor
//! sigue libre de windowing.

use std::collections::HashMap;
use std::sync::Arc;

use llimphi_layout::taffy::NodeId;
use llimphi_layout::{ComputedLayout, LayoutTree, Style};
use vello::kurbo::{
    Affine, Ellipse, Point, Rect as KurboRect, RoundedRect, RoundedRectRadii, Stroke,
};
use vello::peniko::{BlendMode, Color, Fill, Gradient, ImageBrush as Image, Mix};

mod anim;
mod hero;
mod layout_builder;
mod render;
mod ripple;
mod semantics;
mod view;
pub use anim::{
    ease_out_cubic, reconcile_size_anim, Anim, AnimRegistry, SizeAnim, SizeAnimRegistry,
};
pub use hero::{Hero, HeroRegistry};
pub use layout_builder::{collect_builder_constraints, expand_layout_builders, has_layout_builder};
pub use render::*;
pub use ripple::{Ripple, RippleRegistry};
pub use semantics::{Role, SemanticsFlags, SemanticsSpec};

/// Texto a pintar dentro de un nodo. Alineación por defecto `Center`
/// (horizontal y vertical), apta para labels de botón. Para layouts tipo
/// editor o párrafo, usar `.text_aligned(...)` con `Alignment::Start`.
#[derive(Clone)]
pub struct TextSpec {
    pub content: String,
    pub size_px: f32,
    pub color: Color,
    pub alignment: llimphi_text::Alignment,
    /// `true` = forzar variante italic en la fuente activa. Default false.
    pub italic: bool,
    /// Peso de fuente CSS: 400 = normal, 700 = bold. parley elige la
    /// variante más cercana de la familia activa (o la sintetiza). Se usa
    /// tanto al **medir** como al **pintar**, así medida y dibujo coinciden.
    /// Default 400.
    pub weight: f32,
    /// Límite de líneas (CSS `-webkit-line-clamp` / Flutter `maxLines`). `None`
    /// = sin límite (envuelve libre). Cuando el texto excede, se trunca: con
    /// [`Self::ellipsis`] la última línea termina en `…`, sin él se corta seco.
    /// Afecta medida (taffy reserva el alto de N líneas) y pintado.
    pub max_lines: Option<usize>,
    /// Si `true` y `max_lines` trunca, la última línea visible termina en `…`.
    /// Sin efecto si `max_lines` es `None`. Default false.
    pub ellipsis: bool,
    /// CSS-style font-family string (acepta lista con fallbacks). `None`
    /// = la fuente default de parley.
    pub font_family: Option<String>,
    /// Múltiplo de interlínea (`line-height` / `font-size`). 1.2 es el
    /// default que usaban todos los callers; puriy lo sobreescribe con el
    /// valor computado de CSS. Se usa tanto al **medir** (para que taffy
    /// reserve el alto correcto) como al **pintar**, así medida y dibujo
    /// coinciden.
    pub line_height: f32,
    /// Colores por rango de **bytes** sobre `content`, para texto multicolor
    /// (syntax highlighting) en una sola pasada de shaping. `None` = color
    /// uniforme (`color`). Cuando es `Some`, el runtime usa
    /// `Typesetter::layout_runs` + `draw_layout_runs`, y `color` actúa como
    /// color por defecto de lo no cubierto por ningún run.
    pub runs: Option<Vec<(usize, usize, Color)>>,
    /// Subrayado activo. El runtime pinta la línea bajo la línea base usando
    /// las métricas (`underline_offset`, `underline_size`) que parley deriva
    /// de la fuente — así un texto a 12pt y otro a 24pt tienen un subrayado
    /// proporcional sin que el caller calcule nada.
    pub underline: bool,
    /// Tachado activo. Mismo régimen que [`Self::underline`] pero sobre el
    /// strikethrough metric — útil para listas to-do, items removidos en un
    /// diff, precios viejos.
    pub strikethrough: bool,
    /// **Spans inline mixtos** (RichText): overrides de
    /// tamaño/peso/italic/familia/color/underline/strikethrough por rango
    /// de bytes (parley convention). `None` = texto uniforme (camino
    /// `layout_clamped`); `Some([])` se trata como `None`. Cuando hay
    /// spans, el runtime usa `Typesetter::layout_spans` (Layout<RunBrush>
    /// con `max_width`/wrap) + `draw_layout_runs_xf`; los campos del
    /// `TextSpec` son **defaults a nivel bloque** que cada span puede
    /// sobreescribir. Tier 2 final de PARIDAD-FLUTTER (Bloque 13).
    pub spans: Option<Vec<llimphi_text::TextSpan>>,
    /// `letter-spacing`: px **extra** entre letras (CSS). 0 = normal. Afecta
    /// shaping y medida. Sólo el camino uniforme (`layout_clamped`); el camino
    /// de spans (RichText) lo ignora en v1.
    pub letter_spacing: f32,
    /// `word-spacing`: px **extra** entre palabras (CSS). 0 = normal. Mismo
    /// régimen que [`Self::letter_spacing`].
    pub word_spacing: f32,
    /// `white-space: nowrap`/`pre`: si `true`, el texto **no envuelve** —
    /// se shapea en una sola línea (`break_all_lines(None)`) sin importar el
    /// ancho disponible, y desborda la caja (lo recorta `overflow: hidden` si
    /// lo hay). Afecta medida (taffy reserva el ancho de la línea completa) y
    /// pintado. Default false (wrap libre, comportamiento previo). Sólo el
    /// camino uniforme (`layout_clamped`); el de spans (RichText) lo ignora en
    /// v1, igual que el clamp.
    pub no_wrap: bool,
    /// `overflow-wrap: break-word`/`anywhere` (o `word-break: break-all`): si
    /// `true`, una palabra más ancha que la caja se **parte** para que entre,
    /// en vez de desbordar. Afecta medida (taffy puede reservar menos ancho) y
    /// pintado. Default false (la palabra larga desborda — comportamiento
    /// previo). Sólo el camino uniforme (`layout_clamped`); el de spans
    /// (RichText) lo ignora en v1, igual que `no_wrap`/clamp.
    pub overflow_wrap: bool,
}

/// Fase de un drag activo. `Move` se emite por cada `CursorMoved` con el
/// delta desde el evento anterior; `End` se emite al soltar el botón.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DragPhase {
    Move,
    End,
}

/// Handler de drag. Recibe la fase + delta (`dx`, `dy`) **desde el evento
/// anterior** (no acumulado desde el press). Devolver `None` deja el drag
/// activo sin disparar Msg. `Arc<dyn Fn>` para que el runtime pueda
/// clonarlo barato al iniciar el drag y mantenerlo vivo aunque el cache
/// de la vista se regenere mientras tanto.
pub type DragFn<Msg> = Arc<dyn Fn(DragPhase, f32, f32) -> Option<Msg> + Send + Sync>;

/// Handler de drop. El runtime lo invoca cuando un drag activo se suelta
/// sobre este nodo. Recibe el `payload` `u64` que el origen del drag
/// declaró vía [`View::drag_payload`]. Devolver `None` ignora el drop.
///
/// Los IDs `u64` son opacos para el runtime: el widget elige una
/// convención (índice de tile, hash del item, etc.) y el handler decide
/// qué Msg emitir en función de ese ID.
pub type DropFn<Msg> = Arc<dyn Fn(u64) -> Option<Msg> + Send + Sync>;

/// Handler de click con posición. Recibe `(x_local, y_local, rect_w,
/// rect_h)`: las dos primeras son la posición del cursor **relativa a
/// la esquina superior-izquierda del nodo** y las dos últimas son el
/// ancho/alto actual del nodo en pixels — útil cuando el caller
/// necesita centrar o normalizar. Devolver `None` no dispara update.
pub type ClickAtFn<Msg> = Arc<dyn Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync>;

/// Handler de rueda **local a un nodo**. Recibe el delta `(dx, dy)` en
/// líneas lógicas (misma normalización que `App::on_wheel`: `dy` positivo
/// = scroll hacia abajo). El runtime lo invoca cuando la rueda gira con el
/// cursor sobre este nodo, ANTES de caer al `App::on_wheel` global: si el
/// handler devuelve `Some(Msg)`, el evento se consume acá. Permite áreas
/// de scroll autocontenidas (el widget `scroll` lo usa) sin que cada app
/// rutee la rueda a mano por su `Model`. Devolver `None` deja pasar el
/// evento al `on_wheel` global.
pub type ScrollFn<Msg> = Arc<dyn Fn(f32, f32) -> Option<Msg> + Send + Sync>;

/// Variante de [`DragFn`] que **conoce la posición inicial del press**
/// relativa al rect del nodo. Útil cuando el caller necesita identificar
/// qué entidad (Concepto, lemming, etc.) bajo el cursor agarró el drag.
/// Recibe `(phase, dx, dy, initial_lx, initial_ly)`.
pub type DragAtFn<Msg> = Arc<dyn Fn(DragPhase, f32, f32, f32, f32) -> Option<Msg> + Send + Sync>;

/// Variante de [`DragFn`] que recibe la **velocidad del drag al soltarlo**
/// (`vx`, `vy` en px/s). El runtime mide el desplazamiento sobre los
/// últimos ~100 ms de movimiento (ventana móvil de hasta ocho samples)
/// y la pasa en `DragPhase::End`. Durante `DragPhase::Move` ambas son
/// `0.0` — la velocidad sólo es significativa al final. Permite
/// **fling-desde-drag**: el caller arranca un ticker con esa velocidad y
/// la decae con [`fling_step`](https://docs.rs/) hasta asentar. Reemplaza
/// la estimación manual que antes tenía que llevar el caller con
/// `Instant::now()` por su cuenta.
pub type DragVelocityFn<Msg> =
    Arc<dyn Fn(DragPhase, f32, f32, f32, f32) -> Option<Msg> + Send + Sync>;

/// Fase de un **gesto continuo** (pinch-to-zoom de momento; rotación a futuro).
/// El runtime emite `Begin` al iniciar el gesto, `Update` por cada cambio
/// incremental y `End` al terminar. El camino de Ctrl+rueda (universal, sin
/// trackpad) emite un único `Update` por click de rueda — no hay un "inicio"
/// ni "fin" naturales, así que el handler debe tolerar `Update`s sueltos sin
/// `Begin` previo (es lo común en desktop). El camino de trackpad
/// (`PinchGesture`, sólo macOS/iOS) sí entrega `Begin`/`Update*`/`End`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GesturePhase {
    Begin,
    Update,
    End,
}

/// Handler de gesto de **escala** (pinch-to-zoom). Recibe `(phase, factor,
/// focal_x, focal_y)`:
/// - `factor`: cambio de escala **incremental y multiplicativo** desde el
///   evento anterior — `1.0` = sin cambio, `>1.0` agranda (zoom in), `<1.0`
///   achica (zoom out). El caller acumula con `mi_zoom *= factor` y, si
///   quiere, lo clampa a su rango. En `Begin`/`End` el factor es `1.0`.
/// - `focal_x`/`focal_y`: punto focal del gesto **relativo a la esquina
///   superior-izquierda del rect del nodo** (mismo espacio que los handlers
///   `*_at`). Es el punto que debe quedar fijo bajo el cursor al hacer zoom —
///   el caller lo usa para zoomear "hacia el cursor" en vez de hacia el
///   centro. En Ctrl+rueda es la posición del cursor; en trackpad, idem.
///
/// Devolver `Some(Msg)` dispara una transición; `None` ignora el evento. El
/// runtime lo resuelve con [`hit_test_scale`]: el nodo más al frente bajo el
/// cursor que declare un `on_scale` consume el gesto. Es la base del zoom de
/// los canvases (pineal/cosmos/nakui).
pub type ScaleFn<Msg> = Arc<dyn Fn(GesturePhase, f32, f32, f32) -> Option<Msg> + Send + Sync>;

/// Handler de gesto de **rotación** (trackpad, sólo macOS — winit no emite
/// `RotationGesture` en Wayland/Windows). Análogo a [`ScaleFn`] pero el
/// segundo argumento es el **delta de ángulo incremental en radianes**
/// (positivo = horario) en lugar del factor de escala; `(focal_x, focal_y)`
/// es el punto bajo el cursor relativo al rect del nodo. El nodo más al
/// frente bajo el cursor que declare un `on_rotate` consume el gesto. Base
/// para rotar canvases/imágenes con dos dedos. Ver [`View::on_rotate`].
pub type RotateFn<Msg> = Arc<dyn Fn(GesturePhase, f32, f32, f32) -> Option<Msg> + Send + Sync>;

/// Restricciones de tamaño que un [`LayoutBuilderFn`] recibe: las dimensiones
/// del slot que el layout le asignó al nodo (en px físicos). Análogo a las
/// `BoxConstraints` de Flutter `LayoutBuilder` / al `MediaQuery` pero **local
/// al nodo** (no a la ventana). El builder construye su subárbol en función de
/// esto — p. ej. una columna si `max_width < 600`, dos si es ancho.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Constraints {
    pub max_width: f32,
    pub max_height: f32,
}

/// Constructor **diferido** de subárbol sensible al tamaño (Flutter
/// `LayoutBuilder`). El runtime resuelve el tamaño del slot del nodo en una
/// primera pasada de layout y luego invoca esta closure con esas
/// [`Constraints`] para producir los hijos — así "construir distinto según el
/// espacio disponible" deja de exigir conocer el tamaño al armar el `View`. Ver
/// [`View::layout_builder`].
pub type LayoutBuilderFn<Msg> = Arc<dyn Fn(Constraints) -> View<Msg> + Send + Sync>;

/// Rect absoluto del nodo (en coordenadas físicas del frame). Lo
/// recibe el callback de [`View::paint_with`] para que pueda
/// posicionar sus primitivas custom dentro del nodo.
#[derive(Debug, Clone, Copy, Default)]
pub struct PaintRect {
    pub x: f32,
    pub y: f32,
    pub w: f32,
    pub h: f32,
}

/// Callback de pintura custom. El runtime lo invoca durante el paint
/// del nodo (entre el `fill`/`image` y el `text`) con el `Scene` vivo
/// + el `Typesetter` cacheado del runtime + el rect absoluto del nodo.
/// Pensado para "canvas elements" tipo `dominium-canvas`,
/// `pluma-editor` (osciloscopio de coherencia), `cosmos` (charts).
///
/// El `Typesetter` se pasa porque crearlo por frame es caro
/// (`FontContext::new` enumera las fontes del sistema vía fontique).
/// Los callers que no necesiten texto pueden ignorar el argumento.
///
/// El callback no debe llamar a `scene.push_layer` sin un `pop_layer`
/// correspondiente, ni reset el scene — sólo agregar primitivas que
/// pertenezcan al rect del nodo.
pub type PaintFn = Arc<
    dyn Fn(&mut vello::Scene, &mut llimphi_text::Typesetter, PaintRect) + Send + Sync,
>;

/// Callback de pintura GPU directo, sin vello intermedio. Recibe el
/// `device`/`queue` ya construidos por el runtime más un
/// `CommandEncoder` y la `TextureView` del frame (la intermediate
/// `Rgba8Unorm` de `WinitSurface`), todo durante el paint del nodo.
///
/// El caller abre su propio `begin_render_pass` con `LoadOp::Load` para
/// no sobrescribir lo que ya pintó vello, dibuja sus primitivas y
/// cierra el pass. El runtime se encarga de dispatchear (`queue.submit`)
/// el encoder ya con todas las pasadas de todos los nodos acumuladas —
/// es un solo submit por frame.
///
/// **Orden de pintura en Fase 1**: todos los `gpu_painter` corren
/// DESPUÉS de la pasada completa de vello (fill, image, painter,
/// text) sobre el `mounted` tree. Entre sí mantienen el orden DFS
/// pre-orden. Si una app necesita pintar texto **encima** del render
/// GPU directo, la forma idiomática es ponerlo en `App::view_overlay`,
/// que se renderiza como una segunda Scene de vello encima de todo.
///
/// Pensado para apps con volumen masivo de primitivos (cosmos
/// starfield Gaia, tinkuy particle viewer, nakui viewport, pineal
/// denso) — el hook que paga el costo de mantener pipelines WGSL
/// propias en `llimphi-raster` (ver `02_ruway/llimphi/SDD.md`
/// §"Roadmap — GPU directo wgpu").
pub type GpuPaintFn = Arc<
    dyn Fn(
            &wgpu::Device,
            &wgpu::Queue,
            &mut wgpu::CommandEncoder,
            &wgpu::TextureView,
            PaintRect,
            (u32, u32),
        ) + Send
        + Sync,
>;

/// Callback de pintura vello "over": idéntico en firma a [`PaintFn`]
/// `(&mut Scene, &mut Typesetter, PaintRect)`, pero el runtime lo invoca
/// en una pasada vello FINAL, **después** de todos los `gpu_painter` del
/// frame. Sus primitivas se rasterizan sobre fondo transparente y se
/// componen con alpha encima de la intermedia (que ya tiene
/// vello-base + GPU directo). Resuelve el z-order inverso al de
/// [`GpuPaintFn`]: permite pintar texto/sprites AA por vello **encima**
/// de celdas instanciadas por GPU (dominium grid, futuro motor voxel).
///
/// Orden total del frame: `[vello base] → [gpu_painter] → [over_painter]
/// → [overlay/menús]`. Los menús (`view_overlay`) siguen quedando por
/// encima del over-layer. Ver [`View::paint_over`]. Es un alias de
/// [`PaintFn`]; existe sólo para documentar la semántica temporal.
pub type OverPaintFn = PaintFn;

/// Sombra proyectada detrás del rect del nodo (drop shadow), rasterizada
/// con el `draw_blurred_rounded_rect` nativo de vello. Se pinta **antes**
/// del relleno, así el fill (si es opaco) tapa la parte solapada y la
/// sombra sólo asoma por el desenfoque + el offset. El radio sigue al del
/// nodo (más `spread`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Shadow {
    pub color: Color,
    /// Desviación estándar del gaussiano (qué tan difusa). En px.
    pub blur: f64,
    /// Desplazamiento de la sombra respecto del nodo.
    pub dx: f64,
    pub dy: f64,
    /// Cuánto crece (px) el rect de la sombra respecto del nodo.
    pub spread: f64,
}

impl Shadow {
    /// Sombra con color + blur explícitos, sin offset ni spread.
    pub fn new(color: Color, blur: f64) -> Self {
        Self { color, blur, dx: 0.0, dy: 0.0, spread: 0.0 }
    }

    /// Elevación suave y tasteful: negro translúcido, leve caída hacia
    /// abajo. El default razonable para cards/menús/modales.
    pub fn soft(alpha: u8, blur: f64) -> Self {
        Self {
            color: Color::from_rgba8(0, 0, 0, alpha),
            blur,
            dx: 0.0,
            dy: blur * 0.4,
            spread: 0.0,
        }
    }

    pub fn offset(mut self, dx: f64, dy: f64) -> Self {
        self.dx = dx;
        self.dy = dy;
        self
    }

    pub fn spread(mut self, spread: f64) -> Self {
        self.spread = spread;
        self
    }
}

/// Borde (stroke) pintado sobre el contorno redondeado del nodo, **inset**
/// hacia adentro media línea para que el grosor quede dentro del rect
/// (convención CSS `box-sizing: border-box`). Se pinta después del relleno.
#[derive(Clone, Copy, Debug)]
pub struct Border {
    pub width: f64,
    pub color: Color,
}

impl Border {
    pub fn new(width: f64, color: Color) -> Self {
        Self { width, color }
    }
}

/// Una operación de filtro CSS (`filter: blur()/brightness()/…`) aplicada al
/// **propio subárbol** del nodo. A diferencia de `backdrop_blur` (que afecta lo
/// pintado *debajo*), un `FilterOp` modifica el contenido del nodo. El runtime
/// los aplica como post-pasada GPU sobre la intermediate, restringidos al rect
/// del nodo, en el orden de la lista. La lista crece por fase (CSS Filter
/// Effects 1): `Blur` (7.1232) + `ColorMatrix` (7.1233). Fase 7.1232.
#[derive(Clone, Debug, PartialEq)]
pub enum FilterOp {
    /// `filter: blur(<px>)`. `px` es la desviación estándar del Gauss (igual
    /// convención que CSS). Se aplica con `BlurCompositor`, el mismo camino que
    /// `backdrop_blur`.
    Blur(f32),
    /// Filtros de color (`brightness`/`contrast`/`grayscale`/`sepia`/`saturate`/
    /// `invert`/`hue-rotate`/`opacity`) colapsados a una **matriz de color 4×5**
    /// row-major: por fila `[c0, c1, c2, c3, bias]`, salida R/G/B/A
    /// (`out = M·rgba + bias`). Se aplica con `ColorFilterCompositor`. Fase
    /// 7.1233.
    ColorMatrix([f32; 20]),
    /// `filter: drop-shadow(<ox> <oy> [blur] [color])`. Se pinta como una sombra
    /// Gaussiana del **border-box** detrás del nodo (con `draw_blurred_rounded_rect`,
    /// igual primitiva que `Shadow`/box-shadow). v1: sombra del rect, no de la
    /// silueta alpha del subárbol. A diferencia de `Blur`/`ColorMatrix`, NO es
    /// post-pasada GPU — se pinta en vello antes del relleno, por lo que
    /// `collect_filters` la ignora. Fase 7.1234.
    DropShadow(Shadow),
}

/// Punto de pivote de `transform` (CSS `transform-origin`). Cada eje se resuelve
/// contra el rect del nodo como `px + frac · tamaño`: `px` (ya escalado por zoom
/// por el caller) cubre offsets absolutos y `frac` los porcentuales (`0.5` = 50%
/// del ancho/alto). El default CSS `50% 50%` (centro) es
/// `{ px: (0.0, 0.0), frac: (0.5, 0.5) }`; un nodo con `transform_origin: None`
/// usa ese centro. Modela `px + %` por eje igual que `transform_rel` modela el
/// `translate(<%>)` — necesario porque el % depende del layout, desconocido hasta
/// `paint`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TransformPivot {
    /// Offset absoluto en px (ya × zoom) por eje `(x, y)`.
    pub px: (f64, f64),
    /// Fracción del tamaño del rect por eje `(x, y)` (`0.5` = 50%).
    pub frac: (f64, f64),
}

impl Default for TransformPivot {
    fn default() -> Self {
        // CSS `transform-origin: 50% 50%` — centro del rect.
        Self { px: (0.0, 0.0), frac: (0.5, 0.5) }
    }
}

/// Nodo de la vista declarativa. Estilo de layout (taffy) + relleno opcional
/// (vello) + texto opcional (skrifa+vello) + Msg al click opcional + hijos.
pub struct View<Msg> {
    pub style: Style,
    pub fill: Option<Color>,
    /// Relleno cuando el cursor está sobre este nodo. Sin valor (`None`)
    /// = no se reacciona al hover.
    pub hover_fill: Option<Color>,
    pub radius: f64,
    /// Radio **por esquina** (top-left, top-right, bottom-right, bottom-left),
    /// que sobreescribe a `radius` cuando está presente. Permite cards con
    /// sólo las esquinas de arriba redondeadas, pestañas, bocadillos de chat,
    /// etc. (CSS `border-radius` con 4 valores). `None` = usar el `radius`
    /// uniforme. Ver [`View::radius_corners`]. La **sombra** sigue usando un
    /// radio escalar (el blur nativo de vello no acepta radios por esquina);
    /// el **borde** sí respeta las cuatro esquinas.
    pub corner_radii: Option<RoundedRectRadii>,
    /// Sombra proyectada detrás del nodo (drop shadow). `None` = sin sombra
    /// (la mayoría de nodos). Ver [`Shadow`].
    pub shadow: Option<Shadow>,
    /// Relleno con **gradiente**, autoreado en el cuadrado unidad `[0,1]²` y
    /// mapeado al rect del nodo. Gana sobre `fill` como base; `hover_fill`
    /// (un color) lo sigue overrideando en hover. Ver [`View::fill_gradient`].
    pub fill_gradient: Option<Gradient>,
    /// Borde (stroke) sobre el contorno redondeado. Ver [`Border`].
    pub border: Option<Border>,
    pub text: Option<TextSpec>,
    /// Imagen a pintar dentro del rect del nodo. Se centra y escala
    /// según [`Self::image_fit`] (default `Contain` = preservar
    /// aspect ratio cabiendo). El alfa por píxel de la imagen y el
    /// `Image::alpha` global se respetan; el `fill` (si lo hay) se
    /// pinta debajo como background. El clip al `node_rrect` respeta
    /// `radius`/`corner_radii`, así avatares y cards con esquinas
    /// redondeadas funcionan sin envolver en un padre `clip(true)`.
    pub image: Option<Image>,
    /// Política de encaje de [`Self::image`] en el rect del nodo
    /// (CSS `object-fit`). `None` = `Contain` (el default histórico).
    /// Ver [`ImageFit`] y [`View::image_fit`].
    pub image_fit: Option<ImageFit>,
    /// **Máscara de luminancia** (CSS `mask-image`). Si está presente, el
    /// runtime aísla el subárbol del nodo en una capa y luego lo enmascara con
    /// la luminancia de esta imagen (`push_luminance_mask_layer` de vello):
    /// blanco = visible, negro = oculto, gris = semitransparente. El encaje lo
    /// fija [`Self::mask_placement`] (size/position/repeat); sin él la imagen se
    /// estira al border-box. `None` = sin máscara. Ver [`View::mask_image`].
    pub mask_image: Option<Image>,
    /// Encaje de [`Self::mask_image`] (CSS `mask-size`/`-position`/`-repeat`).
    /// `None` = estirar al border-box (Fase 7.1226). Sólo se consulta si
    /// `mask_image` está presente. Ver [`MaskPlacement`] y
    /// [`View::mask_placement`]. Fase 7.1227.
    pub mask_placement: Option<MaskPlacement>,
    /// Capas de máscara ADICIONALES (`mask-image: url(a), url(b), …`): cada una
    /// es `(imagen, operador)`. Comparten [`Self::mask_placement`] con la capa 0
    /// ([`Self::mask_image`]); se combinan con ella según el operador. Vacío =
    /// una sola capa. Ver [`View::mask_extra`]. Fase 7.1231.
    pub mask_extra: Vec<(Image, MaskCompose)>,
    /// Callback de pintura custom. Si está presente, el runtime lo
    /// invoca durante el paint del nodo con el `Scene` vivo + el rect
    /// absoluto. Pensado para "canvas elements" (dominium, pluma,
    /// cosmos) que pintan primitivas custom no expresables como una
    /// composición de Views.
    pub painter: Option<PaintFn>,
    /// Pintor GPU directo. Se invoca DESPUÉS de la pasada vello del
    /// frame; comparte tree y orden DFS con los demás. Ver
    /// [`GpuPaintFn`].
    pub gpu_painter: Option<GpuPaintFn>,
    /// Pintor vello "over": closure que pinta DESPUÉS del pase GPU del
    /// frame, sobre una escena vello que el runtime compone con alpha
    /// encima de la intermedia. Sirve para sprites/texto AA encima de
    /// celdas instanciadas por GPU. Ver [`View::paint_over`] y
    /// [`OverPaintFn`]. Misma firma que [`PaintFn`] — sólo cambia
    /// *cuándo* corre (post-GPU). `None` = sin over-layer (coste cero).
    pub over_painter: Option<PaintFn>,
    pub on_click: Option<Msg>,
    /// Handler de click que recibe la posición **relativa al rect del
    /// nodo** (esquina superior-izquierda del nodo = `(0, 0)`). Útil
    /// para canvas elements que quieren mapear el click a coordenadas
    /// de mundo. Si está presente, gana sobre `on_click`. Devolver
    /// `None` no dispara update.
    pub on_click_at: Option<ClickAtFn<Msg>>,
    /// Equivalente a `on_click` pero para el botón derecho del ratón.
    /// Pensado para menús contextuales: el nodo declara qué `Msg`
    /// emitir cuando se le hace right-click, y la app abre el overlay
    /// con el menú.
    pub on_right_click: Option<Msg>,
    /// Variante posicional de [`Self::on_right_click`]. Útil para
    /// grillas que necesitan saber *qué celda* del rect recibió el
    /// click derecho (la celda no es un nodo aparte, sino una región
    /// dentro del nodo). Si está presente, gana sobre `on_right_click`.
    pub on_right_click_at: Option<ClickAtFn<Msg>>,
    /// Equivalente a `on_click` pero para el botón del medio del ratón
    /// (rueda presionada). Pensado para abrir en pestaña nueva — los
    /// browsers usan middle-click como atajo equivalente a Ctrl+Click.
    pub on_middle_click: Option<Msg>,
    /// Handler de drag. Si está presente, este nodo arrastra (y NO emite
    /// `on_click` al presionar — un nodo es uno u otro).
    pub drag: Option<DragFn<Msg>>,
    /// Variante de drag que recibe la posición inicial del press relativa
    /// al rect del nodo. Gana sobre `drag` si ambos están presentes.
    pub drag_at: Option<DragAtFn<Msg>>,
    /// Variante de drag que recibe la **velocidad** al soltar (`vx`, `vy`
    /// en px/s) además del delta puntual. Gana sobre `drag`/`drag_at`
    /// cuando está presente — un nodo elige un único sabor de drag. Habilita
    /// fling-desde-drag (el caller arranca un ticker con esa velocidad y la
    /// decae con [`fling_step`]).
    pub drag_velocity: Option<DragVelocityFn<Msg>>,
    /// Payload `u64` que viaja con el drag iniciado sobre este nodo. Lo
    /// recibe el handler [`Self::on_drop`] del drop target. Sin payload,
    /// el drag funciona igual pero ningún drop target reacciona.
    pub drag_payload: Option<u64>,
    /// Handler invocado al soltar un drag sobre este nodo (drop target).
    pub on_drop: Option<DropFn<Msg>>,
    /// Color a pintar mientras un drag activo está hovereando este drop
    /// target. Sobrepone a `fill`/`hover_fill` cuando aplica.
    pub drop_hover_fill: Option<Color>,
    /// Si `true`, los descendientes se recortan al rect del nodo (vía
    /// `scene.push_layer` con `Mix::Clip`). El hit-test también respeta
    /// el recorte: clicks fuera del rect ignoran a los hijos.
    pub clip: bool,
    /// Si `Some([top, right, bottom, left])`, recorta los descendientes a un
    /// rect ENCOGIDO por esos insets (px) desde el rect del nodo — modela
    /// `clip-path: inset(...)`. Implica clip aunque `clip == false`.
    pub clip_inset: Option<[f32; 4]>,
    /// Si `Some(spec)` (14 floats), recorta los descendientes a una ELIPSE —
    /// modela `clip-path: circle()`/`ellipse()`. El centro (4) se resuelve
    /// contra el rect: `cx = cx_px + cx_pct/100·w`, `cy = cy_px +
    /// cy_pct/100·h`. Cada radio (5: `[px, pct_w, pct_h, pct_diag, side]`) con
    /// `side == 0` suma `px + pct_w/100·w + pct_h/100·h + pct_diag/100·diag`
    /// (`diag = √(w²+h²)/√2`); con `side != 0` se computa desde la distancia
    /// del centro a los bordes (`1`/`2` = closest/farthest sobre los 4 lados;
    /// `3`/`4` = ídem sobre el eje del radio). Layout: `[cx×2, cy×2, rx×5,
    /// ry×5]`. Implica clip aunque `clip == false`. Si conviven `clip_inset` y
    /// `clip_ellipse`, gana la elipse (una sola capa de recorte por nodo).
    pub clip_ellipse: Option<[f32; 14]>,
    /// Si `Some((evenodd, puntos))`, recorta los descendientes a un POLÍGONO —
    /// modela `clip-path: polygon()`. Cada punto `[x_px, x_pct, y_px, y_pct]`
    /// resuelve `(x_px + x_pct/100·w, y_px + y_pct/100·h)` contra el rect.
    /// `evenodd` elige la regla de relleno. Implica clip aunque `clip ==
    /// false`. Prioridad de recorte por nodo: polygon > elipse > inset > rect.
    pub clip_polygon: Option<(bool, Vec<[f32; 4]>)>,
    /// Si `Some((evenodd, d))`, recorta los descendientes a un PATH SVG —
    /// modela `clip-path: path()`. `d` es el string SVG crudo (user units px,
    /// relativos al origen del rect); el pintado lo parsea con
    /// `BezPath::from_svg` y lo traslada al origen del nodo. Si el parseo
    /// falla, no recorta. Implica clip aunque `clip == false`. Prioridad:
    /// path > polygon > elipse > inset > rect.
    pub clip_path_svg: Option<(bool, String)>,
    /// Si `Some([t,r,b,l])`, el clip-path se resuelve contra una caja de
    /// referencia (`<geometry-box>`) que es el rect del nodo ENCOGIDO por esos
    /// insets px (padding-box = border; content-box = border+padding). El
    /// pintado lo aplica ANTES de resolver la forma; sin forma, recorta a ese
    /// rect. `None` = referencia = border-box (rect completo). Fase 7.1225.
    pub clip_ref_inset: Option<[f32; 4]>,
    /// Msg a emitir cuando el cursor entra al rect del nodo (transición
    /// no-hover → hover). Útil para previews tipo "URL del link al
    /// pasar el mouse".
    pub on_pointer_enter: Option<Msg>,
    /// Msg a emitir cuando el cursor sale del rect del nodo.
    pub on_pointer_leave: Option<Msg>,
    /// Handler de **movimiento del cursor** sobre el nodo: recibe `(local_x,
    /// local_y, rect_w, rect_h)` en CADA `CursorMoved` mientras el cursor está
    /// encima (no sólo en la transición de entrada, a diferencia de
    /// [`Self::on_pointer_enter`]). Análogo posicional de hover, base de cosas
    /// como el thumbnail que sigue al cursor sobre un timeline o un drawer que
    /// reacciona a la posición. `None` no dispara update.
    pub on_pointer_move_at: Option<ClickAtFn<Msg>>,
    /// Handler de rueda local. Si está presente y el cursor cae sobre este
    /// nodo, el runtime lo invoca antes del `App::on_wheel` global; un
    /// `Some(Msg)` consume el evento. Base de las áreas de scroll
    /// autocontenidas. Ver [`ScrollFn`].
    pub on_scroll: Option<ScrollFn<Msg>>,
    /// Handler de gesto de **escala** (pinch-to-zoom). Si está presente y el
    /// gesto cae sobre este nodo (Ctrl+rueda en desktop, pinch de trackpad en
    /// macOS), el runtime lo invoca con el factor incremental + el punto focal
    /// local. Base del zoom de canvases. Ver [`ScaleFn`] y [`View::on_scale`].
    pub on_scale: Option<ScaleFn<Msg>>,
    /// Handler de gesto de **rotación** (dos dedos en trackpad, macOS). Si
    /// está presente y el gesto cae sobre este nodo, el runtime lo invoca con
    /// el delta de ángulo incremental (radianes) + el punto focal local. Ver
    /// [`RotateFn`] y [`View::on_rotate`].
    pub on_rotate: Option<RotateFn<Msg>>,
    /// Msg a emitir en **doble-tap** (dos presses izquierdos sobre este nodo
    /// dentro de una ventana temporal corta y muy cerca). Es un evento
    /// **aditivo**: si el nodo también tiene `on_click`, éste igual dispara en
    /// cada press; el doble-tap llega además en el segundo. Para doble-tap
    /// exclusivo, poné el handler en un nodo sin `on_click`. Ver
    /// [`View::on_double_tap`].
    pub on_double_tap: Option<Msg>,
    /// Variante posicional de [`Self::on_double_tap`]: recibe la posición del
    /// segundo tap relativa al rect del nodo (para zoom-to-point, etc.). Gana
    /// sobre `on_double_tap` si ambos están.
    pub on_double_tap_at: Option<ClickAtFn<Msg>>,
    /// Msg a emitir en **long-press** (mantener el botón izquierdo sobre este
    /// nodo ~500 ms sin moverse ni soltar). El runtime lo arbitra por tiempo:
    /// si el cursor se aleja (pasó a drag/scroll) o se suelta antes, se
    /// cancela. Evento **aditivo** (ver [`Self::on_double_tap`]); el caso
    /// limpio es un nodo con drag-to-pan + long-press y sin `on_click` (un
    /// canvas). Útil para menús contextuales táctiles / selección. Ver
    /// [`View::on_long_press`].
    pub on_long_press: Option<Msg>,
    /// Variante posicional de [`Self::on_long_press`]: recibe la posición del
    /// press relativa al rect del nodo (para abrir el menú en el punto). Gana
    /// sobre `on_long_press` si ambos están.
    pub on_long_press_at: Option<ClickAtFn<Msg>>,
    /// Marca este nodo como **enfocable** con el id opaco `u64`. El runtime
    /// mantiene el foco (uno por ventana) y lo mueve con Tab/Shift+Tab en
    /// orden de árbol (pre-orden) y al clickear un nodo enfocable; notifica
    /// a la app vía `App::on_focus` para que pinte el ring y rutee el
    /// teclado. El id lo elige el caller (índice de campo, hash, etc.).
    pub focusable: Option<u64>,
    /// Marca este nodo de **texto** como seleccionable con el mouse fuera del
    /// editor (arrastrar resalta, Ctrl/Cmd+C copia). El `u64` es una **key
    /// estable** entre rebuilds del `View` (los `NodeId` de taffy cambian cada
    /// frame, así que la selección retenida en el runtime se ancla a esta key,
    /// igual que `animated`). Sólo tiene efecto en nodos con `text` uniforme
    /// (no `runs`/`spans`). Ver [`View::selectable`].
    pub text_select_key: Option<u64>,
    /// Opacidad multiplicada sobre TODO el subtree (este nodo + hijos),
    /// en `[0.0, 1.0]`. Se realiza con `scene.push_layer(Mix::Normal, a, …)`
    /// alrededor del rect del nodo: el subárbol se rasteriza en una capa
    /// intermedia y se compone al alfa indicado contra lo que ya hay
    /// detrás. `None` = sin capa (caso de la abrumadora mayoría de
    /// nodos). Útil para fade-in/out de overlays, ghosts mientras se
    /// arrastra, modales que aparecen, panels "vidrio". Note que la
    /// composición tiene costo (allocate + blit), por lo que sólo
    /// poblar este slot cuando hace falta — no es un atributo gratis.
    pub alpha: Option<f32>,
    /// Animación **implícita** de las props de paint (fill/radius): cuando el
    /// valor cambia entre frames, el runtime interpola en vez de saltar. `None`
    /// = sin animación (la abrumadora mayoría). La `key` debe ser estable entre
    /// rebuilds. Ver [`Anim`] y [`View::animated`]. Lo consume el runtime vía
    /// [`AnimRegistry::reconcile`] (DESPUÉS de layout, ANTES de paint).
    pub anim: Option<Anim>,
    /// **Animación implícita de tamaño** (Flutter `AnimatedSize` /
    /// Compose `animateContentSize()`). `None` = sin animación. La key
    /// debe ser estable entre rebuilds. A diferencia de [`Self::anim`]
    /// (props de paint, reconcilia DESPUÉS de layout), el tamaño tiene
    /// que estar firme **antes** del layout — siblings/hijos dependen
    /// del rect del nodo. El runtime llama
    /// [`reconcile_size_anim`] sobre el `View` tree **antes** de
    /// `mount` y parcha `style.size` con el valor interpolado. Sólo se
    /// activa si ambos `style.size.width` y `style.size.height` son
    /// `Dimension::Length(_)`. Ver [`SizeAnim`] y [`View::animated_size`].
    pub animated_size: Option<SizeAnim>,
    /// **Semántica accesible** del nodo (rol, label, value, flags ARIA). El
    /// runtime la traduce a un árbol AccessKit por frame para alimentar
    /// lectores de pantalla (NVDA/VoiceOver/Orca/TalkBack). `None` = no
    /// declarada (el lector lee el texto plano si lo hay, sin rol específico).
    /// Ver [`SemanticsSpec`].
    pub semantics: Option<SemanticsSpec>,
    /// **Hero shared-element**: marca este nodo como una identidad estable
    /// entre frames. Si la misma `key` aparece en otra posición en un frame
    /// siguiente, el runtime interpola `transform` para "volar" del rect
    /// anterior al actual durante la `duration` declarada. Ver
    /// [`Hero`] y [`HeroRegistry`]. `None` = sin hero (la abrumadora mayoría).
    pub hero: Option<Hero>,
    /// Transformación afín 2D aplicada a este nodo y todo su subtree
    /// **alrededor del centro de su propio rect** (convención CSS
    /// `transform-origin: 50% 50%`). El runtime resuelve el centro en
    /// `paint` (sólo entonces conoce el layout computado) y compone
    /// `T(centro) · transform · T(-centro)` sobre la transformación
    /// acumulada del padre, así nodos anidados transforman en el espacio
    /// ya transformado de su ancestro — igual que CSS. `None` = identidad
    /// (la abrumadora mayoría de nodos). Pensado para `transform`/
    /// `@keyframes` CSS de puriy (rotate/scale/translate). El hit-test
    /// **respeta** el afín (un nodo transformado recibe clicks donde se ve
    /// pintado). Limitación restante: los `painter`/`runs` custom no heredan
    /// el afín, y la posición local que reciben los handlers `*_at` se
    /// reporta en espacio de pantalla, no en el espacio local del nodo.
    pub transform: Option<Affine>,
    /// Traslación RELATIVA al tamaño del propio nodo, en fracciones de su rect
    /// computado: `(fx, fy)` ⇒ desplaza `(fx · w, fy · h)` px. Se resuelve en
    /// `paint`/`hit_test` (única instancia donde se conoce el tamaño usado) y
    /// se compone como el factor más externo del afín del nodo, ANTES del
    /// centrado por `transform-origin`. Pensado para el `translate(<%>)` de CSS
    /// (p. ej. el truco de centrado `translate(-50%, -50%)` ⇒ `(-0.5, -0.5)`),
    /// que no es expresable como `Affine` fijo porque el % depende del layout.
    /// `None` = sin traslación relativa (la abrumadora mayoría). Compone con
    /// `transform` (afín fijo) si ambos están: `T_rel · transform`.
    pub transform_rel: Option<(f64, f64)>,
    /// Punto de pivote de `transform` (CSS `transform-origin`). `None` ⇒ el
    /// default CSS `50% 50%` (centro del rect) — el caso mayoritario. Ver
    /// [`TransformPivot`] y [`View::transform_origin`].
    pub transform_origin: Option<TransformPivot>,
    /// Texto de **tooltip**: si está, el runtime/cliente puede mostrar un
    /// rótulo flotante cuando el cursor se posa sobre este nodo. Llimphi sólo
    /// transporta el dato hasta el [`MountedNode`]; *quién* lo pinta (un overlay
    /// del runtime, una surface popup del cliente) lo decide el consumidor. El
    /// hit-test de hover ya localiza el nodo bajo el cursor. `None` = sin tip.
    pub tooltip: Option<String>,
    /// Forma del puntero del mouse mientras está sobre este nodo (o un
    /// descendiente sin cursor propio — se hereda del ancestro más cercano que
    /// lo declare). El runtime lo resuelve en el hit-test de hover y lo aplica a
    /// la ventana. `None` = hereda (default flecha en la raíz). Ver [`Cursor`] y
    /// [`View::cursor`]. Llimphi-native (sin winit); el runtime lo mapea.
    pub cursor: Option<Cursor>,
    /// Feedback de tap **ripple/InkWell**: al presionar este nodo, el runtime
    /// emite una salpicadura Material (círculo que se expande desde el punto y
    /// se desvanece, recortado al contorno del nodo). Es puro feedback visual,
    /// aditivo al `on_click`; vive en el runtime ([`RippleRegistry`]), no en el
    /// `Model`. `None` = sin ripple. Ver [`View::ripple`].
    pub ripple: Option<Ripple>,
    /// Constructor **diferido** sensible al tamaño (`LayoutBuilder`). Si está
    /// presente, este nodo NO usa sus `children` estáticos: el runtime resuelve
    /// su slot en una primera pasada de layout y luego invoca esta closure con
    /// las [`Constraints`] resueltas para producir el subárbol. `None` = nodo
    /// normal (la abrumadora mayoría). Ver [`View::layout_builder`].
    pub layout_builder: Option<LayoutBuilderFn<Msg>>,
    /// Backdrop blur sobre el contenido pintado **debajo** de este nodo.
    /// Ver [`View::backdrop_blur`] / [`MountedNode::backdrop_blur`]. v1:
    /// sólo se aplica a nodos top-level sin clip/alpha ancestral.
    pub backdrop_blur: Option<f32>,
    /// Filtros CSS (`filter: …`) sobre el propio subárbol del nodo. Vacío = sin
    /// filtro. Ver [`View::filter`] / [`FilterOp`]. Fase 7.1232.
    pub filter: Vec<FilterOp>,
    /// **Modo de mezcla** del nodo entero contra su backdrop (CSS
    /// `mix-blend-mode`). `Some(bm)` ⇒ el subárbol del nodo se rasteriza en una
    /// capa aislada (`scene.push_layer(bm, …)` alrededor del rect) y se mezcla
    /// con el modo `bm` contra todo lo pintado antes en el stacking context.
    /// `None` = source-over normal (la abrumadora mayoría). Ver [`View::blend`].
    /// Fase 7.1237.
    pub blend: Option<BlendMode>,
    pub children: Vec<View<Msg>>,
}

impl<Msg: 'static> View<Msg> {
    /// Transforma el `Msg` de **todo el árbol** vía `f`, devolviendo
    /// `View<Msg2>`. Es la pieza que permite **embeber el `view` de un sub-app**
    /// en un host (junto con [`crate::Handle::lift`] para sus efectos): el host
    /// pinta `sub_view.map(Msg::Sub)` y los eventos del sub-árbol vuelven como
    /// su propio `Msg`. Patrón estándar de anidado Elm. `f` se comparte (`Arc`)
    /// entre todos los callbacks e hijos, así que debe ser `Send + Sync`.
    pub fn map<Msg2, F>(self, f: F) -> View<Msg2>
    where
        Msg2: 'static,
        F: Fn(Msg) -> Msg2 + Send + Sync + 'static,
    {
        self.map_shared(Arc::new(f))
    }

    fn map_shared<Msg2: 'static>(
        self,
        f: Arc<dyn Fn(Msg) -> Msg2 + Send + Sync>,
    ) -> View<Msg2> {
        let View {
            style,
            fill,
            hover_fill,
            radius,
            corner_radii,
            shadow,
            fill_gradient,
            border,
            text,
            image,
            image_fit,
            mask_image,
            mask_placement,
            mask_extra,
            painter,
            gpu_painter,
            over_painter,
            on_click,
            on_click_at,
            on_right_click,
            on_right_click_at,
            on_middle_click,
            drag,
            drag_at,
            drag_velocity,
            drag_payload,
            on_drop,
            drop_hover_fill,
            clip,
            clip_inset,
            clip_ellipse,
            clip_polygon,
            clip_path_svg,
            clip_ref_inset,
            on_pointer_enter,
            on_pointer_leave,
            on_pointer_move_at,
            on_scroll,
            on_scale,
            on_rotate,
            on_double_tap,
            on_double_tap_at,
            on_long_press,
            on_long_press_at,
            focusable,
            text_select_key,
            alpha,
            anim,
            animated_size,
            semantics,
            hero,
            transform,
            transform_rel,
            transform_origin,
            tooltip,
            cursor,
            ripple,
            layout_builder,
            backdrop_blur,
            filter,
            blend,
            children,
        } = self;
        // Wrappers: cada callback que produce `Option<Msg>` se reenvía y su
        // resultado se eleva con `f`. `f` se clona por callback (todos comparten
        // el mismo `Arc`).
        View {
            // — campos agnósticos al Msg: pasan tal cual —
            style,
            fill,
            hover_fill,
            radius,
            corner_radii,
            shadow,
            fill_gradient,
            border,
            text,
            image,
            image_fit,
            mask_image,
            mask_placement,
            mask_extra,
            painter,
            gpu_painter,
            over_painter,
            drag_payload,
            drop_hover_fill,
            clip,
            clip_inset,
            clip_ellipse,
            clip_polygon,
            clip_path_svg,
            clip_ref_inset,
            focusable,
            text_select_key,
            alpha,
            anim,
            animated_size,
            semantics,
            hero,
            transform,
            transform_rel,
            transform_origin,
            tooltip,
            cursor,
            ripple,
            backdrop_blur,
            filter,
            blend,
            // — Msg simples —
            on_click: on_click.map(|m| f(m)),
            on_right_click: on_right_click.map(|m| f(m)),
            on_middle_click: on_middle_click.map(|m| f(m)),
            on_pointer_enter: on_pointer_enter.map(|m| f(m)),
            on_pointer_leave: on_pointer_leave.map(|m| f(m)),
            on_double_tap: on_double_tap.map(|m| f(m)),
            on_long_press: on_long_press.map(|m| f(m)),
            // — ClickAtFn (lx, ly, w, h) —
            on_click_at: on_click_at.map(|h| {
                let f = f.clone();
                Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
            }),
            on_right_click_at: on_right_click_at.map(|h| {
                let f = f.clone();
                Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
            }),
            on_pointer_move_at: on_pointer_move_at.map(|h| {
                let f = f.clone();
                Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
            }),
            on_double_tap_at: on_double_tap_at.map(|h| {
                let f = f.clone();
                Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
            }),
            on_long_press_at: on_long_press_at.map(|h| {
                let f = f.clone();
                Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
            }),
            // — drag / scroll / gestos —
            drag: drag.map(|h| {
                let f = f.clone();
                Arc::new(move |p, dx, dy| h(p, dx, dy).map(|m| f(m))) as DragFn<Msg2>
            }),
            drag_at: drag_at.map(|h| {
                let f = f.clone();
                Arc::new(move |p, dx, dy, lx, ly| h(p, dx, dy, lx, ly).map(|m| f(m)))
                    as DragAtFn<Msg2>
            }),
            drag_velocity: drag_velocity.map(|h| {
                let f = f.clone();
                Arc::new(move |p, dx, dy, vx, vy| h(p, dx, dy, vx, vy).map(|m| f(m)))
                    as DragVelocityFn<Msg2>
            }),
            on_drop: on_drop.map(|h| {
                let f = f.clone();
                Arc::new(move |payload| h(payload).map(|m| f(m))) as DropFn<Msg2>
            }),
            on_scroll: on_scroll.map(|h| {
                let f = f.clone();
                Arc::new(move |dx, dy| h(dx, dy).map(|m| f(m))) as ScrollFn<Msg2>
            }),
            on_scale: on_scale.map(|h| {
                let f = f.clone();
                Arc::new(move |ph, s, cx, cy| h(ph, s, cx, cy).map(|m| f(m))) as ScaleFn<Msg2>
            }),
            on_rotate: on_rotate.map(|h| {
                let f = f.clone();
                Arc::new(move |ph, r, cx, cy| h(ph, r, cx, cy).map(|m| f(m))) as RotateFn<Msg2>
            }),
            // — layout_builder produce un View<Msg>: recursá el map —
            layout_builder: layout_builder.map(|h| {
                let f = f.clone();
                Arc::new(move |c| h(c).map_shared(f.clone())) as LayoutBuilderFn<Msg2>
            }),
            // — hijos: recursión —
            children: children
                .into_iter()
                .map(|c| c.map_shared(f.clone()))
                .collect(),
        }
    }
}

/// Versión "instalada" del árbol: cada nodo tiene su NodeId de taffy, color
/// y handler. Se mantiene en orden de inserción (recorrido pre-orden), así
/// el hit-test puede iterar al revés para honrar el orden de pintado.
///
/// `pub` (con campos `pub`) porque el runtime (llimphi-ui) lee el árbol
/// montado para hit-test y para la pasada GPU directa, pero vive en otro
/// crate. No se construye fuera de [`mount`].
pub struct Mounted<Msg> {
    pub root: NodeId,
    pub nodes: Vec<MountedNode<Msg>>,
    /// Contenido de texto por nodo-hoja, para que el runtime lo mida con
    /// parley durante `compute_with_measure` y taffy reserve el alto real
    /// del texto envuelto (varias líneas) en vez de una sola. Sin esto un
    /// párrafo que envuelve a N líneas se aplastaría en la altura de una
    /// (el bug clásico de "textos aplastados"). Sólo se pueblan hojas con
    /// texto uniforme (sin `runs` multicolor, que el caller dimensiona).
    pub text_measures: HashMap<NodeId, TextMeasure>,
}

/// Datos de un nodo-hoja de texto necesarios para medirlo (shaping +
/// line-break) sin volver a tocar el `View`. Lo consume el runtime en la
/// función de medición que le pasa a [`LayoutTree::compute_with_measure`].
#[derive(Clone)]
pub struct TextMeasure {
    pub content: String,
    pub size_px: f32,
    pub alignment: llimphi_text::Alignment,
    pub italic: bool,
    pub font_family: Option<String>,
    pub line_height: f32,
    pub weight: f32,
    pub max_lines: Option<usize>,
    pub ellipsis: bool,
    /// Idem [`TextSpec::underline`]. Se replica en la medida porque parley
    /// no cambia de ancho con decoración (no toca el shaping); pero la clave
    /// del caché de shaping sí cambia, y queremos que medida y pintado
    /// peguen la misma entrada del caché.
    pub underline: bool,
    /// Idem [`TextSpec::strikethrough`]. Mismo razonamiento que `underline`.
    pub strikethrough: bool,
    /// Idem [`TextSpec::spans`]. La medida usa el mismo
    /// `Typesetter::layout_spans` que el pintado, así taffy reserva el alto
    /// real considerando overrides de `size_px` por span (un `<h1>` inline
    /// dentro de un párrafo agranda esa línea). `None`/`vacío` = medir con
    /// `layout_clamped` (camino uniforme).
    pub spans: Option<Vec<llimphi_text::TextSpan>>,
    /// Idem [`TextSpec::letter_spacing`]. Entra en la medida porque cambia el
    /// ancho del shaping (y la clave del caché).
    pub letter_spacing: f32,
    /// Idem [`TextSpec::word_spacing`]. Mismo razonamiento que `letter_spacing`.
    pub word_spacing: f32,
    /// Idem [`TextSpec::no_wrap`]. Entra en la medida porque cambia el ancho
    /// reservado: con `no_wrap` el texto se mide en una sola línea (ancho
    /// completo) en vez de envolver al `available`.
    pub no_wrap: bool,
    /// Idem [`TextSpec::overflow_wrap`]. Entra en la medida porque parte la
    /// palabra larga: con el flag, el ancho mínimo del bloque deja de estar
    /// fijado por el token más ancho.
    pub overflow_wrap: bool,
}

/// Cómo encajar una imagen en el rect del nodo (CSS `object-fit` /
/// Flutter `BoxFit`). El runtime calcula la escala y el origen
/// correspondientes a esta política y siempre recorta al
/// `node_rrect` del nodo, así el clip respeta `radius` /
/// `corner_radii`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageFit {
    /// Preservar aspect ratio, **caber** dentro del rect (escala =
    /// `min(sx, sy)`). Deja banda en el eje menos restrictivo.
    /// CSS `object-fit: contain` / Flutter `BoxFit.contain`. **Default
    /// histórico** — lo que hacía `View::image()` antes del Bloque 12.
    Contain,
    /// Preservar aspect ratio, **cubrir** todo el rect (escala =
    /// `max(sx, sy)`). Recorta el sobrante en el eje menos
    /// restrictivo (el clip al `node_rrect` lo absorbe). CSS
    /// `object-fit: cover` / Flutter `BoxFit.cover` — ideal para
    /// avatares y hero images.
    Cover,
    /// Estirar la imagen para ocupar el rect, **sin** preservar
    /// aspect ratio (`sx`/`sy` independientes). CSS `object-fit:
    /// fill` / Flutter `BoxFit.fill`.
    Fill,
    /// **No** escalar la imagen — pintarla a su tamaño original,
    /// centrada en el rect. Si la imagen excede el rect, el clip al
    /// `node_rrect` la recorta. CSS `object-fit: none` / Flutter
    /// `BoxFit.none`.
    None,
}

/// Longitud de un eje de [`MaskSize`]/posición de máscara, **sin resolver** —
/// el paint la resuelve contra el rect del nodo. Neutral respecto de CSS: el
/// frontend (p. ej. puriy) traduce `mask-size`/`mask-position` a esto. Fase
/// 7.1227.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum MaskLen {
    /// Tamaño intrínseco de la imagen (en size) / offset 0 (en position).
    Auto,
    /// Longitud absoluta en px.
    Px(f32),
    /// Porcentaje: en size, del lado correspondiente del rect; en position,
    /// alineación CSS (el `p%` de la máscara cae sobre el `p%` del rect).
    Pct(f32),
}

/// `mask-size` neutral (espejo de `BackgroundSize`). Ver [`MaskPlacement`].
/// Fase 7.1227.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum MaskSize {
    /// Tamaño intrínseco de la imagen-máscara.
    Auto,
    /// Escalar preservando aspecto hasta **cubrir** el rect.
    Cover,
    /// Escalar preservando aspecto hasta **caber** en el rect.
    Contain,
    /// Tamaño explícito por eje (`Auto` en un eje = derivar por aspecto).
    Explicit { x: MaskLen, y: MaskLen },
}

/// Modo de una máscara (CSS `mask-mode`). Decide qué canal del píxel-máscara
/// modula el alpha del contenido. Fase 7.1228.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MaskMode {
    /// La **luminancia** del píxel multiplica el alpha (negro = oculto, blanco =
    /// visible). Lo usa CSS para máscaras SVG `<mask>`. Es el default del
    /// compositor cuando no hay `MaskPlacement` (camino estirado, Fase 7.1226).
    #[default]
    Luminance,
    /// El **canal alpha** del píxel modula el alpha (transparente = oculto). Es
    /// el default CSS para imágenes raster (`mask-mode: match-source`). Se pinta
    /// con `Compose::DestIn` en vez de la capa de luminancia.
    Alpha,
}

/// Operador de combinación entre capas de máscara (CSS `mask-composite`). Mapea
/// a un `Compose` Porter-Duff de vello cuando una capa extra se compone sobre
/// las de abajo. Fase 7.1231.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MaskCompose {
    /// La capa se **suma** sobre las de abajo (source-over). Default CSS.
    #[default]
    Add,
    /// La capa **resta** (source-out: la fuente donde NO solapa el destino).
    Subtract,
    /// **Intersección** (source-in: la fuente donde solapa el destino).
    Intersect,
    /// **Exclusión** (xor: las regiones no solapadas de ambas).
    Exclude,
}

/// Encaje y modo de una **máscara** (CSS `mask-size` + `mask-position` +
/// `mask-repeat` + `mask-mode`), resuelto contra el rect del nodo en el paint,
/// con la misma aritmética que `background-image`. En el [`MountedNode`] viaja
/// como `Option`: `None` = estirar la máscara al border-box en modo luminancia
/// (comportamiento de la Fase 7.1226). Fase 7.1227 (encaje), 7.1228 (modo).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MaskPlacement {
    /// Tamaño del tile.
    pub size: MaskSize,
    /// Offset/alineación horizontal del primer tile.
    pub pos_x: MaskLen,
    /// Offset/alineación vertical del primer tile.
    pub pos_y: MaskLen,
    /// Tilear en X (`mask-repeat` cubre el eje horizontal).
    pub repeat_x: bool,
    /// Tilear en Y.
    pub repeat_y: bool,
    /// Canal que modula el alpha (luminancia vs alpha). Fase 7.1228.
    pub mode: MaskMode,
    /// Insets `[top, right, bottom, left]` px del border-box a la caja de
    /// `mask-clip`: el efecto de la máscara se **recorta** a esa caja. `None` =
    /// border-box. Fase 7.1230.
    pub clip_inset: Option<[f32; 4]>,
    /// Insets `[top, right, bottom, left]` px del border-box a la caja de
    /// `mask-origin`: size/position/tiling se resuelven contra esa caja. `None`
    /// = border-box. Fase 7.1230.
    pub origin_inset: Option<[f32; 4]>,
}

impl Default for ImageFit {
    fn default() -> Self {
        ImageFit::Contain
    }
}

/// Forma del puntero del mouse. Subconjunto práctico, llimphi-native (el
/// compositor no depende de winit). El runtime (`llimphi-ui`) mapea 1:1 a
/// `winit::window::CursorIcon`. Nombres alineados con CSS/winit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Cursor {
    /// Flecha por defecto.
    Default,
    /// Manito — sobre algo clickeable (links, botones).
    Pointer,
    /// I-beam — sobre texto editable/seleccionable.
    Text,
    /// Cruz — selección precisa (canvas, picker de color).
    Crosshair,
    /// Cuatro flechas — mover un objeto.
    Move,
    /// Mano abierta — agarrable (antes de arrastrar).
    Grab,
    /// Mano cerrada — arrastrando.
    Grabbing,
    /// Prohibido — drop no permitido / acción inválida.
    NotAllowed,
    /// Reloj/espera — operación bloqueante.
    Wait,
    /// Progreso — ocupado pero la UI responde.
    Progress,
    /// Interrogación — ayuda contextual.
    Help,
    /// Resize horizontal (columna / divisor vertical).
    ColResize,
    /// Resize vertical (fila / divisor horizontal).
    RowResize,
    /// Resize este-oeste.
    EwResize,
    /// Resize norte-sur.
    NsResize,
    /// Resize diagonal ↗↙.
    NeswResize,
    /// Resize diagonal ↖↘.
    NwseResize,
    /// Lupa + (zoom in).
    ZoomIn,
    /// Lupa − (zoom out).
    ZoomOut,
}

pub struct MountedNode<Msg> {
    pub id: NodeId,
    pub fill: Option<Color>,
    pub hover_fill: Option<Color>,
    pub radius: f64,
    pub corner_radii: Option<RoundedRectRadii>,
    pub shadow: Option<Shadow>,
    pub fill_gradient: Option<Gradient>,
    pub border: Option<Border>,
    pub text: Option<TextSpec>,
    pub image: Option<Image>,
    /// Política de encaje de [`Self::image`] (ver [`ImageFit`]). `None`
    /// = `Contain`.
    pub image_fit: Option<ImageFit>,
    /// Máscara de luminancia del subárbol (CSS `mask-image`). Ver
    /// [`View::mask_image`]. El paint aísla el subárbol y aplica la luminancia
    /// de esta imagen como alpha. `None` = sin máscara.
    pub mask_image: Option<Image>,
    /// Encaje de [`Self::mask_image`] (size/position/repeat). `None` = estirar
    /// al border-box. Ver [`MaskPlacement`]. Fase 7.1227.
    pub mask_placement: Option<MaskPlacement>,
    /// Capas de máscara adicionales `(imagen, operador)` (ver [`View::mask_extra`]).
    /// Comparten el `mask_placement` con la capa 0. Fase 7.1231.
    pub mask_extra: Vec<(Image, MaskCompose)>,
    pub painter: Option<PaintFn>,
    pub gpu_painter: Option<GpuPaintFn>,
    pub over_painter: Option<PaintFn>,
    pub on_click: Option<Msg>,
    pub on_click_at: Option<ClickAtFn<Msg>>,
    pub on_right_click: Option<Msg>,
    pub on_right_click_at: Option<ClickAtFn<Msg>>,
    pub on_middle_click: Option<Msg>,
    pub drag: Option<DragFn<Msg>>,
    pub drag_at: Option<DragAtFn<Msg>>,
    pub drag_velocity: Option<DragVelocityFn<Msg>>,
    pub drag_payload: Option<u64>,
    pub on_drop: Option<DropFn<Msg>>,
    pub drop_hover_fill: Option<Color>,
    pub clip: bool,
    pub clip_inset: Option<[f32; 4]>,
    pub clip_ellipse: Option<[f32; 14]>,
    pub clip_polygon: Option<(bool, Vec<[f32; 4]>)>,
    pub clip_path_svg: Option<(bool, String)>,
    pub clip_ref_inset: Option<[f32; 4]>,
    pub on_pointer_enter: Option<Msg>,
    pub on_pointer_leave: Option<Msg>,
    pub on_pointer_move_at: Option<ClickAtFn<Msg>>,
    pub on_scroll: Option<ScrollFn<Msg>>,
    /// Handler de gesto de escala (pinch-to-zoom) de este nodo. Ver
    /// [`View::on_scale`] y [`ScaleFn`].
    pub on_scale: Option<ScaleFn<Msg>>,
    /// Handler de gesto de rotación (trackpad) de este nodo. Ver
    /// [`View::on_rotate`] y [`RotateFn`].
    pub on_rotate: Option<RotateFn<Msg>>,
    /// Handlers de doble-tap (ver [`View::on_double_tap`]).
    pub on_double_tap: Option<Msg>,
    pub on_double_tap_at: Option<ClickAtFn<Msg>>,
    /// Handlers de long-press (ver [`View::on_long_press`]).
    pub on_long_press: Option<Msg>,
    pub on_long_press_at: Option<ClickAtFn<Msg>>,
    pub focusable: Option<u64>,
    /// Key estable de selección de texto (ver [`View::selectable`]).
    pub text_select_key: Option<u64>,
    pub alpha: Option<f32>,
    pub anim: Option<Anim>,
    /// Animación implícita de tamaño (ver [`View::animated_size`]). El
    /// runtime ya parchó `style.size` antes del layout — este campo se
    /// guarda principalmente para inspección/tests.
    pub animated_size: Option<SizeAnim>,
    /// Semántica accesible del nodo (ver [`View::semantics`]). El runtime la
    /// lee en cada paint para reconstruir el árbol AccessKit del frame.
    pub semantics: Option<SemanticsSpec>,
    /// Marca de hero shared-element (ver [`View::hero`]). El runtime lo lee
    /// en [`HeroRegistry::reconcile`] para enlazar identidad entre frames y
    /// escribir `transform` con la afín "fly" cuando el rect cambia.
    pub hero: Option<Hero>,
    /// Transformación afín 2D del nodo (alrededor del centro de su rect).
    /// Ver [`View::transform`]. `paint` la compone con la del padre.
    pub transform: Option<Affine>,
    /// Traslación relativa al tamaño del nodo (fracciones de su rect). Ver
    /// [`View::transform_rel`]. `paint`/`hit_test` la resuelven contra el rect.
    pub transform_rel: Option<(f64, f64)>,
    /// Pivote de `transform` (CSS `transform-origin`). `None` ⇒ centro. Ver
    /// [`TransformPivot`] / [`View::transform_origin`].
    pub transform_origin: Option<TransformPivot>,
    /// Texto de tooltip de este nodo (ver [`View::tooltip`]). El consumidor lo
    /// lee tras un hit-test de hover para pintar el rótulo flotante.
    pub tooltip: Option<String>,
    /// Forma del puntero sobre este nodo (ver [`View::cursor`]). El runtime la
    /// resuelve heredando del ancestro más cercano que la declare.
    pub cursor: Option<Cursor>,
    /// Ripple/InkWell de este nodo (ver [`View::ripple`]). El runtime lo
    /// dispara en el press y lo pinta vía [`RippleRegistry`].
    pub ripple: Option<Ripple>,
    /// `true` si este nodo era un [`View::layout_builder`] (constructor diferido)
    /// al montarse. El runtime lo usa tras la primera pasada de layout para leer
    /// el rect del slot (vía [`collect_builder_constraints`]) e invocar la
    /// closure. Tras expandirse, el nodo final ya es normal (`false`).
    pub is_layout_builder: bool,
    /// **Backdrop blur** (CSS `backdrop-filter: blur(N)` / Flutter
    /// `BackdropFilter`). Sigma del Gauss en pixels; el runtime aplica una
    /// pasada separable (H+V) sobre la intermediate restringida al rect del
    /// nodo, **antes** de pintar el subárbol del nodo. El subárbol se compone
    /// sobre el backdrop ya borroso vía un buffer secundario. `None` = sin
    /// blur (la abrumadora mayoría). Limitación v1: el nodo no debe estar
    /// dentro de un ancestro con clip/alpha (los subárboles separados pintan
    /// fuera de esas capas — documentado en `PARIDAD-FLUTTER.md` Bloque 11).
    pub backdrop_blur: Option<f32>,
    /// Filtros CSS (`filter: …`) sobre el propio subárbol (ver [`View::filter`]
    /// / [`FilterOp`]). El runtime los recolecta con [`collect_filters`] y los
    /// aplica como post-pasada GPU sobre la intermediate, restringidos al rect
    /// del nodo, **después** de la rasterización. Vacío = sin filtro. Fase
    /// 7.1232.
    pub filter: Vec<FilterOp>,
    /// Modo de mezcla del nodo entero contra su backdrop (CSS `mix-blend-mode`).
    /// Ver [`View::blend`] / [`MountedNode`]. `paint_range` abre una capa de
    /// blend (`push_layer(bm, …)`) alrededor del rect del nodo que envuelve
    /// fill + contenido + hijos y se cierra al fin del subárbol, mezclando el
    /// resultado contra lo ya pintado. `None` = source-over. Fase 7.1237.
    pub blend: Option<BlendMode>,
    /// Índice (exclusivo) del fin del subárbol en `Mounted::nodes`. Los
    /// descendientes ocupan `[idx + 1, subtree_end)`. Hace de "barrera" en
    /// paint/hit_test para `pop_layer` y para saltar subárboles enteros.
    pub subtree_end: usize,
}