tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ main llimphi-compositor/src/lib.rs Rust · 2199 líneas · 106.7 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

1//! llimphi-compositor — el núcleo declarativo de Llimphi, sin winit.
2//!
3//! Aquí vive el árbol de vista `View<Msg>` (DSL declarativo), su instalación
4//! sobre taffy (`mount`), el pintado a `vello::Scene` (`paint`/`paint_gpu`) y
5//! el hit-test. Nada de esto necesita una ventana ni `llimphi-hal`: la
6//! composición `view → layout → scene` es pura y reutilizable.
7//!
8//! El runtime que la maneja vive aparte:
9//! - `llimphi-ui` la corre sobre winit (`run<A: App>()`).
10//! - a futuro, un runtime sobre el framebuffer del kernel `wawa` puede
11//!   reusar exactamente este compositor sin arrastrar winit.
12//!
13//! `wgpu` entra sólo por la firma de [`GpuPaintFn`] (tipos de Device/Queue/
14//! Encoder/TextureView); `wgpu` no depende de winit, así que el compositor
15//! sigue libre de windowing.
16
17// `no_std` cuando se pide el perfil bare-metal (W3.b de PLAN-LLIMPHI.md). El
18// compositor es aritmética sobre un árbol y emisión de órdenes de dibujo: nada
19// de eso necesitaba un sistema operativo, sólo lo heredaba de `std`.
20#![cfg_attr(not(feature = "std"), no_std)]
21
22// `format!` y `vec!` para todo el crate.
23#[macro_use]
24extern crate alloc;
25
26// Preludio de `alloc`: bajo `no_std` estos tipos ya no vienen solos.
27#[allow(unused_imports)]
28use alloc::{boxed::Box, string::{String, ToString}, vec::Vec};
29
30use hashbrown::HashMap;
31use alloc::sync::Arc;
32
33/// El reloj de las animaciones. **En cualquier target nativo esto ES
34/// `crate::Instant`** — `web_time` lo re-exporta sin envoltorio ni coste.
35/// En wasm32 se apoya en `performance.now()`, porque allí
36/// `crate::Instant::now()` no está implementado y panica en runtime.
37///
38/// El compositor no llama a `now()` en el camino de producción (el runtime le
39/// pasa el instante del frame, y los tests instantes fabricados), pero el
40/// **tipo** tiene que ser el mismo que el del runtime — y el de `llimphi-ui`
41/// está atado al `Instant` con que winit tipa `ControlFlow::WaitUntil` en web.
42#[cfg(feature = "std")]
43pub use web_time::Instant;
44
45/// El reloj de las animaciones **sin sistema operativo**.
46///
47/// `web-time` no es `#![no_std]` —reexporta `std::time`— y era la ÚNICA cosa que
48/// impedía que el compositor cruzara a wawa: todo lo demás ya viajaba. Acá se
49/// reemplaza por lo mínimo que el compositor realmente usa, que resultó ser
50/// asombrosamente poco: `saturating_duration_since` y comparar. `Instant::now()`
51/// no existe a propósito —en el kernel no hay un reloj global al que preguntarle
52/// la hora—, y tampoco hace falta: el runtime pasa el instante del fotograma.
53/// Que no exista convierte un uso equivocado en un error de compilación en vez
54/// de una animación congelada.
55#[cfg(not(feature = "std"))]
56pub use reloj::Instant;
57
58#[cfg(not(feature = "std"))]
59mod reloj {
60    use core::time::Duration;
61
62    /// Instante monotónico, en **microsegundos desde el arranque**.
63    ///
64    /// El kernel de wawa lo construye con [`Instant::desde_micros`] a partir de
65    /// su contador del PIT (100 Hz). Microsegundos y no milisegundos porque a
66    /// 60 fps un fotograma dura 16.667 µs: en milisegundos la animación
67    /// avanzaría a saltos de un 6% de frame.
68    #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
69    pub struct Instant(u64);
70
71    impl Instant {
72        /// Un instante a partir del contador monotónico del sistema.
73        pub const fn desde_micros(micros: u64) -> Self {
74            Self(micros)
75        }
76
77        /// Los microsegundos que lleva adentro.
78        pub const fn micros(self) -> u64 {
79            self.0
80        }
81
82        /// Tiempo transcurrido desde `earlier`; cero si `earlier` es posterior.
83        ///
84        /// Satura en vez de entrar en pánico, igual que el de `std`: en un
85        /// kernel, que un reloj vuelva atrás no puede tumbar el compositor.
86        pub fn saturating_duration_since(&self, earlier: Self) -> Duration {
87            Duration::from_micros(self.0.saturating_sub(earlier.0))
88        }
89    }
90}
91
92
93// =============================================================================
94//  Matemática de coma flotante sin `std`
95// -----------------------------------------------------------------------------
96//  `ceil`, `sqrt` y compañía son MÉTODOS que aporta la stdlib, no operaciones
97//  del lenguaje: sin `std` no existen. La tentación es importar
98//  `kurbo::common::FloatFuncs`, pero eso es una trampa de `cfg` —kurbo sólo lo
99//  define cuando SU feature `std` está apagada, así que el import compilaría en
100//  wawa y fallaría en Linux (ya pasó en `llimphi-theme`)—. Dos funciones
101//  explícitas evitan el problema entero.
102// =============================================================================
103
104/// Redondeo hacia arriba, con o sin sistema operativo.
105///
106/// `allow(dead_code)` porque **sus llamadores dependen del perfil**: en el
107/// build con `std` no queda ninguno, y borrarla rompería el `no_std`. Es el
108/// costo de que este crate compile en los dos mundos.
109#[allow(dead_code)]
110#[inline]
111pub(crate) fn ceil_f32(x: f32) -> f32 {
112    #[cfg(feature = "std")]
113    {
114        x.ceil()
115    }
116    #[cfg(not(feature = "std"))]
117    {
118        libm::ceilf(x)
119    }
120}
121
122/// Raíz cuadrada, con o sin sistema operativo.
123#[inline]
124pub(crate) fn sqrt_f64(x: f64) -> f64 {
125    #[cfg(feature = "std")]
126    {
127        x.sqrt()
128    }
129    #[cfg(not(feature = "std"))]
130    {
131        libm::sqrt(x)
132    }
133}
134
135/// El lienzo sobre el que se pinta — `vello::Scene` en Linux,
136/// `llimphi_cpu::Escena` en wawa (W3.b de `PLAN-LLIMPHI.md`).
137///
138/// El compositor no nombra a ninguno de los dos: nombra a `Lienzo`, y el alias
139/// lo conmuta `llimphi-text` según la feature de backend. Los dos tipos exponen
140/// los mismos ocho métodos con las mismas firmas —`llimphi_cpu::Escena` se
141/// diseñó **espejando** a `vello::Scene`, no envolviéndola, justamente para
142/// esto—, así que las 4.000 líneas de `render.rs` no distinguen sobre cuál están
143/// pintando. Cambiar de backend es cambiar una feature, no reescribir el paint.
144pub use llimphi_text::Lienzo;
145
146// -----------------------------------------------------------------------------
147//  El hub de reexports para los widgets  (W4.6)
148// -----------------------------------------------------------------------------
149//  Hasta acá el catálogo de widgets colgaba de `llimphi-ui`, que reexporta todo
150//  esto — y de paso winit, accesskit, arboard y vello. O sea que un botón, para
151//  decir «pinto un rect redondeado», arrastraba el shell de escritorio entero y
152//  quedaba fuera del alcance de una app enjaulada.
153//
154//  No hacía falta: lo que los widgets usan de `llimphi-ui` es, casi sin
155//  excepción, lo que `llimphi-ui` reexporta de acá abajo. Ofrecerlo desde el
156//  compositor los vuelve **agnósticos del backend** sin tocarles una línea de
157//  composición — es la Regla 2 del repo aplicada al catálogo.
158// -----------------------------------------------------------------------------
159
160/// El árbol de layout y taffy. Los widgets lo usan para `Style`, `Size`, `Rect`
161/// y el `prelude` de dimensiones.
162pub use llimphi_layout;
163/// El motor de texto: `Alignment`, `Typesetter`, `measurement`, y —vía él— los
164/// tipos de `peniko` y `kurbo` con los que se describen colores y formas.
165pub use llimphi_text;
166/// Colores y pinceles. Atajo para `llimphi_text::peniko`, que es donde vive.
167pub use llimphi_text::peniko;
168
169/// El modelo de entrada: `KeyEvent`, `ImeEvent`, `Key`… (W4.7). Vive acá y no
170/// en el shell porque un evento de teclado es un DATO del modelo — quién lo
171/// produjo (winit, el canal de scancodes del kernel, un test) es asunto del
172/// sustrato, igual que quién presenta el framebuffer.
173pub mod input;
174pub use input::{
175    destino_diente, fijar_politica_rueda, navegacion, politica_rueda, FuenteRueda, ImeEvent, Key, KeyEvent,
176    KeyState, Modifiers, NamedKey, Navegacion, PoliticaRueda, SentidoRueda, WheelDelta,
177};
178
179use llimphi_layout::taffy::NodeId;
180use llimphi_layout::{ComputedLayout, LayoutTree, Style};
181use llimphi_text::peniko::kurbo::{
182    Affine, Ellipse, Point, Rect as KurboRect, RoundedRect, RoundedRectRadii, Stroke,
183};
184use llimphi_text::peniko::{BlendMode, Color, Fill, Gradient, ImageBrush as Image, Mix};
185
186/// Censo de props raras de un árbol `View` — cuántos nodos pagan la caja.
187///
188/// El ahorro del reparto [`ViewRare`] depende de **una sola cifra**: la fracción
189/// de nodos que lleva alguna prop rara. Un árbol donde casi ninguno la lleva
190/// (grillas, listas, texto) ahorra los ~1.840 B por nodo enteros; uno donde casi
191/// todos la llevan (mucho handler, mucha animación) sólo cambia bytes inline por
192/// bytes en el heap y **suma** una asignación por nodo. `stress_grid` es el
193/// primer caso — o sea el mejor —, así que su número no se puede extrapolar a
194/// una app real sin medirla. Esto es lo que la mide.
195#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
196pub struct CensoRare {
197    /// Nodos del árbol.
198    pub nodos: usize,
199    /// Nodos que materializaron la caja de props raras.
200    pub con_rare: usize,
201}
202
203impl CensoRare {
204    /// Bytes del árbol **antes** del reparto: todo nodo pagaba el `View` gordo.
205    pub const VIEW_ANTES: usize = 2472;
206
207    /// Fracción de nodos que paga la caja, en `[0, 1]`.
208    pub fn fraccion(&self) -> f64 {
209        if self.nodos == 0 {
210            return 0.0;
211        }
212        self.con_rare as f64 / self.nodos as f64
213    }
214
215    /// Bytes que ocuparía el árbol con el `View` viejo (2.472 B por nodo).
216    pub fn bytes_antes(&self) -> usize {
217        self.nodos * Self::VIEW_ANTES
218    }
219
220    /// Bytes que ocupa hoy: el `View` flaco por nodo + una caja por el que la usa.
221    pub fn bytes_ahora<Msg>(&self) -> usize {
222        self.nodos * core::mem::size_of::<View<Msg>>()
223            + self.con_rare * core::mem::size_of::<ViewRare<Msg>>()
224    }
225}
226
227/// Recorre el árbol y cuenta nodos vs. nodos con caja. Ver [`CensoRare`].
228pub fn censo_rare<Msg>(v: &View<Msg>) -> CensoRare {
229    let mut c = CensoRare::default();
230    censo_rec(v, &mut c);
231    c
232}
233
234fn censo_rec<Msg>(v: &View<Msg>, c: &mut CensoRare) {
235    c.nodos += 1;
236    if v.rare.is_some() {
237        c.con_rare += 1;
238    }
239    for h in &v.children {
240        censo_rec(h, c);
241    }
242}
243
244
245/// Nombre de cada prop rara, en el orden de bits de [`mascara_rare`].
246pub const NOMBRES_RARE: [&str; 61] = [
247    "intrinsic", "corner_radii", "shadow", "fill_gradient", "border", "image", "image_fit", "mask_image", "mask_placement", "mask_extra", "gpu_painter", "over_painter", "on_click_at", "on_click_screen", "on_right_click", "on_right_click_at", "on_right_click_screen", "on_middle_click", "drag", "drag_at", "drag_velocity", "drag_payload", "drag_carga", "on_drop", "drop_hover_fill", "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_scroll_take", "on_scale", "on_rotate", "on_double_tap", "on_double_tap_at", "on_long_press", "on_long_press_at", "focusable", "text_select_key", "anim", "animated_size", "hero", "transform", "transform_rel", "transform_origin", "sticky", "tooltip", "cursor", "ripple", "layout_builder", "backdrop_blur", "filter", "blend", "repaint_boundary", "memo", "double_tap_exclusive", "on_drop_hover",
248];
249
250/// Bytes que ocupa cada prop rara dentro de [`ViewRare`], mismo orden.
251///
252/// Con esto se calcula, sin recompilar, cuánto pesaría `View` si tal prop
253/// volviera adentro — la decisión que el censo informa. Así se eligieron los
254/// cuatro que YA volvieron (`text`, `painter`, `alpha`, `semantics`).
255pub fn tam_rare<Msg>() -> [usize; 59] {
256    [
257        core::mem::size_of::<Option<IntrinsicSize>>(),
258        core::mem::size_of::<Option<RoundedRectRadii>>(),
259        core::mem::size_of::<Option<Shadow>>(),
260        core::mem::size_of::<Option<Gradient>>(),
261        core::mem::size_of::<Option<Border>>(),
262        core::mem::size_of::<Option<Image>>(),
263        core::mem::size_of::<Option<ImageFit>>(),
264        core::mem::size_of::<Option<Image>>(),
265        core::mem::size_of::<Option<MaskPlacement>>(),
266        core::mem::size_of::<Vec<(Image, MaskCompose)>>(),
267        core::mem::size_of::<Option<GpuPaintFn>>(),
268        core::mem::size_of::<Option<PaintFn>>(),
269        core::mem::size_of::<Option<ClickAtFn<Msg>>>(),
270        core::mem::size_of::<Option<ClickAtFn<Msg>>>(),
271        core::mem::size_of::<Option<Msg>>(),
272        core::mem::size_of::<Option<ClickAtFn<Msg>>>(),
273        core::mem::size_of::<Option<ClickAtFn<Msg>>>(),
274        core::mem::size_of::<Option<Msg>>(),
275        core::mem::size_of::<Option<DragFn<Msg>>>(),
276        core::mem::size_of::<Option<DragAtFn<Msg>>>(),
277        core::mem::size_of::<Option<DragVelocityFn<Msg>>>(),
278        core::mem::size_of::<Option<u64>>(),
279        core::mem::size_of::<Option<DropFn<Msg>>>(),
280        core::mem::size_of::<Option<Color>>(),
281        core::mem::size_of::<Option<[f32; 4]>>(),
282        core::mem::size_of::<Option<[f32; 14]>>(),
283        core::mem::size_of::<Option<(bool, Vec<[f32; 4]>)>>(),
284        core::mem::size_of::<Option<(bool, String)>>(),
285        core::mem::size_of::<Option<[f32; 4]>>(),
286        core::mem::size_of::<Option<Msg>>(),
287        core::mem::size_of::<Option<Msg>>(),
288        core::mem::size_of::<Option<ClickAtFn<Msg>>>(),
289        core::mem::size_of::<Option<ScrollFn<Msg>>>(),
290        core::mem::size_of::<Option<ScrollTakeFn<Msg>>>(),
291        core::mem::size_of::<Option<ScaleFn<Msg>>>(),
292        core::mem::size_of::<Option<RotateFn<Msg>>>(),
293        core::mem::size_of::<Option<Msg>>(),
294        core::mem::size_of::<Option<ClickAtFn<Msg>>>(),
295        core::mem::size_of::<Option<Msg>>(),
296        core::mem::size_of::<Option<ClickAtFn<Msg>>>(),
297        core::mem::size_of::<Option<u64>>(),
298        core::mem::size_of::<Option<u64>>(),
299        core::mem::size_of::<Option<Anim>>(),
300        core::mem::size_of::<Option<SizeAnim>>(),
301        core::mem::size_of::<Option<Hero>>(),
302        core::mem::size_of::<Option<Affine>>(),
303        core::mem::size_of::<Option<(f64, f64)>>(),
304        core::mem::size_of::<Option<TransformPivot>>(),
305        core::mem::size_of::<Option<[Option<f32>; 4]>>(),
306        core::mem::size_of::<Option<String>>(),
307        core::mem::size_of::<Option<Cursor>>(),
308        core::mem::size_of::<Option<Ripple>>(),
309        core::mem::size_of::<Option<LayoutBuilderFn<Msg>>>(),
310        core::mem::size_of::<Option<f32>>(),
311        core::mem::size_of::<Vec<FilterOp>>(),
312        core::mem::size_of::<Option<BlendMode>>(),
313        core::mem::size_of::<Option<u64>>(),
314        core::mem::size_of::<Option<MemoNode<Msg>>>(),
315        core::mem::size_of::<Option<()>>(),
316    ]
317}
318
319/// Bit `k` prendido = el nodo usa la prop `NOMBRES_RARE[k]`.
320pub fn mascara_rare<Msg>(r: &ViewRare<Msg>) -> u64 {
321    let mut m = 0u64;
322    if r.intrinsic.is_some() { m |= 1 << 0; }
323    if r.corner_radii.is_some() { m |= 1 << 1; }
324    if r.shadow.is_some() { m |= 1 << 2; }
325    if r.fill_gradient.is_some() { m |= 1 << 3; }
326    if r.border.is_some() { m |= 1 << 4; }
327    if r.image.is_some() { m |= 1 << 5; }
328    if r.image_fit.is_some() { m |= 1 << 6; }
329    if r.mask_image.is_some() { m |= 1 << 7; }
330    if r.mask_placement.is_some() { m |= 1 << 8; }
331    if !r.mask_extra.is_empty() { m |= 1 << 9; }
332    if r.gpu_painter.is_some() { m |= 1 << 10; }
333    if r.over_painter.is_some() { m |= 1 << 11; }
334    if r.on_click_at.is_some() { m |= 1 << 12; }
335    if r.on_click_screen.is_some() { m |= 1 << 13; }
336    if r.on_right_click.is_some() { m |= 1 << 14; }
337    if r.on_right_click_at.is_some() { m |= 1 << 15; }
338    if r.on_right_click_screen.is_some() { m |= 1 << 16; }
339    if r.on_middle_click.is_some() { m |= 1 << 17; }
340    if r.drag.is_some() { m |= 1 << 18; }
341    if r.drag_at.is_some() { m |= 1 << 19; }
342    if r.drag_velocity.is_some() { m |= 1 << 20; }
343    if r.drag_payload.is_some() { m |= 1 << 21; }
344    if r.on_drop.is_some() { m |= 1 << 22; }
345    if r.drop_hover_fill.is_some() { m |= 1 << 23; }
346    if r.clip_inset.is_some() { m |= 1 << 24; }
347    if r.clip_ellipse.is_some() { m |= 1 << 25; }
348    if r.clip_polygon.is_some() { m |= 1 << 26; }
349    if r.clip_path_svg.is_some() { m |= 1 << 27; }
350    if r.clip_ref_inset.is_some() { m |= 1 << 28; }
351    if r.on_pointer_enter.is_some() { m |= 1 << 29; }
352    if r.on_pointer_leave.is_some() { m |= 1 << 30; }
353    if r.on_pointer_move_at.is_some() { m |= 1 << 31; }
354    if r.on_scroll.is_some() { m |= 1 << 32; }
355    if r.on_scroll_take.is_some() { m |= 1 << 33; }
356    if r.on_scale.is_some() { m |= 1 << 34; }
357    if r.on_rotate.is_some() { m |= 1 << 35; }
358    if r.on_double_tap.is_some() { m |= 1 << 36; }
359    if r.on_double_tap_at.is_some() { m |= 1 << 37; }
360    if r.on_long_press.is_some() { m |= 1 << 38; }
361    if r.on_long_press_at.is_some() { m |= 1 << 39; }
362    if r.focusable.is_some() { m |= 1 << 40; }
363    if r.text_select_key.is_some() { m |= 1 << 41; }
364    if r.anim.is_some() { m |= 1 << 42; }
365    if r.animated_size.is_some() { m |= 1 << 43; }
366    if r.hero.is_some() { m |= 1 << 44; }
367    if r.transform.is_some() { m |= 1 << 45; }
368    if r.transform_rel.is_some() { m |= 1 << 46; }
369    if r.transform_origin.is_some() { m |= 1 << 47; }
370    if r.sticky.is_some() { m |= 1 << 48; }
371    if r.tooltip.is_some() { m |= 1 << 49; }
372    if r.cursor.is_some() { m |= 1 << 50; }
373    if r.ripple.is_some() { m |= 1 << 51; }
374    if r.layout_builder.is_some() { m |= 1 << 52; }
375    if r.backdrop_blur.is_some() { m |= 1 << 53; }
376    if !r.filter.is_empty() { m |= 1 << 54; }
377    if r.blend.is_some() { m |= 1 << 55; }
378    if r.repaint_boundary.is_some() { m |= 1 << 56; }
379    if r.memo.is_some() { m |= 1 << 57; }
380    if r.double_tap_exclusive.is_some() { m |= 1 << 58; }
381    if r.on_drop_hover.is_some() { m |= 1 << 59; }
382    m
383}
384
385/// Una máscara por nodo **con caja** (ver [`mascara_rare`]). Es el dato crudo
386/// del que salen todos los censos: marginales por campo, y —lo que de verdad
387/// decide— cuántos nodos dejarían de pagar la caja si tal conjunto de props
388/// volviera a vivir inline en `View`.
389pub fn mascaras_rare<Msg>(v: &View<Msg>) -> Vec<u64> {
390    let mut out = Vec::new();
391    mascaras_rec(v, &mut out);
392    out
393}
394
395fn mascaras_rec<Msg>(v: &View<Msg>, out: &mut Vec<u64>) {
396    if let Some(r) = v.rare.as_ref() {
397        out.push(mascara_rare(r));
398    }
399    for h in &v.children {
400        mascaras_rec(h, out);
401    }
402}
403
404/// Censo **por campo** (marginales), derivado de [`mascaras_rare`].
405pub fn censo_campos<Msg>(v: &View<Msg>) -> Vec<(&'static str, usize)> {
406    let ms = mascaras_rare(v);
407    let mut cuenta = [0usize; 58];
408    for m in &ms {
409        for (k, c) in cuenta.iter_mut().enumerate() {
410            if m & (1 << k) != 0 { *c += 1; }
411        }
412    }
413    let mut out: Vec<(&'static str, usize)> = NOMBRES_RARE
414        .iter()
415        .zip(cuenta.iter())
416        .filter(|(_, &c)| c > 0)
417        .map(|(n, &c)| (*n, c))
418        .collect();
419    out.sort_by(|a, b| b.1.cmp(&a.1));
420    out
421}
422/// Lee una prop **rara** de un [`View`] (ver [`ViewRare`]) devolviendo una copia,
423/// o su default si el nodo nunca asignó la caja.
424///
425/// `raro!(v, clip_inset)` dice exactamente lo que decía `v.clip_inset` antes de
426/// que las props raras se mudaran a [`ViewRare`]. Es para **inspeccionar** un
427/// árbol —tests de widgets, sobre todo—; para escribir están los builders de
428/// siempre, que no cambiaron.
429///
430/// Sólo aplica a `View`: [`MountedNode`] conserva los campos planos y se lee
431/// como siempre.
432///
433/// ```ignore
434/// assert!(raro!(vista.children[0], backdrop_blur).is_none());
435/// ```
436#[macro_export]
437macro_rules! raro {
438    ($v:expr, $campo:ident) => {
439        $v.rare
440            .as_ref()
441            .map_or_else(::core::default::Default::default, |r| r.$campo.clone())
442    };
443}
444
445mod anim;
446mod hero;
447mod layout_builder;
448mod render;
449mod ripple;
450mod semantics;
451mod sugerencia;
452mod view;
453pub use anim::{
454    ease_out_cubic, reconcile_size_anim, Anim, AnimRegistry, SizeAnim, SizeAnimRegistry,
455    SwitchStyle,
456};
457pub use hero::{Hero, HeroRegistry};
458pub use layout_builder::{collect_builder_constraints, expand_layout_builders, has_layout_builder};
459pub use render::*;
460pub use ripple::{Ripple, RippleRegistry};
461pub use semantics::{Role, SemanticsFlags, SemanticsSpec};
462pub use sugerencia::{describir, DetectorBoundaries, SugerenciaBoundary};
463
464/// Texto a pintar dentro de un nodo. Alineación por defecto `Center`
465/// (horizontal y vertical), apta para labels de botón. Para layouts tipo
466/// editor o párrafo, usar `.text_aligned(...)` con `Alignment::Start`.
467#[derive(Clone)]
468pub struct TextSpec {
469    pub content: String,
470    pub size_px: f32,
471    pub color: Color,
472    pub alignment: llimphi_text::Alignment,
473    /// `true` = forzar variante italic en la fuente activa. Default false.
474    pub italic: bool,
475    /// Peso de fuente CSS: 400 = normal, 700 = bold. parley elige la
476    /// variante más cercana de la familia activa (o la sintetiza). Se usa
477    /// tanto al **medir** como al **pintar**, así medida y dibujo coinciden.
478    /// Default 400.
479    pub weight: f32,
480    /// Límite de líneas (CSS `-webkit-line-clamp` / Flutter `maxLines`). `None`
481    /// = sin límite (envuelve libre). Cuando el texto excede, se trunca: con
482    /// [`Self::ellipsis`] la última línea termina en `…`, sin él se corta seco.
483    /// Afecta medida (taffy reserva el alto de N líneas) y pintado.
484    pub max_lines: Option<usize>,
485    /// Si `true` y `max_lines` trunca, la última línea visible termina en `…`.
486    /// Sin efecto si `max_lines` es `None`. Default false.
487    pub ellipsis: bool,
488    /// CSS-style font-family string (acepta lista con fallbacks). `None`
489    /// = la fuente default de parley.
490    pub font_family: Option<String>,
491    /// Múltiplo de interlínea (`line-height` / `font-size`). 1.2 es el
492    /// default que usaban todos los callers; puriy lo sobreescribe con el
493    /// valor computado de CSS. Se usa tanto al **medir** (para que taffy
494    /// reserve el alto correcto) como al **pintar**, así medida y dibujo
495    /// coinciden.
496    pub line_height: f32,
497    /// Colores por rango de **bytes** sobre `content`, para texto multicolor
498    /// (syntax highlighting) en una sola pasada de shaping. `None` = color
499    /// uniforme (`color`). Cuando es `Some`, el runtime usa
500    /// `Typesetter::layout_runs` + `draw_layout_runs`, y `color` actúa como
501    /// color por defecto de lo no cubierto por ningún run.
502    pub runs: Option<Vec<(usize, usize, Color)>>,
503    /// Subrayado activo. El runtime pinta la línea bajo la línea base usando
504    /// las métricas (`underline_offset`, `underline_size`) que parley deriva
505    /// de la fuente — así un texto a 12pt y otro a 24pt tienen un subrayado
506    /// proporcional sin que el caller calcule nada.
507    pub underline: bool,
508    /// Tachado activo. Mismo régimen que [`Self::underline`] pero sobre el
509    /// strikethrough metric — útil para listas to-do, items removidos en un
510    /// diff, precios viejos.
511    pub strikethrough: bool,
512    /// **Spans inline mixtos** (RichText): overrides de
513    /// tamaño/peso/italic/familia/color/underline/strikethrough por rango
514    /// de bytes (parley convention). `None` = texto uniforme (camino
515    /// `layout_clamped`); `Some([])` se trata como `None`. Cuando hay
516    /// spans, el runtime usa `Typesetter::layout_spans` (Layout<RunBrush>
517    /// con `max_width`/wrap) + `draw_layout_runs_xf`; los campos del
518    /// `TextSpec` son **defaults a nivel bloque** que cada span puede
519    /// sobreescribir. Tier 2 final de PARIDAD-FLUTTER (Bloque 13).
520    pub spans: Option<Vec<llimphi_text::TextSpan>>,
521    /// `letter-spacing`: px **extra** entre letras (CSS). 0 = normal. Afecta
522    /// shaping y medida. Sólo el camino uniforme (`layout_clamped`); el camino
523    /// de spans (RichText) lo ignora en v1.
524    pub letter_spacing: f32,
525    /// `word-spacing`: px **extra** entre palabras (CSS). 0 = normal. Mismo
526    /// régimen que [`Self::letter_spacing`].
527    pub word_spacing: f32,
528    /// `white-space: nowrap`/`pre`: si `true`, el texto **no envuelve** —
529    /// se shapea en una sola línea (`break_all_lines(None)`) sin importar el
530    /// ancho disponible, y desborda la caja (lo recorta `overflow: hidden` si
531    /// lo hay). Afecta medida (taffy reserva el ancho de la línea completa) y
532    /// pintado. Default false (wrap libre, comportamiento previo). Sólo el
533    /// camino uniforme (`layout_clamped`); el de spans (RichText) lo ignora en
534    /// v1, igual que el clamp.
535    pub no_wrap: bool,
536    /// `overflow-wrap: break-word`/`anywhere` (o `word-break: break-all`): si
537    /// `true`, una palabra más ancha que la caja se **parte** para que entre,
538    /// en vez de desbordar. Afecta medida (taffy puede reservar menos ancho) y
539    /// pintado. Default false (la palabra larga desborda — comportamiento
540    /// previo). Sólo el camino uniforme (`layout_clamped`); el de spans
541    /// (RichText) lo ignora en v1, igual que `no_wrap`/clamp.
542    pub overflow_wrap: bool,
543    /// `font-feature-settings` (+ `font-kerning` plegado como `kern`) en formato
544    /// CSS crudo (`"liga" 1, "kern" 0`). Vacío = default de la fuente. Afecta
545    /// shaping y medida (ligaduras/kerning cambian el ancho). Sólo el camino
546    /// uniforme (`layout_clamped`); el de spans (RichText) lo ignora en v1.
547    pub font_features: String,
548    /// `font-variation-settings` en formato CSS crudo (`"wght" 700`). Vacío =
549    /// ejes en su default. Mismo régimen que [`Self::font_features`].
550    pub font_variations: String,
551    /// `font-stretch`/`font-width` como **porcentaje** (100 = normal, <100
552    /// condensada, >100 expandida). Afecta shaping y medida (la face
553    /// condensada/expandida cambia el ancho). Sólo el camino uniforme
554    /// (`layout_clamped`); el de spans (RichText) lo ignora en v1.
555    pub font_width: f32,
556}
557
558/// Fase de un drag activo. `Move` se emite por cada `CursorMoved` con el
559/// delta desde el evento anterior; `End` se emite al soltar el botón.
560#[derive(Debug, Clone, Copy, PartialEq, Eq)]
561pub enum DragPhase {
562    Move,
563    End,
564}
565
566/// Handler de drag. Recibe la fase + delta (`dx`, `dy`) **desde el evento
567/// anterior** (no acumulado desde el press). Devolver `None` deja el drag
568/// activo sin disparar Msg. `Arc<dyn Fn>` para que el runtime pueda
569/// clonarlo barato al iniciar el drag y mantenerlo vivo aunque el cache
570/// de la vista se regenere mientras tanto.
571pub type DragFn<Msg> = Arc<dyn Fn(DragPhase, f32, f32) -> Option<Msg> + Send + Sync>;
572
573/// **Lo que un arrastre lleva puesto**, para que se vea mientras viaja.
574///
575/// Un drag con `drag_payload` transfiere algo, pero hasta el 2026-09-10 eso no
576/// se veía en ningún lado: el único aviso era que el destino bajo el cursor se
577/// iluminaba, o sea que no había forma de saber **qué** estabas arrastrando ni
578/// —si arrancaste sin querer— que estabas arrastrando algo.
579///
580/// El nodo origen la declara con [`View::drag_carga`] y el runtime pinta un
581/// fantasma pegado al cursor. Es deliberadamente pobre —un rótulo y un
582/// número— porque tiene que servir igual para una fila de archivos, una
583/// pestaña y un panel teselado, y porque el fantasma se pinta **por cuadro**
584/// mientras dura el gesto.
585#[derive(Clone, Debug, PartialEq, Eq)]
586pub struct CargaArrastre {
587    /// Qué llevás, en una línea. El runtime lo recorta si no entra.
588    pub rotulo: String,
589    /// Cuántas cosas. `1` no pinta número; `>1` pinta la chapita.
590    pub cuantos: usize,
591    /// **Las rutas, si lo que viaja son archivos.** Vacío = el arrastre es
592    /// interno de la app (reordenar una pestaña, mover un panel) y no tiene
593    /// nada que ofrecerle a otra ventana.
594    ///
595    /// Con rutas, el runtime le entrega el gesto al compositor en cuanto el
596    /// cursor se va de la ventana: eso es lo que permite soltar el archivo en
597    /// un navegador o en otra app. Son `String` y no `PathBuf` porque este
598    /// crate tiene que poder compilar sin `std` (el kernel de wawa lo usa).
599    pub archivos: Vec<String>,
600}
601
602impl CargaArrastre {
603    /// Una sola cosa, con su nombre.
604    pub fn una(rotulo: impl Into<String>) -> Self {
605        Self { rotulo: rotulo.into(), cuantos: 1, archivos: Vec::new() }
606    }
607
608    /// Varias: el rótulo es el de la primera y el número dice cuántas van.
609    pub fn varias(rotulo: impl Into<String>, cuantos: usize) -> Self {
610        Self { rotulo: rotulo.into(), cuantos: cuantos.max(1), archivos: Vec::new() }
611    }
612
613    /// **Y son ARCHIVOS**: estas rutas son lo que se le ofrece a otra ventana
614    /// cuando el cursor se va de la app. Sin esto el arrastre no sale de acá.
615    pub fn con_archivos(mut self, rutas: impl IntoIterator<Item = String>) -> Self {
616        self.archivos = rutas.into_iter().collect();
617        self
618    }
619}
620
621/// Handler de drop. El runtime lo invoca cuando un drag activo se suelta
622/// sobre este nodo. Recibe el `payload` `u64` que el origen del drag
623/// declaró vía [`View::drag_payload`]. Devolver `None` ignora el drop.
624///
625/// Los IDs `u64` son opacos para el runtime: el widget elige una
626/// convención (índice de tile, hash del item, etc.) y el handler decide
627/// qué Msg emitir en función de ese ID.
628pub type DropFn<Msg> = Arc<dyn Fn(u64) -> Option<Msg> + Send + Sync>;
629
630/// Handler de click con posición. Recibe `(x_local, y_local, rect_w,
631/// rect_h)`: las dos primeras son la posición del cursor **relativa a
632/// la esquina superior-izquierda del nodo** y las dos últimas son el
633/// ancho/alto actual del nodo en pixels — útil cuando el caller
634/// necesita centrar o normalizar. Devolver `None` no dispara update.
635pub type ClickAtFn<Msg> = Arc<dyn Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync>;
636
637/// Handler de rueda **local a un nodo**. Recibe el delta `(dx, dy)` en
638/// líneas lógicas (misma normalización que `App::on_wheel`: `dy` positivo
639/// = scroll hacia abajo). El runtime lo invoca cuando la rueda gira con el
640/// cursor sobre este nodo, ANTES de caer al `App::on_wheel` global: si el
641/// handler devuelve `Some(Msg)`, el evento se consume aquí. Permite áreas
642/// de scroll autocontenidas (el widget `scroll` lo usa) sin que cada app
643/// rutee la rueda a mano por su `Model`. Devolver `None` deja pasar el
644/// evento al `on_wheel` global.
645pub type ScrollFn<Msg> = Arc<dyn Fn(f32, f32) -> Option<Msg> + Send + Sync>;
646
647/// Handler de rueda con **reparto parcial** (scroll anidado real): recibe el
648/// delta `(dx, dy)` en líneas y devuelve `(msg, rest_x, rest_y)` — el `Msg`
649/// por lo que consumió (o `None` si no consumió nada) y el **sobrante** en la
650/// misma unidad, que el runtime sigue repartiendo al siguiente scrollable de
651/// la cadena (el ancestro). Un mismo evento puede así mover al hijo hasta su
652/// tope Y al padre con el resto, en el mismo frame. Ver
653/// [`View::on_scroll_take`] y [`consume_scroll_chain`].
654pub type ScrollTakeFn<Msg> = Arc<dyn Fn(f32, f32) -> (Option<Msg>, f32, f32) + Send + Sync>;
655
656/// Variante de [`DragFn`] que **conoce la posición inicial del press**
657/// relativa al rect del nodo. Útil cuando el caller necesita identificar
658/// qué entidad (Concepto, lemming, etc.) bajo el cursor agarró el drag.
659/// Recibe `(phase, dx, dy, initial_lx, initial_ly)`.
660pub type DragAtFn<Msg> = Arc<dyn Fn(DragPhase, f32, f32, f32, f32) -> Option<Msg> + Send + Sync>;
661
662/// Variante de [`DragFn`] que recibe la **velocidad del drag al soltarlo**
663/// (`vx`, `vy` en px/s). El runtime mide el desplazamiento sobre los
664/// últimos ~100 ms de movimiento (ventana móvil de hasta ocho samples)
665/// y la pasa en `DragPhase::End`. Durante `DragPhase::Move` ambas son
666/// `0.0` — la velocidad sólo es significativa al final. Permite
667/// **fling-desde-drag**: el caller arranca un ticker con esa velocidad y
668/// la decae con [`fling_step`](https://docs.rs/) hasta asentar. Reemplaza
669/// la estimación manual que antes tenía que llevar el caller con
670/// `Instant::now()` por su cuenta.
671pub type DragVelocityFn<Msg> =
672    Arc<dyn Fn(DragPhase, f32, f32, f32, f32) -> Option<Msg> + Send + Sync>;
673
674/// Fase de un **gesto continuo** (pinch-to-zoom de momento; rotación a futuro).
675/// El runtime emite `Begin` al iniciar el gesto, `Update` por cada cambio
676/// incremental y `End` al terminar. El camino de Ctrl+rueda (universal, sin
677/// trackpad) emite un único `Update` por click de rueda — no hay un "inicio"
678/// ni "fin" naturales, así que el handler debe tolerar `Update`s sueltos sin
679/// `Begin` previo (es lo común en desktop). El camino de trackpad
680/// (`PinchGesture`, sólo macOS/iOS) sí entrega `Begin`/`Update*`/`End`.
681#[derive(Debug, Clone, Copy, PartialEq, Eq)]
682pub enum GesturePhase {
683    Begin,
684    Update,
685    End,
686}
687
688/// Handler de gesto de **escala** (pinch-to-zoom). Recibe `(phase, factor,
689/// focal_x, focal_y)`:
690/// - `factor`: cambio de escala **incremental y multiplicativo** desde el
691///   evento anterior — `1.0` = sin cambio, `>1.0` agranda (zoom in), `<1.0`
692///   achica (zoom out). El caller acumula con `mi_zoom *= factor` y, si
693///   quiere, lo clampa a su rango. En `Begin`/`End` el factor es `1.0`.
694/// - `focal_x`/`focal_y`: punto focal del gesto **relativo a la esquina
695///   superior-izquierda del rect del nodo** (mismo espacio que los handlers
696///   `*_at`). Es el punto que debe quedar fijo bajo el cursor al hacer zoom —
697///   el caller lo usa para zoomear "hacia el cursor" en vez de hacia el
698///   centro. En Ctrl+rueda es la posición del cursor; en trackpad, idem.
699///
700/// Devolver `Some(Msg)` dispara una transición; `None` ignora el evento. El
701/// runtime lo resuelve con [`hit_test_scale`]: el nodo más al frente bajo el
702/// cursor que declare un `on_scale` consume el gesto. Es la base del zoom de
703/// los canvases (pineal/cosmos/nakui).
704pub type ScaleFn<Msg> = Arc<dyn Fn(GesturePhase, f32, f32, f32) -> Option<Msg> + Send + Sync>;
705
706/// Handler de gesto de **rotación** (trackpad, sólo macOS — winit no emite
707/// `RotationGesture` en Wayland/Windows). Análogo a [`ScaleFn`] pero el
708/// segundo argumento es el **delta de ángulo incremental en radianes**
709/// (positivo = horario) en lugar del factor de escala; `(focal_x, focal_y)`
710/// es el punto bajo el cursor relativo al rect del nodo. El nodo más al
711/// frente bajo el cursor que declare un `on_rotate` consume el gesto. Base
712/// para rotar canvases/imágenes con dos dedos. Ver [`View::on_rotate`].
713pub type RotateFn<Msg> = Arc<dyn Fn(GesturePhase, f32, f32, f32) -> Option<Msg> + Send + Sync>;
714
715/// Restricciones de tamaño que un [`LayoutBuilderFn`] recibe: las dimensiones
716/// del slot que el layout le asignó al nodo (en px físicos). Análogo a las
717/// `BoxConstraints` de Flutter `LayoutBuilder` / al `MediaQuery` pero **local
718/// al nodo** (no a la ventana). El builder construye su subárbol en función de
719/// esto — p. ej. una columna si `max_width < 600`, dos si es ancho.
720#[derive(Debug, Clone, Copy, PartialEq)]
721pub struct Constraints {
722    pub max_width: f32,
723    pub max_height: f32,
724}
725
726/// Constructor **diferido** de subárbol sensible al tamaño (Flutter
727/// `LayoutBuilder`). El runtime resuelve el tamaño del slot del nodo en una
728/// primera pasada de layout y luego invoca esta closure con esas
729/// [`Constraints`] para producir los hijos — así "construir distinto según el
730/// espacio disponible" deja de exigir conocer el tamaño al armar el `View`. Ver
731/// [`View::layout_builder`].
732pub type LayoutBuilderFn<Msg> = Arc<dyn Fn(Constraints) -> View<Msg> + Send + Sync>;
733
734/// Constructor **diferido** del contenido de un [`View::memo`] (Bloque 25).
735/// Produce el subárbol (un único `View`) que va DENTRO del nodo memo. El runtime
736/// lo llama sólo cuando `deps` (o el tamaño del slot) cambió; si no, reusa el
737/// `Mounted` cacheado sin reconstruir el `View`.
738pub type MemoBuilderFn<Msg> = Arc<dyn Fn() -> View<Msg> + Send + Sync>;
739
740/// Nodo **memoizado** (Bloque 25 de `PLAN-NUCLEO-RETENIDO.md`): un subárbol
741/// cuyo `View` NO se reconstruye —ni se re-monta ni se re-layoutea— si sus
742/// `deps` no cambiaron. El nodo memo lleva un `Style` con **tamaño definido**
743/// (length/percent, no auto): el runtime lo monta como hoja de ese tamaño,
744/// computa el subárbol en un `LayoutTree` aislado (que taffy cachea) y splica
745/// su `Mounted` cacheado. `builder` = `Some` mientras está sin resolver;
746/// `resolve_memos` lo extrae y el nodo queda como marcador. Ver [`View::memo`].
747pub struct MemoNode<Msg> {
748    pub key: u64,
749    pub deps: u64,
750    pub builder: Option<MemoBuilderFn<Msg>>,
751}
752
753/// Rect absoluto del nodo (en coordenadas físicas del frame). Lo
754/// recibe el callback de [`View::paint_with`] para que pueda
755/// posicionar sus primitivas custom dentro del nodo.
756#[derive(Debug, Clone, Copy, Default)]
757pub struct PaintRect {
758    pub x: f32,
759    pub y: f32,
760    pub w: f32,
761    pub h: f32,
762}
763
764/// Callback de pintura custom. El runtime lo invoca durante el paint
765/// del nodo (entre el `fill`/`image` y el `text`) con el `Scene` vivo
766/// + el `Typesetter` cacheado del runtime + el rect absoluto del nodo.
767/// Pensado para "canvas elements" tipo `dominium-canvas`,
768/// `pluma-editor` (osciloscopio de coherencia), `cosmos` (charts).
769///
770/// El `Typesetter` se pasa porque crearlo por frame es caro
771/// (`FontContext::new` enumera las fontes del sistema vía fontique).
772/// Los callers que no necesiten texto pueden ignorar el argumento.
773///
774/// El callback no debe llamar a `scene.push_layer` sin un `pop_layer`
775/// correspondiente, ni reset el scene — sólo agregar primitivas que
776/// pertenezcan al rect del nodo.
777pub type PaintFn = Arc<
778    dyn Fn(&mut Lienzo, &mut llimphi_text::Typesetter, PaintRect) + Send + Sync,
779>;
780
781/// Callback de pintura GPU directo, sin vello intermedio. Recibe el
782/// `device`/`queue` ya construidos por el runtime más un
783/// `CommandEncoder` y la `TextureView` del frame (la intermediate
784/// `Rgba8Unorm` de `WinitSurface`), todo durante el paint del nodo.
785///
786/// El caller abre su propio `begin_render_pass` con `LoadOp::Load` para
787/// no sobrescribir lo que ya pintó vello, dibuja sus primitivas y
788/// cierra el pass. El runtime se encarga de dispatchear (`queue.submit`)
789/// el encoder ya con todas las pasadas de todos los nodos acumuladas —
790/// es un solo submit por frame.
791///
792/// **Orden de pintura en Fase 1**: todos los `gpu_painter` corren
793/// DESPUÉS de la pasada completa de vello (fill, image, painter,
794/// text) sobre el `mounted` tree. Entre sí mantienen el orden DFS
795/// pre-orden. Si una app necesita pintar texto **encima** del render
796/// GPU directo, la forma idiomática es ponerlo en `App::view_overlay`,
797/// que se renderiza como una segunda Scene de vello encima de todo.
798///
799/// Pensado para apps con volumen masivo de primitivos (cosmos
800/// starfield Gaia, tinkuy particle viewer, nakui viewport, pineal
801/// denso) — el hook que paga el costo de mantener pipelines WGSL
802/// propias en `llimphi-raster` (ver `02_ruway/llimphi/SDD.md`
803/// §"Roadmap — GPU directo wgpu").
804#[cfg(feature = "gpu")]
805pub type GpuPaintFn = Arc<
806    dyn Fn(
807            &wgpu::Device,
808            &wgpu::Queue,
809            &mut wgpu::CommandEncoder,
810            &wgpu::TextureView,
811            PaintRect,
812            (u32, u32),
813        ) + Send
814        + Sync,
815>;
816
817/// Sin la feature `gpu` no hay `wgpu`, y por lo tanto no hay pintor GPU
818/// directo — pero el TIPO tiene que seguir existiendo.
819///
820/// Es una decisión de diff, no de diseño: el campo `gpu_painter` aparece en dos
821/// structs y se mueve por nombre corto en dos literales dentro de un crate de
822/// 10.000 líneas. Ponerle `#[cfg]` a cada uno de esos sitios sería revolver el
823/// núcleo del compositor para nada. Cambiando sólo la FORMA del alias, todo ese
824/// cuerpo queda intacto y el campo simplemente nunca se puebla: el único
825/// constructor —[`View::gpu_paint_with`]— tampoco existe sin la feature.
826#[cfg(not(feature = "gpu"))]
827pub type GpuPaintFn = Arc<dyn Fn(PaintRect, (u32, u32)) + Send + Sync>;
828
829/// Callback de pintura vello "over": idéntico en firma a [`PaintFn`]
830/// `(&mut Scene, &mut Typesetter, PaintRect)`, pero el runtime lo invoca
831/// en una pasada vello FINAL, **después** de todos los `gpu_painter` del
832/// frame. Sus primitivas se rasterizan sobre fondo transparente y se
833/// componen con alpha encima de la intermedia (que ya tiene
834/// vello-base + GPU directo). Resuelve el z-order inverso al de
835/// [`GpuPaintFn`]: permite pintar texto/sprites AA por vello **encima**
836/// de celdas instanciadas por GPU (dominium grid, futuro motor voxel).
837///
838/// Orden total del frame: `[vello base] → [gpu_painter] → [over_painter]
839/// → [overlay/menús]`. Los menús (`view_overlay`) siguen quedando por
840/// encima del over-layer. Ver [`View::paint_over`]. Es un alias de
841/// [`PaintFn`]; existe sólo para documentar la semántica temporal.
842pub type OverPaintFn = PaintFn;
843
844/// Sombra proyectada detrás del rect del nodo (drop shadow), rasterizada
845/// con el `draw_blurred_rounded_rect` nativo de vello. Se pinta **antes**
846/// del relleno, así el fill (si es opaco) tapa la parte solapada y la
847/// sombra sólo asoma por el desenfoque + el offset. El radio sigue al del
848/// nodo (más `spread`).
849#[derive(Clone, Copy, Debug, PartialEq)]
850pub struct Shadow {
851    pub color: Color,
852    /// Desviación estándar del gaussiano (qué tan difusa). En px.
853    pub blur: f64,
854    /// Desplazamiento de la sombra respecto del nodo.
855    pub dx: f64,
856    pub dy: f64,
857    /// Cuánto crece (px) el rect de la sombra respecto del nodo.
858    pub spread: f64,
859}
860
861impl Shadow {
862    /// Sombra con color + blur explícitos, sin offset ni spread.
863    pub fn new(color: Color, blur: f64) -> Self {
864        Self { color, blur, dx: 0.0, dy: 0.0, spread: 0.0 }
865    }
866
867    /// Elevación suave y tasteful: negro translúcido, leve caída hacia
868    /// abajo. El default razonable para cards/menús/modales.
869    pub fn soft(alpha: u8, blur: f64) -> Self {
870        Self {
871            color: Color::from_rgba8(0, 0, 0, alpha),
872            blur,
873            dx: 0.0,
874            dy: blur * 0.4,
875            spread: 0.0,
876        }
877    }
878
879    pub fn offset(mut self, dx: f64, dy: f64) -> Self {
880        self.dx = dx;
881        self.dy = dy;
882        self
883    }
884
885    pub fn spread(mut self, spread: f64) -> Self {
886        self.spread = spread;
887        self
888    }
889}
890
891/// Borde (stroke) pintado sobre el contorno redondeado del nodo, **inset**
892/// hacia adentro media línea para que el grosor quede dentro del rect
893/// (convención CSS `box-sizing: border-box`). Se pinta después del relleno.
894#[derive(Clone, Copy, Debug)]
895pub struct Border {
896    pub width: f64,
897    pub color: Color,
898}
899
900impl Border {
901    pub fn new(width: f64, color: Color) -> Self {
902        Self { width, color }
903    }
904}
905
906/// Una operación de filtro CSS (`filter: blur()/brightness()/…`) aplicada al
907/// **propio subárbol** del nodo. A diferencia de `backdrop_blur` (que afecta lo
908/// pintado *debajo*), un `FilterOp` modifica el contenido del nodo. El runtime
909/// los aplica como post-pasada GPU sobre la intermediate, restringidos al rect
910/// del nodo, en el orden de la lista. La lista crece por fase (CSS Filter
911/// Effects 1): `Blur` (7.1232) + `ColorMatrix` (7.1233). Fase 7.1232.
912#[derive(Clone, Debug, PartialEq)]
913pub enum FilterOp {
914    /// `filter: blur(<px>)`. `px` es la desviación estándar del Gauss (igual
915    /// convención que CSS). Se aplica con `BlurCompositor`, el mismo camino que
916    /// `backdrop_blur`.
917    Blur(f32),
918    /// Filtros de color (`brightness`/`contrast`/`grayscale`/`sepia`/`saturate`/
919    /// `invert`/`hue-rotate`/`opacity`) colapsados a una **matriz de color 4×5**
920    /// row-major: por fila `[c0, c1, c2, c3, bias]`, salida R/G/B/A
921    /// (`out = M·rgba + bias`). Se aplica con `ColorFilterCompositor`. Fase
922    /// 7.1233.
923    ColorMatrix([f32; 20]),
924    /// `filter: drop-shadow(<ox> <oy> [blur] [color])`. Se pinta como una sombra
925    /// Gaussiana del **border-box** detrás del nodo (con `draw_blurred_rounded_rect`,
926    /// igual primitiva que `Shadow`/box-shadow). v1: sombra del rect, no de la
927    /// silueta alpha del subárbol. A diferencia de `Blur`/`ColorMatrix`, NO es
928    /// post-pasada GPU — se pinta en vello antes del relleno, por lo que
929    /// `collect_filters` la ignora. Fase 7.1234.
930    DropShadow(Shadow),
931}
932
933/// Punto de pivote de `transform` (CSS `transform-origin`). Cada eje se resuelve
934/// contra el rect del nodo como `px + frac · tamaño`: `px` (ya escalado por zoom
935/// por el caller) cubre offsets absolutos y `frac` los porcentuales (`0.5` = 50%
936/// del ancho/alto). El default CSS `50% 50%` (centro) es
937/// `{ px: (0.0, 0.0), frac: (0.5, 0.5) }`; un nodo con `transform_origin: None`
938/// usa ese centro. Modela `px + %` por eje igual que `transform_rel` modela el
939/// `translate(<%>)` — necesario porque el % depende del layout, desconocido hasta
940/// `paint`.
941#[derive(Debug, Clone, Copy, PartialEq)]
942pub struct TransformPivot {
943    /// Offset absoluto en px (ya × zoom) por eje `(x, y)`.
944    pub px: (f64, f64),
945    /// Fracción del tamaño del rect por eje `(x, y)` (`0.5` = 50%).
946    pub frac: (f64, f64),
947}
948
949impl Default for TransformPivot {
950    fn default() -> Self {
951        // CSS `transform-origin: 50% 50%` — centro del rect.
952        Self { px: (0.0, 0.0), frac: (0.5, 0.5) }
953    }
954}
955
956/// Nodo de la vista declarativa. Estilo de layout (taffy) + relleno opcional
957/// (vello) + texto opcional (skrifa+vello) + Msg al click opcional + hijos.
958pub struct View<Msg> {
959    pub style: Style,
960    pub fill: Option<Color>,
961    /// Relleno cuando el cursor está sobre este nodo. Sin valor (`None`)
962    /// = no se reacciona al hover.
963    pub hover_fill: Option<Color>,
964    pub radius: f64,
965    /// **Subárbol over** (ver [`View::over`]): este nodo y todos sus
966    /// descendientes se pintan en la pasada vello FINAL, después del pase
967    /// GPU directo — con toda la maquinaria normal (fills, texto, imágenes,
968    /// painters, hover). Es el primitivo para UI **encima** de contenido
969    /// `gpu_paint_with` (OSC de un reproductor, HUD sobre un canvas GPU).
970    /// El hit-test no cambia (usa el layout, no la pasada de pintura).
971    /// `false` = pinta en la pasada base, como siempre (coste cero).
972    pub over_layer: bool,
973    pub on_click: Option<Msg>,
974    /// Si `true`, los descendientes se recortan al rect del nodo (vía
975    /// `scene.push_layer` con `Mix::Clip`). El hit-test también respeta
976    /// el recorte: clicks fuera del rect ignoran a los hijos.
977    pub clip: bool,
978    pub children: Vec<View<Msg>>,
979    pub text: Option<TextSpec>,
980    /// Callback de pintura custom. Si está presente, el runtime lo
981    /// invoca durante el paint del nodo con el `Scene` vivo + el rect
982    /// absoluto. Pensado para "canvas elements" (dominium, pluma,
983    /// cosmos) que pintan primitivas custom no expresables como una
984    /// composición de Views.
985    pub painter: Option<PaintFn>,
986    /// Opacidad multiplicada sobre TODO el subtree (este nodo + hijos),
987    /// en `[0.0, 1.0]`. Se realiza con `scene.push_layer(Mix::Normal, a, …)`
988    /// alrededor del rect del nodo: el subárbol se rasteriza en una capa
989    /// intermedia y se compone al alfa indicado contra lo que ya hay
990    /// detrás. `None` = sin capa (caso de la abrumadora mayoría de
991    /// nodos). Útil para fade-in/out de overlays, ghosts mientras se
992    /// arrastra, modales que aparecen, panels "vidrio". Note que la
993    /// composición tiene costo (allocate + blit), por lo que sólo
994    /// poblar este slot cuando hace falta — no es un atributo gratis.
995    pub alpha: Option<f32>,
996    /// **Semántica accesible** del nodo (rol, label, value, flags ARIA). El
997    /// runtime la traduce a un árbol AccessKit por frame para alimentar
998    /// lectores de pantalla (NVDA/VoiceOver/Orca/TalkBack). `None` = no
999    /// declarada (el lector lee el texto plano si lo hay, sin rol específico).
1000    /// Ver [`SemanticsSpec`].
1001    pub semantics: Option<SemanticsSpec>,
1002    /// Props raras, boxeadas aparte (ver [`ViewRare`]). `None` —el caso
1003    /// normal— son 8 B; sólo el nodo que usa alguna paga la asignación.
1004    pub rare: Option<Box<ViewRare<Msg>>>,
1005}
1006
1007/// Props **raras** de un [`View`]: las que lleva una minoría de los nodos de
1008/// cualquier árbol real (animaciones, máscaras, clips exóticos, la docena de
1009/// handlers de gesto, semántica de accesibilidad…).
1010///
1011/// Viven acá y no en `View` porque `View` se asigna **una vez por nodo y por
1012/// frame**, y el perfilador de asignaciones (Bloque 21) mostró que a 200k nodos
1013/// esa sola struct era el 90 % del heap del frame — 484 MB, `size_of::<View>()`
1014/// × nodos, clavado. Cada campo raro que vivía inline se cobraba en los 200.000
1015/// nodos aunque lo usaran diez. Acá se cobra un puntero (8 B) en el que no lo
1016/// usa, y una asignación en el que sí.
1017///
1018/// Es el mismo reparto que hacen Blink (`RareInheritedData`) y Servo por la
1019/// misma razón. La API pública no cambia: los builders de [`View`] escriben acá
1020/// solos vía `View::raro`.
1021pub struct ViewRare<Msg> {
1022    /// Tamaño natural del contenido reemplazado de este nodo (ver
1023    /// [`IntrinsicSize`]). Lo pone [`View::intrinsic`]; sólo se registra para
1024    /// medir si el nodo es HOJA.
1025    pub intrinsic: Option<IntrinsicSize>,
1026    /// Radio **por esquina** (top-left, top-right, bottom-right, bottom-left),
1027    /// que sobreescribe a `radius` cuando está presente. Permite cards con
1028    /// sólo las esquinas de arriba redondeadas, pestañas, bocadillos de chat,
1029    /// etc. (CSS `border-radius` con 4 valores). `None` = usar el `radius`
1030    /// uniforme. Ver [`View::radius_corners`]. La **sombra** sigue usando un
1031    /// radio escalar (el blur nativo de vello no acepta radios por esquina);
1032    /// el **borde** sí respeta las cuatro esquinas.
1033    pub corner_radii: Option<RoundedRectRadii>,
1034    /// Sombra proyectada detrás del nodo (drop shadow). `None` = sin sombra
1035    /// (la mayoría de nodos). Ver [`Shadow`].
1036    pub shadow: Option<Shadow>,
1037    /// Relleno con **gradiente**, autoreado en el cuadrado unidad `[0,1]²` y
1038    /// mapeado al rect del nodo. Gana sobre `fill` como base; `hover_fill`
1039    /// (un color) lo sigue overrideando en hover. Ver [`View::fill_gradient`].
1040    pub fill_gradient: Option<Gradient>,
1041    /// `fill_gradient` en coordenadas de la **superficie** (px) y no del
1042    /// cuadrado unidad del nodo: el degradé es uno solo para toda la superficie
1043    /// y cada nodo muestra su tramo. Ver [`View::fill_gradient_superficie`].
1044    pub fill_gradient_superficie: bool,
1045    /// Borde (stroke) sobre el contorno redondeado. Ver [`Border`].
1046    pub border: Option<Border>,
1047    /// Imagen a pintar dentro del rect del nodo. Se centra y escala
1048    /// según [`Self::image_fit`] (default `Contain` = preservar
1049    /// aspect ratio cabiendo). El alfa por píxel de la imagen y el
1050    /// `Image::alpha` global se respetan; el `fill` (si lo hay) se
1051    /// pinta debajo como background. El clip al `node_rrect` respeta
1052    /// `radius`/`corner_radii`, así avatares y cards con esquinas
1053    /// redondeadas funcionan sin envolver en un padre `clip(true)`.
1054    pub image: Option<Image>,
1055    /// Política de encaje de [`Self::image`] en el rect del nodo
1056    /// (CSS `object-fit`). `None` = `Contain` (el default histórico).
1057    /// Ver [`ImageFit`] y [`View::image_fit`].
1058    pub image_fit: Option<ImageFit>,
1059    /// **Máscara de luminancia** (CSS `mask-image`). Si está presente, el
1060    /// runtime aísla el subárbol del nodo en una capa y luego lo enmascara con
1061    /// la luminancia de esta imagen (`push_luminance_mask_layer` de vello):
1062    /// blanco = visible, negro = oculto, gris = semitransparente. El encaje lo
1063    /// fija [`Self::mask_placement`] (size/position/repeat); sin él la imagen se
1064    /// estira al border-box. `None` = sin máscara. Ver [`View::mask_image`].
1065    pub mask_image: Option<Image>,
1066    /// Encaje de [`Self::mask_image`] (CSS `mask-size`/`-position`/`-repeat`).
1067    /// `None` = estirar al border-box (Fase 7.1226). Sólo se consulta si
1068    /// `mask_image` está presente. Ver [`MaskPlacement`] y
1069    /// [`View::mask_placement`]. Fase 7.1227.
1070    pub mask_placement: Option<MaskPlacement>,
1071    /// Capas de máscara ADICIONALES (`mask-image: url(a), url(b), …`): cada una
1072    /// es `(imagen, operador)`. Comparten [`Self::mask_placement`] con la capa 0
1073    /// ([`Self::mask_image`]); se combinan con ella según el operador. Vacío =
1074    /// una sola capa. Ver [`View::mask_extra`]. Fase 7.1231.
1075    pub mask_extra: Vec<(Image, MaskCompose)>,
1076    /// Pintor GPU directo. Se invoca DESPUÉS de la pasada vello del
1077    /// frame; comparte tree y orden DFS con los demás. Ver
1078    /// [`GpuPaintFn`].
1079    pub gpu_painter: Option<GpuPaintFn>,
1080    /// Pintor vello "over": closure que pinta DESPUÉS del pase GPU del
1081    /// frame, sobre una escena vello que el runtime compone con alpha
1082    /// encima de la intermedia. Sirve para sprites/texto AA encima de
1083    /// celdas instanciadas por GPU. Ver [`View::paint_over`] y
1084    /// [`OverPaintFn`]. Misma firma que [`PaintFn`] — sólo cambia
1085    /// *cuándo* corre (post-GPU). `None` = sin over-layer (coste cero).
1086    pub over_painter: Option<PaintFn>,
1087    /// Handler de click que recibe la posición **relativa al rect del
1088    /// nodo** (esquina superior-izquierda del nodo = `(0, 0)`). Útil
1089    /// para canvas elements que quieren mapear el click a coordenadas
1090    /// de mundo. Si está presente, gana sobre `on_click`. Devolver
1091    /// `None` no dispara update.
1092    pub on_click_at: Option<ClickAtFn<Msg>>,
1093    /// Gemelo de izquierda de [`Self::on_right_click_screen`]: click izquierdo con
1094    /// coords **absolutas de ventana**, para anclar un popup (select/dropdown) en
1095    /// la capa `view_overlay`.
1096    pub on_click_screen: Option<ClickAtFn<Msg>>,
1097    /// Equivalente a `on_click` pero para el botón derecho del ratón.
1098    /// Pensado para menús contextuales: el nodo declara qué `Msg`
1099    /// emitir cuando se le hace right-click, y la app abre el overlay
1100    /// con el menú.
1101    pub on_right_click: Option<Msg>,
1102    /// Variante posicional de [`Self::on_right_click`]. Útil para
1103    /// grillas que necesitan saber *qué celda* del rect recibió el
1104    /// click derecho (la celda no es un nodo aparte, sino una región
1105    /// dentro del nodo). Si está presente, gana sobre `on_right_click`.
1106    pub on_right_click_at: Option<ClickAtFn<Msg>>,
1107    /// Como [`Self::on_right_click_at`] pero recibe coords **absolutas de
1108    /// ventana** (no locales al nodo). Para anclar menús contextuales en la
1109    /// posición del puntero: `on_right_click_at` (local) sirve a grillas/canvas
1110    /// que necesitan saber QUÉ región del rect se clickeó; éste sirve a popups
1111    /// que quieren la posición en pantalla. Los backends le pasan el cursor
1112    /// absoluto. Convive con `on_right_click_at` (un nodo puede tener ambos).
1113    pub on_right_click_screen: Option<ClickAtFn<Msg>>,
1114    /// Equivalente a `on_click` pero para el botón del medio del ratón
1115    /// (rueda presionada). Pensado para abrir en pestaña nueva — los
1116    /// browsers usan middle-click como atajo equivalente a Ctrl+Click.
1117    pub on_middle_click: Option<Msg>,
1118    /// Handler de drag. Si está presente, este nodo arrastra (y NO emite
1119    /// `on_click` al presionar — un nodo es uno u otro).
1120    pub drag: Option<DragFn<Msg>>,
1121    /// Variante de drag que recibe la posición inicial del press relativa
1122    /// al rect del nodo. Gana sobre `drag` si ambos están presentes.
1123    pub drag_at: Option<DragAtFn<Msg>>,
1124    /// Variante de drag que recibe la **velocidad** al soltar (`vx`, `vy`
1125    /// en px/s) además del delta puntual. Gana sobre `drag`/`drag_at`
1126    /// cuando está presente — un nodo elige un único sabor de drag. Habilita
1127    /// fling-desde-drag (el caller arranca un ticker con esa velocidad y la
1128    /// decae con [`fling_step`]).
1129    pub drag_velocity: Option<DragVelocityFn<Msg>>,
1130    /// Payload `u64` que viaja con el drag iniciado sobre este nodo. Lo
1131    /// recibe el handler [`Self::on_drop`] del drop target. Sin payload,
1132    /// el drag funciona igual pero ningún drop target reacciona.
1133    pub drag_payload: Option<u64>,
1134    /// Qué se ve mientras este nodo viaja arrastrado (ver [`CargaArrastre`]).
1135    /// Sin esto el arrastre es invisible, que es como estaba.
1136    pub drag_carga: Option<Arc<CargaArrastre>>,
1137    /// Handler invocado al soltar un drag sobre este nodo (drop target).
1138    pub on_drop: Option<DropFn<Msg>>,
1139    /// Color a pintar mientras un drag activo está hovereando este drop
1140    /// target. Sobrepone a `fill`/`hover_fill` cuando aplica.
1141    pub drop_hover_fill: Option<Color>,
1142    /// **El resorte**: qué emite este drop target cuando un arrastre se queda
1143    /// POSADO encima suyo un rato, sin soltar todavía.
1144    ///
1145    /// Es el gesto de las carpetas con resorte («spring-loaded folders»): para
1146    /// soltar algo *adentro* de un contenedor plegado hay que abrirlo antes, y
1147    /// con el botón apretado no se puede clickear. El runtime cuenta el tiempo
1148    /// que el puntero lleva sobre ESTE nodo con un arrastre activo y, al pasar
1149    /// el umbral, emite el `Msg` **una sola vez** por posada.
1150    ///
1151    /// El mensaje tiene que ser **idempotente** («abrí esto»), no un toggle: el
1152    /// runtime no sabe si el destino ya estaba abierto, y un toggle cerraría
1153    /// justo el contenedor sobre el que estás por soltar.
1154    pub on_drop_hover: Option<Msg>,
1155    /// Si `Some([top, right, bottom, left])`, recorta los descendientes a un
1156    /// rect ENCOGIDO por esos insets (px) desde el rect del nodo — modela
1157    /// `clip-path: inset(...)`. Implica clip aunque `clip == false`.
1158    pub clip_inset: Option<[f32; 4]>,
1159    /// Si `Some(spec)` (14 floats), recorta los descendientes a una ELIPSE —
1160    /// modela `clip-path: circle()`/`ellipse()`. El centro (4) se resuelve
1161    /// contra el rect: `cx = cx_px + cx_pct/100·w`, `cy = cy_px +
1162    /// cy_pct/100·h`. Cada radio (5: `[px, pct_w, pct_h, pct_diag, side]`) con
1163    /// `side == 0` suma `px + pct_w/100·w + pct_h/100·h + pct_diag/100·diag`
1164    /// (`diag = √(w²+h²)/√2`); con `side != 0` se computa desde la distancia
1165    /// del centro a los bordes (`1`/`2` = closest/farthest sobre los 4 lados;
1166    /// `3`/`4` = ídem sobre el eje del radio). Layout: `[cx×2, cy×2, rx×5,
1167    /// ry×5]`. Implica clip aunque `clip == false`. Si conviven `clip_inset` y
1168    /// `clip_ellipse`, gana la elipse (una sola capa de recorte por nodo).
1169    pub clip_ellipse: Option<[f32; 14]>,
1170    /// Si `Some((evenodd, puntos))`, recorta los descendientes a un POLÍGONO —
1171    /// modela `clip-path: polygon()`. Cada punto `[x_px, x_pct, y_px, y_pct]`
1172    /// resuelve `(x_px + x_pct/100·w, y_px + y_pct/100·h)` contra el rect.
1173    /// `evenodd` elige la regla de relleno. Implica clip aunque `clip ==
1174    /// false`. Prioridad de recorte por nodo: polygon > elipse > inset > rect.
1175    pub clip_polygon: Option<(bool, Vec<[f32; 4]>)>,
1176    /// Si `Some((evenodd, d))`, recorta los descendientes a un PATH SVG —
1177    /// modela `clip-path: path()`. `d` es el string SVG crudo (user units px,
1178    /// relativos al origen del rect); el pintado lo parsea con
1179    /// `BezPath::from_svg` y lo traslada al origen del nodo. Si el parseo
1180    /// falla, no recorta. Implica clip aunque `clip == false`. Prioridad:
1181    /// path > polygon > elipse > inset > rect.
1182    pub clip_path_svg: Option<(bool, String)>,
1183    /// Si `Some([t,r,b,l])`, el clip-path se resuelve contra una caja de
1184    /// referencia (`<geometry-box>`) que es el rect del nodo ENCOGIDO por esos
1185    /// insets px (padding-box = border; content-box = border+padding). El
1186    /// pintado lo aplica ANTES de resolver la forma; sin forma, recorta a ese
1187    /// rect. `None` = referencia = border-box (rect completo). Fase 7.1225.
1188    pub clip_ref_inset: Option<[f32; 4]>,
1189    /// Msg a emitir cuando el cursor entra al rect del nodo (transición
1190    /// no-hover → hover). Útil para previews tipo "URL del link al
1191    /// pasar el mouse".
1192    pub on_pointer_enter: Option<Msg>,
1193    /// Msg a emitir cuando el cursor sale del rect del nodo.
1194    pub on_pointer_leave: Option<Msg>,
1195    /// Handler de **movimiento del cursor** sobre el nodo: recibe `(local_x,
1196    /// local_y, rect_w, rect_h)` en CADA `CursorMoved` mientras el cursor está
1197    /// encima (no sólo en la transición de entrada, a diferencia de
1198    /// [`Self::on_pointer_enter`]). Análogo posicional de hover, base de cosas
1199    /// como el thumbnail que sigue al cursor sobre un timeline o un drawer que
1200    /// reacciona a la posición. `None` no dispara update.
1201    pub on_pointer_move_at: Option<ClickAtFn<Msg>>,
1202    /// Handler de rueda local. Si está presente y el cursor cae sobre este
1203    /// nodo, el runtime lo invoca antes del `App::on_wheel` global; un
1204    /// `Some(Msg)` consume el evento. Base de las áreas de scroll
1205    /// autocontenidas. Ver [`ScrollFn`].
1206    pub on_scroll: Option<ScrollFn<Msg>>,
1207    /// Variante con **reparto parcial** del delta (scroll anidado real):
1208    /// consume lo que puede y devuelve el sobrante, que sigue al ancestro.
1209    /// Tiene prioridad sobre `on_scroll` si ambos están. Ver [`ScrollTakeFn`].
1210    pub on_scroll_take: Option<ScrollTakeFn<Msg>>,
1211    /// Handler de gesto de **escala** (pinch-to-zoom). Si está presente y el
1212    /// gesto cae sobre este nodo (Ctrl+rueda en desktop, pinch de trackpad en
1213    /// macOS), el runtime lo invoca con el factor incremental + el punto focal
1214    /// local. Base del zoom de canvases. Ver [`ScaleFn`] y [`View::on_scale`].
1215    pub on_scale: Option<ScaleFn<Msg>>,
1216    /// Handler de gesto de **rotación** (dos dedos en trackpad, macOS). Si
1217    /// está presente y el gesto cae sobre este nodo, el runtime lo invoca con
1218    /// el delta de ángulo incremental (radianes) + el punto focal local. Ver
1219    /// [`RotateFn`] y [`View::on_rotate`].
1220    pub on_rotate: Option<RotateFn<Msg>>,
1221    /// Msg a emitir en **doble-tap** (dos presses izquierdos sobre este nodo
1222    /// dentro de una ventana temporal corta y muy cerca). Es un evento
1223    /// **aditivo**: si el nodo también tiene `on_click`, éste igual dispara en
1224    /// cada press; el doble-tap llega además en el segundo. Para doble-tap
1225    /// exclusivo, pon el handler en un nodo sin `on_click`. Ver
1226    /// [`View::on_double_tap`].
1227    pub on_double_tap: Option<Msg>,
1228    /// Variante posicional de [`Self::on_double_tap`]: recibe la posición del
1229    /// segundo tap relativa al rect del nodo (para zoom-to-point, etc.). Gana
1230    /// sobre `on_double_tap` si ambos están.
1231    pub on_double_tap_at: Option<ClickAtFn<Msg>>,
1232    /// El doble-tap de este nodo **arbitra contra su propio click**: deja de ser
1233    /// aditivo. El runtime retiene el click del primer tap hasta que venza la
1234    /// ventana del doble (`llimphi_ui::doble_tap_califica`) y, si llega el
1235    /// segundo, lo **descarta** — así el doble-tap dispara su `Msg` y nada más.
1236    ///
1237    /// Existe porque el diente de un rag-sidebar hace las dos cosas con el mismo
1238    /// botón: un click despliega su panel, dos fijan el espacio. Sin arbitrar,
1239    /// un doble-click desplegaba y replegaba el panel de paso — y en FIJO eso
1240    /// suelta y vuelve a tomar la franja reservada del escritorio, que se ve.
1241    /// Es **opt-in** justamente porque cuesta latencia: el que no lo pide (una
1242    /// fila de árbol que selecciona con uno y abre con dos, el texto de una
1243    /// terminal) sigue con el doble-tap aditivo y su click instantáneo.
1244    pub double_tap_exclusive: Option<()>,
1245    /// Msg a emitir en **long-press** (mantener el botón izquierdo sobre este
1246    /// nodo ~500 ms sin moverse ni soltar). El runtime lo arbitra por tiempo:
1247    /// si el cursor se aleja (pasó a drag/scroll) o se suelta antes, se
1248    /// cancela. Evento **aditivo** (ver [`Self::on_double_tap`]); el caso
1249    /// limpio es un nodo con drag-to-pan + long-press y sin `on_click` (un
1250    /// canvas). Útil para menús contextuales táctiles / selección. Ver
1251    /// [`View::on_long_press`].
1252    pub on_long_press: Option<Msg>,
1253    /// Variante posicional de [`Self::on_long_press`]: recibe la posición del
1254    /// press relativa al rect del nodo (para abrir el menú en el punto). Gana
1255    /// sobre `on_long_press` si ambos están.
1256    pub on_long_press_at: Option<ClickAtFn<Msg>>,
1257    /// Marca este nodo como **enfocable** con el id opaco `u64`. El runtime
1258    /// mantiene el foco (uno por ventana) y lo mueve con Tab/Shift+Tab en
1259    /// orden de árbol (pre-orden) y al clickear un nodo enfocable; notifica
1260    /// a la app vía `App::on_focus` para que pinte el ring y rutee el
1261    /// teclado. El id lo elige el caller (índice de campo, hash, etc.).
1262    pub focusable: Option<u64>,
1263    /// Marca este nodo de **texto** como seleccionable con el mouse fuera del
1264    /// editor (arrastrar resalta, Ctrl/Cmd+C copia). El `u64` es una **key
1265    /// estable** entre rebuilds del `View` (los `NodeId` de taffy cambian cada
1266    /// frame, así que la selección retenida en el runtime se ancla a esta key,
1267    /// igual que `animated`). Sólo tiene efecto en nodos con `text` uniforme
1268    /// (no `runs`/`spans`). Ver [`View::selectable`].
1269    pub text_select_key: Option<u64>,
1270    /// Animación **implícita** de las props de paint (fill/radius): cuando el
1271    /// valor cambia entre frames, el runtime interpola en vez de saltar. `None`
1272    /// = sin animación (la abrumadora mayoría). La `key` debe ser estable entre
1273    /// rebuilds. Ver [`Anim`] y [`View::animated`]. Lo consume el runtime vía
1274    /// [`AnimRegistry::reconcile`] (DESPUÉS de layout, ANTES de paint).
1275    pub anim: Option<Anim>,
1276    /// **Animación implícita de tamaño** (Flutter `AnimatedSize` /
1277    /// Compose `animateContentSize()`). `None` = sin animación. La key
1278    /// debe ser estable entre rebuilds. A diferencia de [`Self::anim`]
1279    /// (props de paint, reconcilia DESPUÉS de layout), el tamaño tiene
1280    /// que estar firme **antes** del layout — siblings/hijos dependen
1281    /// del rect del nodo. El runtime llama
1282    /// [`reconcile_size_anim`] sobre el `View` tree **antes** de
1283    /// `mount` y parcha `style.size` con el valor interpolado. Sólo se
1284    /// activa si ambos `style.size.width` y `style.size.height` son
1285    /// `Dimension::Length(_)`. Ver [`SizeAnim`] y [`View::animated_size`].
1286    pub animated_size: Option<SizeAnim>,
1287    /// **Hero shared-element**: marca este nodo como una identidad estable
1288    /// entre frames. Si la misma `key` aparece en otra posición en un frame
1289    /// siguiente, el runtime interpola `transform` para "volar" del rect
1290    /// anterior al actual durante la `duration` declarada. Ver
1291    /// [`Hero`] y [`HeroRegistry`]. `None` = sin hero (la abrumadora mayoría).
1292    pub hero: Option<Hero>,
1293    /// Transformación afín 2D aplicada a este nodo y todo su subtree
1294    /// **alrededor del centro de su propio rect** (convención CSS
1295    /// `transform-origin: 50% 50%`). El runtime resuelve el centro en
1296    /// `paint` (sólo entonces conoce el layout computado) y compone
1297    /// `T(centro) · transform · T(-centro)` sobre la transformación
1298    /// acumulada del padre, así nodos anidados transforman en el espacio
1299    /// ya transformado de su ancestro — igual que CSS. `None` = identidad
1300    /// (la abrumadora mayoría de nodos). Pensado para `transform`/
1301    /// `@keyframes` CSS de puriy (rotate/scale/translate). El hit-test
1302    /// **respeta** el afín (un nodo transformado recibe clicks donde se ve
1303    /// pintado). Limitación restante: los `painter`/`runs` custom no heredan
1304    /// el afín, y la posición local que reciben los handlers `*_at` se
1305    /// reporta en espacio de pantalla, no en el espacio local del nodo.
1306    pub transform: Option<Affine>,
1307    /// Traslación RELATIVA al tamaño del propio nodo, en fracciones de su rect
1308    /// computado: `(fx, fy)` ⇒ desplaza `(fx · w, fy · h)` px. Se resuelve en
1309    /// `paint`/`hit_test` (única instancia donde se conoce el tamaño usado) y
1310    /// se compone como el factor más externo del afín del nodo, ANTES del
1311    /// centrado por `transform-origin`. Pensado para el `translate(<%>)` de CSS
1312    /// (p. ej. el truco de centrado `translate(-50%, -50%)` ⇒ `(-0.5, -0.5)`),
1313    /// que no es expresable como `Affine` fijo porque el % depende del layout.
1314    /// `None` = sin traslación relativa (la abrumadora mayoría). Compone con
1315    /// `transform` (afín fijo) si ambos están: `T_rel · transform`.
1316    pub transform_rel: Option<(f64, f64)>,
1317    /// Punto de pivote de `transform` (CSS `transform-origin`). `None` ⇒ el
1318    /// default CSS `50% 50%` (centro del rect) — el caso mayoritario. Ver
1319    /// [`TransformPivot`] y [`View::transform_origin`].
1320    pub transform_origin: Option<TransformPivot>,
1321    /// `position: sticky` (CSS): insets `[top, right, bottom, left]` en px
1322    /// relativos al **scrollport** (el ancestro que recorta, `clip==true`).
1323    /// El nodo se pinta en su posición de flujo normal hasta que el scroll lo
1324    /// lleva al umbral del inset; ahí se "pega" a esa distancia del borde del
1325    /// scrollport, sin salir de él. Como el scroll ya está horneado en el
1326    /// layout (el rect del nodo refleja el desplazamiento), `paint` resuelve el
1327    /// enganche con pura aritmética de rects — no necesita conocer el offset de
1328    /// scroll. `None` ⇒ nodo no-sticky (la abrumadora mayoría). Ver
1329    /// [`View::sticky`]. Limitación v1: el clamp usa el scrollport como caja
1330    /// contenedora (no el bloque contenedor CSS exacto); alcanza para el caso
1331    /// canónico (header/columna sticky a nivel de página).
1332    pub sticky: Option<[Option<f32>; 4]>,
1333    /// Texto de **tooltip**: si está, el runtime/cliente puede mostrar un
1334    /// rótulo flotante cuando el cursor se posa sobre este nodo. Llimphi sólo
1335    /// transporta el dato hasta el [`MountedNode`]; *quién* lo pinta (un overlay
1336    /// del runtime, una surface popup del cliente) lo decide el consumidor. El
1337    /// hit-test de hover ya localiza el nodo bajo el cursor. `None` = sin tip.
1338    pub tooltip: Option<String>,
1339    /// Forma del puntero del mouse mientras está sobre este nodo (o un
1340    /// descendiente sin cursor propio — se hereda del ancestro más cercano que
1341    /// lo declare). El runtime lo resuelve en el hit-test de hover y lo aplica a
1342    /// la ventana. `None` = hereda (default flecha en la raíz). Ver [`Cursor`] y
1343    /// [`View::cursor`]. Llimphi-native (sin winit); el runtime lo mapea.
1344    pub cursor: Option<Cursor>,
1345    /// Feedback de tap **ripple/InkWell**: al presionar este nodo, el runtime
1346    /// emite una salpicadura Material (círculo que se expande desde el punto y
1347    /// se desvanece, recortado al contorno del nodo). Es puro feedback visual,
1348    /// aditivo al `on_click`; vive en el runtime ([`RippleRegistry`]), no en el
1349    /// `Model`. `None` = sin ripple. Ver [`View::ripple`].
1350    pub ripple: Option<Ripple>,
1351    /// Constructor **diferido** sensible al tamaño (`LayoutBuilder`). Si está
1352    /// presente, este nodo NO usa sus `children` estáticos: el runtime resuelve
1353    /// su slot en una primera pasada de layout y luego invoca esta closure con
1354    /// las [`Constraints`] resueltas para producir el subárbol. `None` = nodo
1355    /// normal (la abrumadora mayoría). Ver [`View::layout_builder`].
1356    pub layout_builder: Option<LayoutBuilderFn<Msg>>,
1357    /// Backdrop blur sobre el contenido pintado **debajo** de este nodo.
1358    /// Ver [`View::backdrop_blur`] / [`MountedNode::backdrop_blur`]. v1:
1359    /// sólo se aplica a nodos top-level sin clip/alpha ancestral.
1360    pub backdrop_blur: Option<f32>,
1361    /// Filtros CSS (`filter: …`) sobre el propio subárbol del nodo. Vacío = sin
1362    /// filtro. Ver [`View::filter`] / [`FilterOp`]. Fase 7.1232.
1363    pub filter: Vec<FilterOp>,
1364    /// **Modo de mezcla** del nodo entero contra su backdrop (CSS
1365    /// `mix-blend-mode`). `Some(bm)` ⇒ el subárbol del nodo se rasteriza en una
1366    /// capa aislada (`scene.push_layer(bm, …)` alrededor del rect) y se mezcla
1367    /// con el modo `bm` contra todo lo pintado antes en el stacking context.
1368    /// `None` = source-over normal (la abrumadora mayoría). Ver [`View::blend`].
1369    /// Fase 7.1237.
1370    pub blend: Option<BlendMode>,
1371    /// **RepaintBoundary** (Bloque 23 de `PLAN-NUCLEO-RETENIDO.md`): marca este
1372    /// subárbol como candidato a **cachear su rasterización** entre frames. Si
1373    /// el subárbol es paint-puro (sin painter/gpu/over/anim/ripple/gradiente/
1374    /// imagen/hover adentro) y está en una posición "limpia" (sin transform/clip/
1375    /// alpha ancestral), el runtime rasteriza su sub-escena una vez y la **reusa**
1376    /// (append trasladado) mientras su contenido no cambie — evitando re-pintarlo.
1377    /// `key` estable entre rebuilds. `None` = nodo normal (la abrumadora mayoría).
1378    /// Ver [`View::repaint_boundary`].
1379    pub repaint_boundary: Option<u64>,
1380    /// **Memo** (Bloque 25): si está presente, el contenido de este nodo se
1381    /// construye vía el `builder` diferido y se **reusa entre frames** mientras
1382    /// `deps` no cambie (no se reconstruye el `View` ni se re-monta/re-layoutea).
1383    /// El nodo debe tener un `Style` con tamaño definido. `None` = nodo normal.
1384    /// Ver [`View::memo`].
1385    pub memo: Option<MemoNode<Msg>>,
1386}
1387
1388impl<Msg> Default for ViewRare<Msg> {
1389    fn default() -> Self {
1390        Self {
1391            intrinsic: None,
1392            corner_radii: None,
1393            shadow: None,
1394            fill_gradient: None,
1395            fill_gradient_superficie: false,
1396            border: None,
1397            image: None,
1398            image_fit: None,
1399            mask_image: None,
1400            mask_placement: None,
1401            mask_extra: Vec::new(),
1402            gpu_painter: None,
1403            over_painter: None,
1404            on_click_at: None,
1405            on_click_screen: None,
1406            on_right_click: None,
1407            on_right_click_at: None,
1408            on_right_click_screen: None,
1409            on_middle_click: None,
1410            drag: None,
1411            drag_at: None,
1412            drag_velocity: None,
1413            drag_payload: None,
1414            drag_carga: None,
1415            on_drop: None,
1416            drop_hover_fill: None,
1417            on_drop_hover: None,
1418            clip_inset: None,
1419            clip_ellipse: None,
1420            clip_polygon: None,
1421            clip_path_svg: None,
1422            clip_ref_inset: None,
1423            on_pointer_enter: None,
1424            on_pointer_leave: None,
1425            on_pointer_move_at: None,
1426            on_scroll: None,
1427            on_scroll_take: None,
1428            on_scale: None,
1429            on_rotate: None,
1430            on_double_tap: None,
1431            on_double_tap_at: None,
1432            double_tap_exclusive: None,
1433            on_long_press: None,
1434            on_long_press_at: None,
1435            focusable: None,
1436            text_select_key: None,
1437            anim: None,
1438            animated_size: None,
1439            hero: None,
1440            transform: None,
1441            transform_rel: None,
1442            transform_origin: None,
1443            sticky: None,
1444            tooltip: None,
1445            cursor: None,
1446            ripple: None,
1447            layout_builder: None,
1448            backdrop_blur: None,
1449            filter: Vec::new(),
1450            blend: None,
1451            repaint_boundary: None,
1452            memo: None,
1453        }
1454    }
1455}
1456
1457impl<Msg: 'static> View<Msg> {
1458    /// Transforma el `Msg` de **todo el árbol** vía `f`, devolviendo
1459    /// `View<Msg2>`. Es la pieza que permite **embeber el `view` de un sub-app**
1460    /// en un host (junto con [`crate::Handle::lift`] para sus efectos): el host
1461    /// pinta `sub_view.map(Msg::Sub)` y los eventos del sub-árbol vuelven como
1462    /// su propio `Msg`. Patrón estándar de anidado Elm. `f` se comparte (`Arc`)
1463    /// entre todos los callbacks e hijos, así que debe ser `Send + Sync`.
1464    pub fn map<Msg2, F>(self, f: F) -> View<Msg2>
1465    where
1466        Msg2: 'static,
1467        F: Fn(Msg) -> Msg2 + Send + Sync + 'static,
1468    {
1469        self.map_shared(Arc::new(f))
1470    }
1471
1472    fn map_shared<Msg2: 'static>(
1473        self,
1474        f: Arc<dyn Fn(Msg) -> Msg2 + Send + Sync>,
1475    ) -> View<Msg2> {
1476        let View {
1477            style,
1478            text,
1479            painter,
1480            alpha,
1481            semantics,
1482            fill,
1483            hover_fill,
1484            radius,
1485            clip,
1486            over_layer,
1487            on_click,
1488            children,
1489            rare,
1490        } = self;
1491        // Wrappers: cada callback que produce `Option<Msg>` se reenvía y su
1492        // resultado se eleva con `f`. `f` se clona por callback (todos comparten
1493        // el mismo `Arc`).
1494        View {
1495            style,
1496            fill,
1497            hover_fill,
1498            radius,
1499            clip,
1500            over_layer,
1501            text,
1502            painter,
1503            alpha,
1504            semantics,
1505            on_click: on_click.map(|m| f(m)),
1506            children: children
1507                .into_iter()
1508                .map(|c| c.map_shared(f.clone()))
1509                .collect(),
1510            // Props raras: se mapean SÓLO si el nodo las tiene. Así un subárbol
1511            // mapeado conserva el `None` y no paga la caja (ver [`ViewRare`]).
1512            rare: rare.map(move |caja| {
1513                let ViewRare {
1514                    intrinsic,
1515                    corner_radii,
1516                    shadow,
1517                    fill_gradient,
1518                    fill_gradient_superficie,
1519                    border,
1520                    image,
1521                    image_fit,
1522                    mask_image,
1523                    mask_placement,
1524                    mask_extra,
1525                    gpu_painter,
1526                    over_painter,
1527                    on_click_at,
1528                    on_click_screen,
1529                    on_right_click,
1530                    on_right_click_at,
1531                    on_right_click_screen,
1532                    on_middle_click,
1533                    drag,
1534                    drag_at,
1535                    drag_velocity,
1536                    drag_payload,
1537                    drag_carga,
1538                    on_drop,
1539                    drop_hover_fill,
1540                    on_drop_hover,
1541                    clip_inset,
1542                    clip_ellipse,
1543                    clip_polygon,
1544                    clip_path_svg,
1545                    clip_ref_inset,
1546                    on_pointer_enter,
1547                    on_pointer_leave,
1548                    on_pointer_move_at,
1549                    on_scroll,
1550                    on_scroll_take,
1551                    on_scale,
1552                    on_rotate,
1553                    on_double_tap,
1554                    on_double_tap_at,
1555                    on_long_press,
1556                    on_long_press_at,
1557                    focusable,
1558                    text_select_key,
1559                    anim,
1560                    animated_size,
1561                    hero,
1562                    transform,
1563                    transform_rel,
1564                    transform_origin,
1565                    sticky,
1566                    tooltip,
1567                    cursor,
1568                    ripple,
1569                    layout_builder,
1570                    backdrop_blur,
1571                    filter,
1572                    blend,
1573                    repaint_boundary,
1574                    memo,
1575                    double_tap_exclusive,
1576                } = *caja;
1577                Box::new(ViewRare {
1578                    intrinsic,
1579                    corner_radii,
1580                    shadow,
1581                    fill_gradient,
1582                    fill_gradient_superficie,
1583                    border,
1584                    image,
1585                    image_fit,
1586                    mask_image,
1587                    mask_placement,
1588                    mask_extra,
1589                    gpu_painter,
1590                    over_painter,
1591                    on_click_at: on_click_at.map(|h| {
1592                        let f = f.clone();
1593                        Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
1594                    }),
1595                    on_click_screen: on_click_screen.map(|h| {
1596                        let f = f.clone();
1597                        Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
1598                    }),
1599                    on_right_click: on_right_click.map(|m| f(m)),
1600                    on_right_click_at: on_right_click_at.map(|h| {
1601                        let f = f.clone();
1602                        Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
1603                    }),
1604                    on_right_click_screen: on_right_click_screen.map(|h| {
1605                        let f = f.clone();
1606                        Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
1607                    }),
1608                    on_middle_click: on_middle_click.map(|m| f(m)),
1609                    drag: drag.map(|h| {
1610                        let f = f.clone();
1611                        Arc::new(move |p, dx, dy| h(p, dx, dy).map(|m| f(m))) as DragFn<Msg2>
1612                    }),
1613                    drag_at: drag_at.map(|h| {
1614                        let f = f.clone();
1615                        Arc::new(move |p, dx, dy, lx, ly| h(p, dx, dy, lx, ly).map(|m| f(m)))
1616                            as DragAtFn<Msg2>
1617                    }),
1618                    drag_velocity: drag_velocity.map(|h| {
1619                        let f = f.clone();
1620                        Arc::new(move |p, dx, dy, vx, vy| h(p, dx, dy, vx, vy).map(|m| f(m)))
1621                            as DragVelocityFn<Msg2>
1622                    }),
1623                    drag_payload,
1624                    drag_carga,
1625                    on_drop: on_drop.map(|h| {
1626                        let f = f.clone();
1627                        Arc::new(move |payload| h(payload).map(|m| f(m))) as DropFn<Msg2>
1628                    }),
1629                    drop_hover_fill,
1630                    on_drop_hover: on_drop_hover.map(|m| f(m)),
1631                    clip_inset,
1632                    clip_ellipse,
1633                    clip_polygon,
1634                    clip_path_svg,
1635                    clip_ref_inset,
1636                    on_pointer_enter: on_pointer_enter.map(|m| f(m)),
1637                    on_pointer_leave: on_pointer_leave.map(|m| f(m)),
1638                    on_pointer_move_at: on_pointer_move_at.map(|h| {
1639                        let f = f.clone();
1640                        Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
1641                    }),
1642                    on_scroll: on_scroll.map(|h| {
1643                        let f = f.clone();
1644                        Arc::new(move |dx, dy| h(dx, dy).map(|m| f(m))) as ScrollFn<Msg2>
1645                    }),
1646                    on_scroll_take: on_scroll_take.map(|h| {
1647                        let f = f.clone();
1648                        Arc::new(move |dx, dy| {
1649                            let (msg, rx, ry) = h(dx, dy);
1650                            (msg.map(|m| f(m)), rx, ry)
1651                        }) as ScrollTakeFn<Msg2>
1652                    }),
1653                    on_scale: on_scale.map(|h| {
1654                        let f = f.clone();
1655                        Arc::new(move |ph, s, cx, cy| h(ph, s, cx, cy).map(|m| f(m))) as ScaleFn<Msg2>
1656                    }),
1657                    on_rotate: on_rotate.map(|h| {
1658                        let f = f.clone();
1659                        Arc::new(move |ph, r, cx, cy| h(ph, r, cx, cy).map(|m| f(m))) as RotateFn<Msg2>
1660                    }),
1661                    // — layout_builder produce un View<Msg>: recursa el map —
1662                    on_double_tap: on_double_tap.map(|m| f(m)),
1663                    on_double_tap_at: on_double_tap_at.map(|h| {
1664                        let f = f.clone();
1665                        Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
1666                    }),
1667                    on_long_press: on_long_press.map(|m| f(m)),
1668                    // — ClickAtFn (lx, ly, w, h) —
1669                    on_long_press_at: on_long_press_at.map(|h| {
1670                        let f = f.clone();
1671                        Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
1672                    }),
1673                    // — drag / scroll / gestos —
1674                    focusable,
1675                    text_select_key,
1676                    anim,
1677                    animated_size,
1678                    hero,
1679                    transform,
1680                    transform_rel,
1681                    transform_origin,
1682                    sticky,
1683                    tooltip,
1684                    cursor,
1685                    ripple,
1686                    layout_builder: layout_builder.map(|h| {
1687                        let f = f.clone();
1688                        Arc::new(move |c| h(c).map_shared(f.clone())) as LayoutBuilderFn<Msg2>
1689                    }),
1690                    // — hijos: recursión —
1691                    backdrop_blur,
1692                    filter,
1693                    blend,
1694                    repaint_boundary,
1695                    // El builder del memo produce `View<Msg>`; lo envolvemos para que
1696                    // produzca `View<Msg2>` mapeando su resultado con `f`.
1697                    memo: memo.map(|m| MemoNode {
1698                        key: m.key,
1699                        deps: m.deps,
1700                        builder: m.builder.map(|b| {
1701                            let f = f.clone();
1702                            Arc::new(move || b().map_shared(f.clone())) as MemoBuilderFn<Msg2>
1703                        }),
1704                    }),
1705                    double_tap_exclusive,
1706                    // — Msg simples —
1707                })
1708            }),
1709        }
1710    }
1711}
1712
1713/// Versión "instalada" del árbol: cada nodo tiene su NodeId de taffy, color
1714/// y handler. Se mantiene en orden de inserción (recorrido pre-orden), así
1715/// el hit-test puede iterar al revés para honrar el orden de pintado.
1716///
1717/// `pub` (con campos `pub`) porque el runtime (llimphi-ui) lee el árbol
1718/// montado para hit-test y para la pasada GPU directa, pero vive en otro
1719/// crate. No se construye fuera de [`mount`].
1720pub struct Mounted<Msg> {
1721    pub root: NodeId,
1722    pub nodes: Vec<MountedNode<Msg>>,
1723    /// Contenido de texto por nodo-hoja, para que el runtime lo mida con
1724    /// parley durante `compute_with_measure` y taffy reserve el alto real
1725    /// del texto envuelto (varias líneas) en vez de una sola. Sin esto un
1726    /// párrafo que envuelve a N líneas se aplastaría en la altura de una
1727    /// (el bug clásico de "textos aplastados"). Sólo se pueblan hojas con
1728    /// texto uniforme (sin `runs` multicolor, que el caller dimensiona).
1729    pub text_measures: HashMap<NodeId, TextMeasure>,
1730    /// Tamaño INTRÍNSECO por nodo-hoja de contenido reemplazado (una imagen),
1731    /// para que el runtime lo mida igual que mide el texto. Ver
1732    /// [`IntrinsicSize`] y [`measure_intrinsic_node`].
1733    pub intrinsic_measures: HashMap<NodeId, IntrinsicSize>,
1734}
1735
1736/// Tamaño natural de un contenido REEMPLAZADO (hoy: una imagen), para que el
1737/// layout lo trate como CSS trata a un `<img>`: prefiere su tamaño natural,
1738/// nunca pasa del espacio disponible, y **puede encogerse hasta 0**.
1739///
1740/// Existe porque un `Style` solo no puede expresar eso. Una hoja con `size`
1741/// definido aporta ese mismo ancho a min-content Y a max-content: el contenedor
1742/// que la tiene adentro no puede encoger por debajo del ancho natural de la
1743/// imagen. Y una hoja con `size` en porcentaje aporta 0 a los DOS: encoge bien,
1744/// pero adentro de un padre shrink-to-fit (un `float`, un `inline-block`) el
1745/// padre mide 0 y la imagen desaparece. Las dos se midieron sobre el corpus de
1746/// puriy y cada una rompe una punta.
1747///
1748/// Con la medida, la hoja aporta **0 a min-content y su ancho natural a
1749/// max-content**, que es lo que hace un browser. Es el mismo mecanismo que
1750/// [`TextMeasure`], que ya existía para el texto.
1751#[derive(Clone, Copy, Debug, PartialEq)]
1752pub struct IntrinsicSize {
1753    pub width: f32,
1754    pub height: f32,
1755}
1756
1757/// Datos de un nodo-hoja de texto necesarios para medirlo (shaping +
1758/// line-break) sin volver a tocar el `View`. Lo consume el runtime en la
1759/// función de medición que le pasa a [`LayoutTree::compute_with_measure`].
1760#[derive(Clone)]
1761pub struct TextMeasure {
1762    pub content: String,
1763    pub size_px: f32,
1764    pub alignment: llimphi_text::Alignment,
1765    pub italic: bool,
1766    pub font_family: Option<String>,
1767    pub line_height: f32,
1768    pub weight: f32,
1769    pub max_lines: Option<usize>,
1770    pub ellipsis: bool,
1771    /// Idem [`TextSpec::underline`]. Se replica en la medida porque parley
1772    /// no cambia de ancho con decoración (no toca el shaping); pero la clave
1773    /// del caché de shaping sí cambia, y queremos que medida y pintado
1774    /// peguen la misma entrada del caché.
1775    pub underline: bool,
1776    /// Idem [`TextSpec::strikethrough`]. Mismo razonamiento que `underline`.
1777    pub strikethrough: bool,
1778    /// Idem [`TextSpec::spans`]. La medida usa el mismo
1779    /// `Typesetter::layout_spans` que el pintado, así taffy reserva el alto
1780    /// real considerando overrides de `size_px` por span (un `<h1>` inline
1781    /// dentro de un párrafo agranda esa línea). `None`/`vacío` = medir con
1782    /// `layout_clamped` (camino uniforme).
1783    pub spans: Option<Vec<llimphi_text::TextSpan>>,
1784    /// Idem [`TextSpec::letter_spacing`]. Entra en la medida porque cambia el
1785    /// ancho del shaping (y la clave del caché).
1786    pub letter_spacing: f32,
1787    /// Idem [`TextSpec::word_spacing`]. Mismo razonamiento que `letter_spacing`.
1788    pub word_spacing: f32,
1789    /// Idem [`TextSpec::no_wrap`]. Entra en la medida porque cambia el ancho
1790    /// reservado: con `no_wrap` el texto se mide en una sola línea (ancho
1791    /// completo) en vez de envolver al `available`.
1792    pub no_wrap: bool,
1793    /// Idem [`TextSpec::overflow_wrap`]. Entra en la medida porque parte la
1794    /// palabra larga: con el flag, el ancho mínimo del bloque deja de estar
1795    /// fijado por el token más ancho.
1796    pub overflow_wrap: bool,
1797    /// Idem [`TextSpec::font_features`]. Entra en la medida porque ligaduras/
1798    /// kerning cambian el ancho del shaping (y la clave del caché).
1799    pub font_features: String,
1800    /// Idem [`TextSpec::font_variations`]. Mismo razonamiento que `font_features`.
1801    pub font_variations: String,
1802    /// Idem [`TextSpec::font_width`]. Entra en la medida porque la face
1803    /// condensada/expandida cambia el ancho del shaping (y la clave del caché).
1804    pub font_width: f32,
1805}
1806
1807/// Cómo encajar una imagen en el rect del nodo (CSS `object-fit` /
1808/// Flutter `BoxFit`). El runtime calcula la escala y el origen
1809/// correspondientes a esta política y siempre recorta al
1810/// `node_rrect` del nodo, así el clip respeta `radius` /
1811/// `corner_radii`.
1812#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1813pub enum ImageFit {
1814    /// Preservar aspect ratio, **caber** dentro del rect (escala =
1815    /// `min(sx, sy)`). Deja banda en el eje menos restrictivo.
1816    /// CSS `object-fit: contain` / Flutter `BoxFit.contain`. **Default
1817    /// histórico** — lo que hacía `View::image()` antes del Bloque 12.
1818    Contain,
1819    /// Preservar aspect ratio, **cubrir** todo el rect (escala =
1820    /// `max(sx, sy)`). Recorta el sobrante en el eje menos
1821    /// restrictivo (el clip al `node_rrect` lo absorbe). CSS
1822    /// `object-fit: cover` / Flutter `BoxFit.cover` — ideal para
1823    /// avatares y hero images.
1824    Cover,
1825    /// Estirar la imagen para ocupar el rect, **sin** preservar
1826    /// aspect ratio (`sx`/`sy` independientes). CSS `object-fit:
1827    /// fill` / Flutter `BoxFit.fill`.
1828    Fill,
1829    /// **No** escalar la imagen — pintarla a su tamaño original,
1830    /// centrada en el rect. Si la imagen excede el rect, el clip al
1831    /// `node_rrect` la recorta. CSS `object-fit: none` / Flutter
1832    /// `BoxFit.none`.
1833    None,
1834}
1835
1836/// Longitud de un eje de [`MaskSize`]/posición de máscara, **sin resolver** —
1837/// el paint la resuelve contra el rect del nodo. Neutral respecto de CSS: el
1838/// frontend (p. ej. puriy) traduce `mask-size`/`mask-position` a esto. Fase
1839/// 7.1227.
1840#[derive(Debug, Clone, Copy, PartialEq)]
1841pub enum MaskLen {
1842    /// Tamaño intrínseco de la imagen (en size) / offset 0 (en position).
1843    Auto,
1844    /// Longitud absoluta en px.
1845    Px(f32),
1846    /// Porcentaje: en size, del lado correspondiente del rect; en position,
1847    /// alineación CSS (el `p%` de la máscara cae sobre el `p%` del rect).
1848    Pct(f32),
1849}
1850
1851/// `mask-size` neutral (espejo de `BackgroundSize`). Ver [`MaskPlacement`].
1852/// Fase 7.1227.
1853#[derive(Debug, Clone, Copy, PartialEq)]
1854pub enum MaskSize {
1855    /// Tamaño intrínseco de la imagen-máscara.
1856    Auto,
1857    /// Escalar preservando aspecto hasta **cubrir** el rect.
1858    Cover,
1859    /// Escalar preservando aspecto hasta **caber** en el rect.
1860    Contain,
1861    /// Tamaño explícito por eje (`Auto` en un eje = derivar por aspecto).
1862    Explicit { x: MaskLen, y: MaskLen },
1863}
1864
1865/// Modo de una máscara (CSS `mask-mode`). Decide qué canal del píxel-máscara
1866/// modula el alpha del contenido. Fase 7.1228.
1867#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1868pub enum MaskMode {
1869    /// La **luminancia** del píxel multiplica el alpha (negro = oculto, blanco =
1870    /// visible). Lo usa CSS para máscaras SVG `<mask>`. Es el default del
1871    /// compositor cuando no hay `MaskPlacement` (camino estirado, Fase 7.1226).
1872    #[default]
1873    Luminance,
1874    /// El **canal alpha** del píxel modula el alpha (transparente = oculto). Es
1875    /// el default CSS para imágenes raster (`mask-mode: match-source`). Se pinta
1876    /// con `Compose::DestIn` en vez de la capa de luminancia.
1877    Alpha,
1878}
1879
1880/// Operador de combinación entre capas de máscara (CSS `mask-composite`). Mapea
1881/// a un `Compose` Porter-Duff de vello cuando una capa extra se compone sobre
1882/// las de abajo. Fase 7.1231.
1883#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1884pub enum MaskCompose {
1885    /// La capa se **suma** sobre las de abajo (source-over). Default CSS.
1886    #[default]
1887    Add,
1888    /// La capa **resta** (source-out: la fuente donde NO solapa el destino).
1889    Subtract,
1890    /// **Intersección** (source-in: la fuente donde solapa el destino).
1891    Intersect,
1892    /// **Exclusión** (xor: las regiones no solapadas de ambas).
1893    Exclude,
1894}
1895
1896/// Encaje y modo de una **máscara** (CSS `mask-size` + `mask-position` +
1897/// `mask-repeat` + `mask-mode`), resuelto contra el rect del nodo en el paint,
1898/// con la misma aritmética que `background-image`. En el [`MountedNode`] viaja
1899/// como `Option`: `None` = estirar la máscara al border-box en modo luminancia
1900/// (comportamiento de la Fase 7.1226). Fase 7.1227 (encaje), 7.1228 (modo).
1901#[derive(Debug, Clone, Copy, PartialEq)]
1902pub struct MaskPlacement {
1903    /// Tamaño del tile.
1904    pub size: MaskSize,
1905    /// Offset/alineación horizontal del primer tile.
1906    pub pos_x: MaskLen,
1907    /// Offset/alineación vertical del primer tile.
1908    pub pos_y: MaskLen,
1909    /// Tilear en X (`mask-repeat` cubre el eje horizontal).
1910    pub repeat_x: bool,
1911    /// Tilear en Y.
1912    pub repeat_y: bool,
1913    /// Canal que modula el alpha (luminancia vs alpha). Fase 7.1228.
1914    pub mode: MaskMode,
1915    /// Insets `[top, right, bottom, left]` px del border-box a la caja de
1916    /// `mask-clip`: el efecto de la máscara se **recorta** a esa caja. `None` =
1917    /// border-box. Fase 7.1230.
1918    pub clip_inset: Option<[f32; 4]>,
1919    /// Insets `[top, right, bottom, left]` px del border-box a la caja de
1920    /// `mask-origin`: size/position/tiling se resuelven contra esa caja. `None`
1921    /// = border-box. Fase 7.1230.
1922    pub origin_inset: Option<[f32; 4]>,
1923}
1924
1925impl Default for ImageFit {
1926    fn default() -> Self {
1927        ImageFit::Contain
1928    }
1929}
1930
1931/// Forma del puntero del mouse. Subconjunto práctico, llimphi-native (el
1932/// compositor no depende de winit). El runtime (`llimphi-ui`) mapea 1:1 a
1933/// `winit::window::CursorIcon`. Nombres alineados con CSS/winit.
1934#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1935pub enum Cursor {
1936    /// Flecha por defecto.
1937    Default,
1938    /// Manito — sobre algo clickeable (links, botones).
1939    Pointer,
1940    /// I-beam — sobre texto editable/seleccionable.
1941    Text,
1942    /// Cruz — selección precisa (canvas, picker de color).
1943    Crosshair,
1944    /// Cuatro flechas — mover un objeto.
1945    Move,
1946    /// Mano abierta — agarrable (antes de arrastrar).
1947    Grab,
1948    /// Mano cerrada — arrastrando.
1949    Grabbing,
1950    /// Prohibido — drop no permitido / acción inválida.
1951    NotAllowed,
1952    /// **Copiar** — un arrastre que va a duplicar, no a mover. Es la mitad
1953    /// que faltaba para que el cursor diga qué va a pasar al soltar.
1954    Copy,
1955    /// **Acá no** — arrastrando sobre algo que no recibe. Distinto de
1956    /// [`Self::NotAllowed`]: aquél es «esta acción no se puede», éste es
1957    /// «este destino no».
1958    NoDrop,
1959    /// Reloj/espera — operación bloqueante.
1960    Wait,
1961    /// Progreso — ocupado pero la UI responde.
1962    Progress,
1963    /// Interrogación — ayuda contextual.
1964    Help,
1965    /// Resize horizontal (columna / divisor vertical).
1966    ColResize,
1967    /// Resize vertical (fila / divisor horizontal).
1968    RowResize,
1969    /// Resize este-oeste.
1970    EwResize,
1971    /// Resize norte-sur.
1972    NsResize,
1973    /// Resize diagonal ↗↙.
1974    NeswResize,
1975    /// Resize diagonal ↖↘.
1976    NwseResize,
1977    /// Lupa + (zoom in).
1978    ZoomIn,
1979    /// Lupa − (zoom out).
1980    ZoomOut,
1981}
1982
1983#[derive(Clone)]
1984pub struct MountedNode<Msg> {
1985    pub id: NodeId,
1986    pub fill: Option<Color>,
1987    pub hover_fill: Option<Color>,
1988    pub radius: f64,
1989    pub corner_radii: Option<RoundedRectRadii>,
1990    pub shadow: Option<Shadow>,
1991    pub fill_gradient: Option<Gradient>,
1992    pub fill_gradient_superficie: bool,
1993    pub border: Option<Border>,
1994    pub text: Option<TextSpec>,
1995    pub image: Option<Image>,
1996    /// Política de encaje de [`Self::image`] (ver [`ImageFit`]). `None`
1997    /// = `Contain`.
1998    pub image_fit: Option<ImageFit>,
1999    /// Máscara de luminancia del subárbol (CSS `mask-image`). Ver
2000    /// [`View::mask_image`]. El paint aísla el subárbol y aplica la luminancia
2001    /// de esta imagen como alpha. `None` = sin máscara.
2002    pub mask_image: Option<Image>,
2003    /// Encaje de [`Self::mask_image`] (size/position/repeat). `None` = estirar
2004    /// al border-box. Ver [`MaskPlacement`]. Fase 7.1227.
2005    pub mask_placement: Option<MaskPlacement>,
2006    /// Capas de máscara adicionales `(imagen, operador)` (ver [`View::mask_extra`]).
2007    /// Comparten el `mask_placement` con la capa 0. Fase 7.1231.
2008    pub mask_extra: Vec<(Image, MaskCompose)>,
2009    pub painter: Option<PaintFn>,
2010    pub gpu_painter: Option<GpuPaintFn>,
2011    pub over_painter: Option<PaintFn>,
2012    /// Subárbol over (ver [`View::over`]): este nodo y sus descendientes
2013    /// pintan en la pasada vello final, después del pase GPU.
2014    pub over_layer: bool,
2015    pub on_click: Option<Msg>,
2016    pub on_click_at: Option<ClickAtFn<Msg>>,
2017    /// Gemelo de izquierda de [`Self::on_right_click_screen`]: click izquierdo con
2018    /// coords **absolutas de ventana**, para anclar un popup (select/dropdown).
2019    pub on_click_screen: Option<ClickAtFn<Msg>>,
2020    pub on_right_click: Option<Msg>,
2021    pub on_right_click_at: Option<ClickAtFn<Msg>>,
2022    pub on_right_click_screen: Option<ClickAtFn<Msg>>,
2023    pub on_middle_click: Option<Msg>,
2024    pub drag: Option<DragFn<Msg>>,
2025    pub drag_at: Option<DragAtFn<Msg>>,
2026    pub drag_velocity: Option<DragVelocityFn<Msg>>,
2027    pub drag_payload: Option<u64>,
2028    pub drag_carga: Option<Arc<CargaArrastre>>,
2029    pub on_drop: Option<DropFn<Msg>>,
2030    pub drop_hover_fill: Option<Color>,
2031    pub on_drop_hover: Option<Msg>,
2032    pub clip: bool,
2033    pub clip_inset: Option<[f32; 4]>,
2034    pub clip_ellipse: Option<[f32; 14]>,
2035    pub clip_polygon: Option<(bool, Vec<[f32; 4]>)>,
2036    pub clip_path_svg: Option<(bool, String)>,
2037    pub clip_ref_inset: Option<[f32; 4]>,
2038    pub on_pointer_enter: Option<Msg>,
2039    pub on_pointer_leave: Option<Msg>,
2040    pub on_pointer_move_at: Option<ClickAtFn<Msg>>,
2041    pub on_scroll: Option<ScrollFn<Msg>>,
2042    /// Handler de scroll con reparto parcial. Ver [`ScrollTakeFn`].
2043    pub on_scroll_take: Option<ScrollTakeFn<Msg>>,
2044    /// Handler de gesto de escala (pinch-to-zoom) de este nodo. Ver
2045    /// [`View::on_scale`] y [`ScaleFn`].
2046    pub on_scale: Option<ScaleFn<Msg>>,
2047    /// Handler de gesto de rotación (trackpad) de este nodo. Ver
2048    /// [`View::on_rotate`] y [`RotateFn`].
2049    pub on_rotate: Option<RotateFn<Msg>>,
2050    /// Handlers de doble-tap (ver [`View::on_double_tap`]).
2051    pub on_double_tap: Option<Msg>,
2052    pub on_double_tap_at: Option<ClickAtFn<Msg>>,
2053    /// Ver [`ViewRare::double_tap_exclusive`]: el runtime lo lee en el press
2054    /// para saber si retiene el click del primer tap.
2055    pub double_tap_exclusive: Option<()>,
2056    /// Handlers de long-press (ver [`View::on_long_press`]).
2057    pub on_long_press: Option<Msg>,
2058    pub on_long_press_at: Option<ClickAtFn<Msg>>,
2059    pub focusable: Option<u64>,
2060    /// Key estable de selección de texto (ver [`View::selectable`]).
2061    pub text_select_key: Option<u64>,
2062    pub alpha: Option<f32>,
2063    pub anim: Option<Anim>,
2064    /// Animación implícita de tamaño (ver [`View::animated_size`]). El
2065    /// runtime ya parchó `style.size` antes del layout — este campo se
2066    /// guarda principalmente para inspección/tests.
2067    pub animated_size: Option<SizeAnim>,
2068    /// Semántica accesible del nodo (ver [`View::semantics`]). El runtime la
2069    /// lee en cada paint para reconstruir el árbol AccessKit del frame.
2070    pub semantics: Option<SemanticsSpec>,
2071    /// Marca de hero shared-element (ver [`View::hero`]). El runtime lo lee
2072    /// en [`HeroRegistry::reconcile`] para enlazar identidad entre frames y
2073    /// escribir `transform` con la afín "fly" cuando el rect cambia.
2074    pub hero: Option<Hero>,
2075    /// Subescena a pintar **DEBAJO** del contenido de este nodo, en su
2076    /// posición z (y dentro del clip/transform de sus ancestros). La escribe
2077    /// el runtime — [`AnimRegistry::prepare_underlays`]: el fantasma del
2078    /// contenido viejo de un `switch` con `exit_under` — no el `View`. El
2079    /// paint la apendea componiendo la afín acumulada de los ancestros,
2080    /// ANTES del transform/alpha propios del nodo (que pertenecen al
2081    /// contenido NUEVO).
2082    pub underlay: Option<Lienzo>,
2083    /// Transformación afín 2D del nodo (alrededor del centro de su rect).
2084    /// Ver [`View::transform`]. `paint` la compone con la del padre.
2085    pub transform: Option<Affine>,
2086    /// Traslación relativa al tamaño del nodo (fracciones de su rect). Ver
2087    /// [`View::transform_rel`]. `paint`/`hit_test` la resuelven contra el rect.
2088    pub transform_rel: Option<(f64, f64)>,
2089    /// Pivote de `transform` (CSS `transform-origin`). `None` ⇒ centro. Ver
2090    /// [`TransformPivot`] / [`View::transform_origin`].
2091    pub transform_origin: Option<TransformPivot>,
2092    /// `position: sticky`: insets `[top, right, bottom, left]` px relativos al
2093    /// scrollport. Ver [`View::sticky`]. `paint` lo resuelve con los rects
2094    /// computados (el scroll ya está en el layout). `None` ⇒ no-sticky.
2095    pub sticky: Option<[Option<f32>; 4]>,
2096    /// Texto de tooltip de este nodo (ver [`View::tooltip`]). El consumidor lo
2097    /// lee tras un hit-test de hover para pintar el rótulo flotante.
2098    pub tooltip: Option<String>,
2099    /// Forma del puntero sobre este nodo (ver [`View::cursor`]). El runtime la
2100    /// resuelve heredando del ancestro más cercano que la declare.
2101    pub cursor: Option<Cursor>,
2102    /// Ripple/InkWell de este nodo (ver [`View::ripple`]). El runtime lo
2103    /// dispara en el press y lo pinta vía [`RippleRegistry`].
2104    pub ripple: Option<Ripple>,
2105    /// `true` si este nodo era un [`View::layout_builder`] (constructor diferido)
2106    /// al montarse. El runtime lo usa tras la primera pasada de layout para leer
2107    /// el rect del slot (vía [`collect_builder_constraints`]) e invocar la
2108    /// closure. Tras expandirse, el nodo final ya es normal (`false`).
2109    pub is_layout_builder: bool,
2110    /// **Backdrop blur** (CSS `backdrop-filter: blur(N)` / Flutter
2111    /// `BackdropFilter`). Sigma del Gauss en pixels; el runtime aplica una
2112    /// pasada separable (H+V) sobre la intermediate restringida al rect del
2113    /// nodo, **antes** de pintar el subárbol del nodo. El subárbol se compone
2114    /// sobre el backdrop ya borroso vía un buffer secundario. `None` = sin
2115    /// blur (la abrumadora mayoría). Limitación v1: el nodo no debe estar
2116    /// dentro de un ancestro con clip/alpha (los subárboles separados pintan
2117    /// fuera de esas capas — documentado en `PARIDAD-FLUTTER.md` Bloque 11).
2118    pub backdrop_blur: Option<f32>,
2119    /// Filtros CSS (`filter: …`) sobre el propio subárbol (ver [`View::filter`]
2120    /// / [`FilterOp`]). El runtime los recolecta con [`collect_filters`] y los
2121    /// aplica como post-pasada GPU sobre la intermediate, restringidos al rect
2122    /// del nodo, **después** de la rasterización. Vacío = sin filtro. Fase
2123    /// 7.1232.
2124    pub filter: Vec<FilterOp>,
2125    /// Modo de mezcla del nodo entero contra su backdrop (CSS `mix-blend-mode`).
2126    /// Ver [`View::blend`] / [`MountedNode`]. `paint_range` abre una capa de
2127    /// blend (`push_layer(bm, …)`) alrededor del rect del nodo que envuelve
2128    /// fill + contenido + hijos y se cierra al fin del subárbol, mezclando el
2129    /// resultado contra lo ya pintado. `None` = source-over. Fase 7.1237.
2130    pub blend: Option<BlendMode>,
2131    /// Key de [`View::repaint_boundary`] si este nodo abre un límite de
2132    /// repintado cacheable. `None` = normal. Lo consume `paint_cached`.
2133    pub repaint_boundary: Option<u64>,
2134    /// Key de [`View::memo`] si este nodo es un marcador de subárbol memoizado
2135    /// (Bloque 25). El runtime lo splica tras el layout. `None` = normal.
2136    pub memo_ref: Option<u64>,
2137    /// Índice (exclusivo) del fin del subárbol en `Mounted::nodes`. Los
2138    /// descendientes ocupan `[idx + 1, subtree_end)`. Hace de "barrera" en
2139    /// paint/hit_test para `pop_layer` y para saltar subárboles enteros.
2140    pub subtree_end: usize,
2141}
2142
2143#[cfg(test)]
2144mod tamanos {
2145    use super::*;
2146
2147    /// Guarda de regresión sobre el tamaño de los dos tipos que se asignan una
2148    /// vez POR NODO en cada frame. El contador de asignaciones de Bloque 21
2149    /// (`LLIMPHI_PERF` + `perf-alloc`) mostró que el heap por frame es ~6 KB por
2150    /// nodo y que el 96 % de esos bytes son `View` + `MountedNode`: no son
2151    /// muchas asignaciones, son POCAS y ENORMES (un `Vec` de hijos de una
2152    /// columna de 71 nodos pesa ~170 KB). El costo no está en el número de
2153    /// viajes al allocator sino en el `size_of` de estos structs, así que
2154    /// cualquier variante gorda que se agregue sin boxear se paga multiplicada
2155    /// por el árbol entero.
2156    ///
2157    /// Los topes son generosos a propósito: esto no persigue un número exacto,
2158    /// avisa cuando alguien duplica el struct sin darse cuenta.
2159    #[test]
2160    fn view_y_mounted_no_engordan_en_silencio() {
2161        let view = core::mem::size_of::<View<()>>();
2162        let mounted = core::mem::size_of::<MountedNode<()>>();
2163        eprintln!("size_of::<View<()>>() = {view} B · size_of::<MountedNode<()>>() = {mounted} B");
2164        // Desglose de los campos candidatos a boxear (palanca 2). `cargo test -p
2165        // llimphi-compositor tamanos -- --nocapture` lo imprime.
2166        macro_rules! p {
2167            ($($t:ty),* $(,)?) => {$(
2168                eprintln!("  {:>5} B  {}", core::mem::size_of::<$t>(), stringify!($t));
2169            )*};
2170        }
2171        eprintln!("  {:>5} B  ViewRare<()>", core::mem::size_of::<ViewRare<()>>());
2172        p!(
2173            Style,
2174            Option<TextSpec>,
2175            Option<Gradient>,
2176            Option<Image>,
2177            Option<Shadow>,
2178            Option<Border>,
2179            Option<SemanticsSpec>,
2180            Option<Anim>,
2181            Option<SizeAnim>,
2182            Option<Ripple>,
2183            Option<Hero>,
2184            Option<MemoNode<()>>,
2185            Option<[f32; 14]>,
2186            Vec<FilterOp>,
2187            Vec<View<()>>,
2188        );
2189        // El tope de `View` es apretado a propósito: 2026-07-28 el reparto
2190        // caliente/[`ViewRare`] lo bajó de 2.472 B a 632, y después el censo
2191        // contra un árbol REAL (`llimphi-gallery --census`) devolvió inline los
2192        // cuatro campos que la mayoría de los nodos usa igual —`text`,
2193        // `painter`, `alpha`, `semantics`—, dejándolo en 920. De esos 920 son
2194        // 544 el `Style` de taffy. Cualquier prop nueva va a `ViewRare` salvo
2195        // que el censo diga otra cosa; este assert es lo que lo hace cumplir.
2196        assert!(view <= 1024, "View<Msg> engordó a {view} B (tope 1024: ¿la prop nueva va en ViewRare?)");
2197        assert!(mounted <= 3072, "MountedNode<Msg> engordó a {mounted} B (tope 3072)");
2198    }
2199}